From d7f552717c693f43ee7f090a568ee6e579657ece Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:00 -0500 Subject: [PATCH 01/38] feat(core): add typed build-time feature flags and featureGuard Introduce a single source of truth for hiding non-MVP areas: a `features` map in both environment files (all off), typed by the `FeatureKey` union and checked with `satisfies FeatureFlagMap`, read through the `FeatureFlags.isEnabled()` service. Tests override flags with `provideFeatureFlags()`. `featureGuard(key, next?)` is a functional canMatch guard: while the flag is off the route does not match, so the URL falls through to the wildcard fallback like an unknown path (no blank page, no lazy chunk). An existing canMatch guard is chained through `next` because Angular runs every guard of a canMatch array eagerly. --- src/app/core/features/feature-flags.ts | 42 ++++++++++++ src/app/core/guards/feature.guard.spec.ts | 82 +++++++++++++++++++++++ src/app/core/guards/feature.guard.ts | 24 +++++++ src/environments/environment.prod.ts | 13 ++++ src/environments/environment.ts | 13 ++++ 5 files changed, 174 insertions(+) create mode 100644 src/app/core/features/feature-flags.ts create mode 100644 src/app/core/guards/feature.guard.spec.ts create mode 100644 src/app/core/guards/feature.guard.ts diff --git a/src/app/core/features/feature-flags.ts b/src/app/core/features/feature-flags.ts new file mode 100644 index 0000000..93567cf --- /dev/null +++ b/src/app/core/features/feature-flags.ts @@ -0,0 +1,42 @@ +import { Injectable, InjectionToken, Provider, inject } from '@angular/core'; +import { environment } from '../../../environments/environment'; + +/** + * The product areas that can be switched off per build. A disabled feature disappears from the + * UI — nav entries, command-palette actions, buttons and CTAs — and its routes stop matching, so + * its URLs behave like unknown paths. The code and the backend stay in place: turning a flag back + * on in the environment files needs no other change. See docs/FEATURE-FLAGS.md. + */ +export type FeatureKey = + 'billing' | 'usage' | 'integrations' | 'members' | 'customRoles' | 'notifications' | 'tokens'; + +/** One on/off switch per {@link FeatureKey}: the shape every environment file must provide. */ +export type FeatureFlagMap = Readonly>; + +/** The active flag map: the build's environment by default (see {@link provideFeatureFlags}). */ +const FEATURE_FLAGS = new InjectionToken('FEATURE_FLAGS', { + providedIn: 'root', + factory: () => environment.features, +}); + +/** + * Overrides individual flags on top of the environment's map, e.g. + * `provideFeatureFlags({ members: true })` in a test's providers. + */ +export function provideFeatureFlags(overrides: Partial): Provider { + return { provide: FEATURE_FLAGS, useValue: { ...environment.features, ...overrides } }; +} + +/** + * Read-only access to the build's feature flags. The map is static for the lifetime of the + * app (it comes from the environment file), so callers can read a flag once into a field. + */ +@Injectable({ providedIn: 'root' }) +export class FeatureFlags { + private readonly flags = inject(FEATURE_FLAGS); + + /** True when `feature` is switched on for this build. */ + isEnabled(feature: FeatureKey): boolean { + return this.flags[feature]; + } +} diff --git a/src/app/core/guards/feature.guard.spec.ts b/src/app/core/guards/feature.guard.spec.ts new file mode 100644 index 0000000..a1af175 --- /dev/null +++ b/src/app/core/guards/feature.guard.spec.ts @@ -0,0 +1,82 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CanMatchFn, Route, Router, UrlSegment, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { provideFeatureFlags } from '../features/feature-flags'; +import { featureGuard } from './feature.guard'; + +@Component({ template: '' }) +class Page {} + +const route = {} as Route; +const segments = [] as UrlSegment[]; + +/** Runs a `CanMatch` guard the way the router does: inside the injection context. */ +function run(guard: CanMatchFn): unknown { + return TestBed.runInInjectionContext(() => guard(route, segments, {} as never)); +} + +describe('featureGuard', () => { + describe('as a guard function', () => { + it('does not match while the feature is off', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ billing: false })] }); + expect(run(featureGuard('billing'))).toBe(false); + }); + + it('matches while the feature is on', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ billing: true })] }); + expect(run(featureGuard('billing'))).toBe(true); + }); + + it('never runs the chained guard while the feature is off', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ members: false })] }); + const next = vi.fn(() => true); + + expect(run(featureGuard('members', next))).toBe(false); + expect(next).not.toHaveBeenCalled(); + }); + + it('defers to the chained guard while the feature is on', () => { + TestBed.configureTestingModule({ providers: [provideFeatureFlags({ members: true })] }); + const next = vi.fn(() => false); + + expect(run(featureGuard('members', next))).toBe(false); + expect(next).toHaveBeenCalledOnce(); + }); + }); + + describe('in a route table', () => { + function navigate(flags: { billing: boolean }, url: string): Promise { + TestBed.configureTestingModule({ + providers: [ + provideFeatureFlags(flags), + provideRouter([ + { path: 'home', component: Page }, + { + path: 'settings', + children: [ + { path: 'general', component: Page }, + { path: 'billing', canMatch: [featureGuard('billing')], component: Page }, + ], + }, + { path: '**', redirectTo: 'home' }, + ]), + ], + }); + const router = TestBed.inject(Router); + return router.navigateByUrl(url).then(() => router.url); + } + + it('makes a disabled route behave like an unknown URL (wildcard fallback)', async () => { + expect(await navigate({ billing: false }, '/settings/billing')).toBe('/home'); + }); + + it('leaves sibling routes of a disabled one untouched', async () => { + expect(await navigate({ billing: false }, '/settings/general')).toBe('/settings/general'); + }); + + it('resolves the route normally once the feature is on', async () => { + expect(await navigate({ billing: true }, '/settings/billing')).toBe('/settings/billing'); + }); + }); +}); diff --git a/src/app/core/guards/feature.guard.ts b/src/app/core/guards/feature.guard.ts new file mode 100644 index 0000000..192985d --- /dev/null +++ b/src/app/core/guards/feature.guard.ts @@ -0,0 +1,24 @@ +import { inject } from '@angular/core'; +import { CanMatchFn } from '@angular/router'; +import { FeatureFlags, FeatureKey } from '../features/feature-flags'; + +/** + * `CanMatch` guard factory for a feature-flagged route. While `feature` is off the route + * simply does not match: the router keeps looking, so the URL falls through to the wildcard + * fallback exactly like an unknown path — no blank page, no "no access" toast, and the lazy + * chunk is never fetched. While it is on, the route matches as usual, or `next` decides. + * + * Pass the route's own `CanMatch` guard (e.g. `requirePermissionMatch`) as `next` instead of + * listing it beside this one: Angular runs every guard of a `canMatch` array eagerly, so a + * sibling permission guard would still fetch permissions — and toast a denial — for a route + * that is switched off. + * + * Usage: `canMatch: [featureGuard('billing')]`, or + * `canMatch: [featureGuard('members', requirePermissionMatch('MEMBER_READ'))]`. + */ +export function featureGuard(feature: FeatureKey, next?: CanMatchFn): CanMatchFn { + return (route, segments, currentSnapshot) => { + if (!inject(FeatureFlags).isEnabled(feature)) return false; + return next ? next(route, segments, currentSnapshot) : true; + }; +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d517e08..24364b2 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,3 +1,5 @@ +import type { FeatureFlagMap } from '../app/core/features/feature-flags'; + export const environment = { production: true, // Empty on purpose: HTTP calls use relative /api paths and the WebSocket @@ -6,4 +8,15 @@ export const environment = { // ALB under the same origin as the frontend, so no absolute URL is needed. apiUrl: '', wsUrl: '', + // MVP scope: every non-MVP area is hidden. Set a flag to true to bring that area back + // (see docs/FEATURE-FLAGS.md); keep environment.ts in sync. + features: { + billing: false, + usage: false, + integrations: false, + members: false, + customRoles: false, + notifications: false, + tokens: false, + } satisfies FeatureFlagMap, } as const; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index d5b19ab..bcf49a5 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,5 +1,18 @@ +import type { FeatureFlagMap } from '../app/core/features/feature-flags'; + export const environment = { production: false, apiUrl: '', // proxy.conf.json forwards /api → localhost:8080 wsUrl: '', // proxy.conf.json forwards /ws → ws://localhost:8080 + // MVP scope: every non-MVP area is hidden. Set a flag to true to bring that area back + // (see docs/FEATURE-FLAGS.md); keep environment.prod.ts in sync. + features: { + billing: false, + usage: false, + integrations: false, + members: false, + customRoles: false, + notifications: false, + tokens: false, + } satisfies FeatureFlagMap, } as const; From 6b43eaaf3b35e55781fa6541586bb5c486076949 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:09 -0500 Subject: [PATCH 02/38] feat(routing): hide non-MVP routes behind feature flags Gate every non-MVP route with featureGuard so a disabled feature's URLs fall through to the `**` -> /projects fallback like unknown paths: billing (settings page + checkout success/cancel), usage, integrations (org + project pages and the Jira OAuth callback), members (org + project pages and the invitation landing), customRoles (roles list/new/edit) and the notifications/tokens account placeholders. Routes that already had a permission canMatch chain it through featureGuard so a hidden page never fetches permissions or toasts a denial. The invitation landing follows `members`: with it off nobody can invite from the UI and an owner has no screen to see or remove whoever joins, so a stale link must not add people to an org. The project settings landing skips members/roles while their feature is off. A spec drives the real route table to pin both directions. --- src/app/app.routes.spec.ts | 145 ++++++++++++++++++++++++ src/app/app.routes.ts | 24 +++- src/app/core/guards/permission.guard.ts | 12 +- 3 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/app/app.routes.spec.ts diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts new file mode 100644 index 0000000..2dfaf1e --- /dev/null +++ b/src/app/app.routes.spec.ts @@ -0,0 +1,145 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Router, provideRouter, withRouterConfig } from '@angular/router'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { of } from 'rxjs'; +import { routes } from './app.routes'; +import { AuthStore } from './core/auth/auth.store'; +import { CURRENT_TERMS_VERSION } from './core/auth/terms'; +import { PermissionsStore } from './core/authz/permissions.store'; +import { FeatureFlagMap, provideFeatureFlags } from './core/features/feature-flags'; +import { TenantContextService } from './core/tenant/tenant-context.service'; + +/** Every non-MVP URL, each gated by one feature flag. */ +const HIDDEN_URLS = [ + '/settings/billing', + '/billing/success', + '/billing/cancel', + '/settings/usage', + '/settings/integrations', + '/settings/integrations/jira/callback', + '/projects/p1/settings/integrations', + '/settings/members', + '/projects/p1/settings/members', + '/invitations/accept', + '/projects/p1/settings/roles', + '/projects/p1/settings/roles/new', + '/projects/p1/settings/roles/r1/edit', + '/account/notifications', + '/account/tokens', +]; + +/** MVP URLs that must keep resolving with every flag off. */ +const MVP_URLS = [ + '/projects', + '/projects/p1/overview', + '/projects/p1/sessions/history', + '/projects/p1/stories', + '/projects/p1/stories/new', + '/projects/p1/stories/s1', + '/projects/p1/glossary', + '/projects/p1/constraints', + '/projects/p1/settings/general', + '/projects/p1/settings/danger', + '/settings/general', + '/account/profile', + '/account/security', + '/account/appearance', +]; + +/** The MVP release: every non-MVP feature off (explicit, so the test doesn't follow the env). */ +const ALL_OFF: FeatureFlagMap = { + billing: false, + usage: false, + integrations: false, + members: false, + customRoles: false, + notifications: false, + tokens: false, +}; + +const ALL_ON: FeatureFlagMap = { + billing: true, + usage: true, + integrations: true, + members: true, + customRoles: true, + notifications: true, + tokens: true, +}; + +/** + * Drives the REAL route table as a signed-in organization owner (stubbed auth, terms and + * permissions, so every role/permission guard passes) and reports where each URL ends up. + * No outlet is mounted, so the lazy pages are loaded but never rendered. + */ +describe('app routes with feature flags', () => { + function setup(flags: FeatureFlagMap): Router { + TestBed.configureTestingModule({ + imports: [TranslocoTestingModule.forRoot({ langs: { en: {} } })], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'always' })), + provideFeatureFlags(flags), + { + provide: AuthStore, + useValue: { isAuthenticated: () => true, organizationId: () => 'org-1' }, + }, + { + provide: TenantContextService, + useValue: { termsVersion: () => CURRENT_TERMS_VERSION }, + }, + { + provide: PermissionsStore, + useValue: { + loadOrgAuthorization: () => of(undefined), + loadProjectPermissions: () => of(undefined), + isOrgOwner: () => true, + isOrgOwnerOrAdmin: () => true, + has: () => true, + }, + }, + ], + }); + return TestBed.inject(Router); + } + + async function landing(router: Router, url: string): Promise { + await router.navigateByUrl(url); + return router.url; + } + + describe('with the MVP flags (all off)', () => { + it.each(HIDDEN_URLS)('treats %s as an unknown URL (falls back to /projects)', async (url) => { + const router = setup(ALL_OFF); + expect(await landing(router, url)).toBe('/projects'); + }); + + it.each(['/members', '/projects/p1/members'])( + 'sends the legacy %s redirect to the fallback too', + async (url) => { + const router = setup(ALL_OFF); + expect(await landing(router, url)).toBe('/projects'); + }, + ); + + it.each(MVP_URLS)('still resolves %s', async (url) => { + const router = setup(ALL_OFF); + expect(await landing(router, url)).toBe(url); + }); + + it('lands the project settings index on General', async () => { + const router = setup(ALL_OFF); + expect(await landing(router, '/projects/p1/settings')).toBe('/projects/p1/settings/general'); + }); + }); + + describe('with every flag on', () => { + it.each(HIDDEN_URLS)('resolves %s again', async (url) => { + const router = setup(ALL_ON); + expect(await landing(router, url)).toBe(url); + }); + }); +}); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 8cc2eb0..c691742 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { authGuard } from './core/guards/auth.guard'; +import { featureGuard } from './core/guards/feature.guard'; import { launchGuard } from './core/guards/launch.guard'; import { onboardingGuard, orgGuard } from './core/guards/org.guard'; import { @@ -21,9 +22,12 @@ export const routes: Routes = [ }, // Invitation landing: chrome-less, no auth guard so logged-out invitees can view it. + // Part of the `members` feature: while it is off nobody can invite or manage members, + // so a stale invite link falls through to the fallback like any unknown URL. { path: 'invitations/accept', title: 'titles.acceptInvitation', + canMatch: [featureGuard('members')], loadComponent: () => import('./features/workspace/pages/accept-invitation/accept-invitation').then( (m) => m.AcceptInvitation, @@ -43,6 +47,7 @@ export const routes: Routes = [ { path: 'settings/integrations/jira/callback', title: 'titles.integrations', + canMatch: [featureGuard('integrations')], canActivate: [authGuard, termsGuard], loadComponent: () => import('./features/workspace/pages/jira-oauth-callback/jira-oauth-callback').then( @@ -133,6 +138,7 @@ export const routes: Routes = [ { path: 'members', title: 'titles.members', + canMatch: [featureGuard('members')], canActivate: [requireOrgRole('ADMIN')], data: { orgRole: 'ADMIN' }, loadComponent: () => @@ -141,6 +147,7 @@ export const routes: Routes = [ { path: 'billing', title: 'titles.billing', + canMatch: [featureGuard('billing')], canActivate: [requireOrgRole('OWNER')], data: { orgRole: 'OWNER' }, loadComponent: () => @@ -149,6 +156,7 @@ export const routes: Routes = [ { path: 'integrations', title: 'titles.integrations', + canMatch: [featureGuard('integrations')], canActivate: [requireOrgRole('ADMIN')], data: { orgRole: 'ADMIN' }, loadComponent: () => @@ -159,6 +167,7 @@ export const routes: Routes = [ { path: 'usage', title: 'titles.usage', + canMatch: [featureGuard('usage')], canActivate: [requireOrgRole('OWNER')], data: { orgRole: 'OWNER' }, loadComponent: () => @@ -170,6 +179,7 @@ export const routes: Routes = [ { path: 'billing/success', title: 'titles.billing', + canMatch: [featureGuard('billing')], loadComponent: () => import('./features/billing/pages/checkout-result/checkout-result').then( (m) => m.CheckoutResult, @@ -179,6 +189,7 @@ export const routes: Routes = [ { path: 'billing/cancel', title: 'titles.billing', + canMatch: [featureGuard('billing')], loadComponent: () => import('./features/billing/pages/checkout-result/checkout-result').then( (m) => m.CheckoutResult, @@ -214,6 +225,7 @@ export const routes: Routes = [ { path: 'notifications', title: 'titles.notifications', + canMatch: [featureGuard('notifications')], loadComponent: () => import('./shared/components/coming-soon/coming-soon').then((m) => m.ComingSoon), data: { titleKey: 'titles.notifications', icon: 'lucideBell' }, @@ -221,6 +233,7 @@ export const routes: Routes = [ { path: 'tokens', title: 'titles.tokens', + canMatch: [featureGuard('tokens')], loadComponent: () => import('./shared/components/coming-soon/coming-soon').then((m) => m.ComingSoon), data: { titleKey: 'titles.tokens', icon: 'lucideKey' }, @@ -322,7 +335,7 @@ export const routes: Routes = [ { path: 'roles', title: 'titles.projectRoles', - canMatch: [requirePermissionMatch('ROLE_READ')], + canMatch: [featureGuard('customRoles', requirePermissionMatch('ROLE_READ'))], canActivate: [requirePermission('ROLE_READ')], data: { permission: 'ROLE_READ' }, loadComponent: () => @@ -333,6 +346,7 @@ export const routes: Routes = [ { path: 'roles/new', title: 'titles.newRole', + canMatch: [featureGuard('customRoles')], canActivate: [requirePermission('ROLE_CREATE')], data: { permission: 'ROLE_CREATE' }, loadComponent: () => @@ -343,6 +357,7 @@ export const routes: Routes = [ { path: 'roles/:roleId/edit', title: 'titles.editRole', + canMatch: [featureGuard('customRoles')], canActivate: [requirePermission('ROLE_UPDATE')], data: { permission: 'ROLE_UPDATE' }, loadComponent: () => @@ -353,7 +368,7 @@ export const routes: Routes = [ { path: 'members', title: 'titles.projectMembers', - canMatch: [requirePermissionMatch('MEMBER_READ')], + canMatch: [featureGuard('members', requirePermissionMatch('MEMBER_READ'))], canActivate: [requirePermission('MEMBER_READ')], data: { permission: 'MEMBER_READ' }, loadComponent: () => @@ -364,7 +379,9 @@ export const routes: Routes = [ { path: 'integrations', title: 'titles.integrations', - canMatch: [requirePermissionMatch('INTEGRATION_READ')], + canMatch: [ + featureGuard('integrations', requirePermissionMatch('INTEGRATION_READ')), + ], canActivate: [requirePermission('INTEGRATION_READ')], data: { permission: 'INTEGRATION_READ' }, loadComponent: () => @@ -390,5 +407,6 @@ export const routes: Routes = [ ], }, + // Unknown URLs — and the routes of a disabled feature (see featureGuard) — land here. { path: '**', redirectTo: 'projects' }, ]; diff --git a/src/app/core/guards/permission.guard.ts b/src/app/core/guards/permission.guard.ts index 2b62dfe..23fb155 100644 --- a/src/app/core/guards/permission.guard.ts +++ b/src/app/core/guards/permission.guard.ts @@ -12,6 +12,7 @@ import { TranslocoService } from '@jsverse/transloco'; import { firstValueFrom } from 'rxjs'; import { AuthStore } from '../auth/auth.store'; import { PermissionsStore } from '../authz/permissions.store'; +import { FeatureFlags } from '../features/feature-flags'; import { OrgRole } from '../authz/permissions.models'; import { ToastService } from '../../shared/toast/toast.service'; @@ -22,6 +23,7 @@ import { ToastService } from '../../shared/toast/toast.service'; */ interface GuardDeps { auth: AuthStore; + flags: FeatureFlags; permissions: PermissionsStore; router: Router; toast: ToastService; @@ -31,6 +33,7 @@ interface GuardDeps { function resolveDeps(): GuardDeps { return { auth: inject(AuthStore), + flags: inject(FeatureFlags), permissions: inject(PermissionsStore), router: inject(Router), toast: inject(ToastService), @@ -163,8 +166,9 @@ export function requireOrgRole(role: OrgRole): CanActivateFn { * Landing redirect for the project Settings index. The fixed `general` default is * gated by `PROJECT_UPDATE`, so a member who can only reach (say) Members would be * bounced with a "no access" toast just for entering Settings. Instead, redirect to the - * FIRST settings sub-page the caller can actually access; fall back to the project - * overview when none apply. No toast — this is a silent landing choice, not a denial. + * FIRST settings sub-page the caller can actually access — skipping the pages of a + * disabled feature — and fall back to the project overview when none apply. No toast: + * this is a silent landing choice, not a denial. */ export const projectSettingsLanding: CanActivateFn = async (route: ActivatedRouteSnapshot) => { const deps = resolveDeps(); @@ -174,8 +178,8 @@ export const projectSettingsLanding: CanActivateFn = async (route: ActivatedRout const can = (p: string) => deps.permissions.isOrgOwnerOrAdmin() || deps.permissions.has(p); const target = (can('PROJECT_UPDATE') && 'general') || - (can('MEMBER_READ') && 'members') || - (can('ROLE_READ') && 'roles') || + (deps.flags.isEnabled('members') && can('MEMBER_READ') && 'members') || + (deps.flags.isEnabled('customRoles') && can('ROLE_READ') && 'roles') || (can('PROJECT_DELETE') && 'danger') || null; return projectId && target From 64c6017fa8e7af6d8d89a66a3bca5490466a461c Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:14 -0500 Subject: [PATCH 03/38] feat(shell): filter the sidebar nav by feature flag Move the nav segment lists and their visibility rule into shell-nav.ts so they can be unit-tested, and add an optional `feature` to NavSeg that visibleNavSegs checks alongside role/permission. Flagged entries: org members/billing/integrations/usage, project roles/members/integrations and the account notifications/tokens placeholders. A "Settings" root entry with no reachable sub-page is now dropped, and the org one opens the first reachable sub-page instead of the owner-only General, so an admin never lands on a "no access" toast. The Jira job banner only mounts while integrations are on. --- src/app/layout/shell/shell-nav.spec.ts | 122 +++++++++++++++++++++++++ src/app/layout/shell/shell-nav.ts | 77 ++++++++++++++++ src/app/layout/shell/shell.ts | 116 +++++++++-------------- 3 files changed, 243 insertions(+), 72 deletions(-) create mode 100644 src/app/layout/shell/shell-nav.spec.ts create mode 100644 src/app/layout/shell/shell-nav.ts diff --git a/src/app/layout/shell/shell-nav.spec.ts b/src/app/layout/shell/shell-nav.spec.ts new file mode 100644 index 0000000..421a0c9 --- /dev/null +++ b/src/app/layout/shell/shell-nav.spec.ts @@ -0,0 +1,122 @@ +import { OrgRole } from '../../core/authz/permissions.models'; +import { FeatureKey } from '../../core/features/feature-flags'; +import { + ACCOUNT_NAV, + NavAccess, + NavSeg, + ORG_SETTINGS_NAV, + PROJECT_SETTINGS_NAV, + visibleNavSegs, +} from './shell-nav'; + +/** A caller with `role`, the given project permissions and the given features switched on. */ +function access( + role: OrgRole, + features: readonly FeatureKey[] = [], + permissions: readonly string[] = [], +): NavAccess { + const ownerOrAdmin = role === 'OWNER' || role === 'ADMIN'; + return { + isOrgOwner: () => role === 'OWNER', + isOrgOwnerOrAdmin: () => ownerOrAdmin, + has: (p) => ownerOrAdmin || permissions.includes(p), + isEnabled: (f) => features.includes(f), + }; +} + +const ALL_FEATURES: readonly FeatureKey[] = [ + 'billing', + 'usage', + 'integrations', + 'members', + 'customRoles', + 'notifications', + 'tokens', +]; + +const segs = (list: NavSeg[]) => list.map((s) => s.seg); + +describe('visibleNavSegs', () => { + it('hides an item whose feature is off, even for the owner', () => { + const items: NavSeg[] = [{ seg: 'general' }, { seg: 'billing', feature: 'billing' }]; + expect(segs(visibleNavSegs(items, access('OWNER')))).toEqual(['general']); + }); + + it('shows an item whose feature is on', () => { + const items: NavSeg[] = [{ seg: 'general' }, { seg: 'billing', feature: 'billing' }]; + expect(segs(visibleNavSegs(items, access('OWNER', ['billing'])))).toEqual([ + 'general', + 'billing', + ]); + }); + + it('still applies the role gate when the feature is on', () => { + const items: NavSeg[] = [{ seg: 'billing', role: 'OWNER', feature: 'billing' }]; + expect(visibleNavSegs(items, access('ADMIN', ['billing']))).toEqual([]); + }); + + it('still applies the permission gate when the feature is on', () => { + const items: NavSeg[] = [{ seg: 'members', permission: 'MEMBER_READ', feature: 'members' }]; + expect(visibleNavSegs(items, access('MEMBER', ['members']))).toEqual([]); + expect(segs(visibleNavSegs(items, access('MEMBER', ['members'], ['MEMBER_READ'])))).toEqual([ + 'members', + ]); + }); + + describe('with the MVP flags (everything off)', () => { + it('leaves only General in the org settings nav', () => { + expect(segs(visibleNavSegs(ORG_SETTINGS_NAV, access('OWNER')))).toEqual(['general']); + }); + + it('leaves an admin no org settings page at all', () => { + expect(visibleNavSegs(ORG_SETTINGS_NAV, access('ADMIN'))).toEqual([]); + }); + + it('leaves General and Danger in the project settings nav', () => { + expect(segs(visibleNavSegs(PROJECT_SETTINGS_NAV, access('OWNER')))).toEqual([ + 'general', + 'danger', + ]); + }); + + it('drops the "soon" notifications and tokens entries from the account nav', () => { + expect(segs(visibleNavSegs(ACCOUNT_NAV, access('MEMBER')))).toEqual([ + 'profile', + 'security', + 'appearance', + ]); + }); + }); + + describe('with every flag on', () => { + it('restores the full org settings nav', () => { + expect(segs(visibleNavSegs(ORG_SETTINGS_NAV, access('OWNER', ALL_FEATURES)))).toEqual([ + 'general', + 'members', + 'billing', + 'integrations', + 'usage', + ]); + }); + + it('restores the full project settings nav', () => { + expect(segs(visibleNavSegs(PROJECT_SETTINGS_NAV, access('OWNER', ALL_FEATURES)))).toEqual([ + 'general', + 'roles', + 'members', + 'integrations', + 'danger', + ]); + }); + + it('restores the account placeholders', () => { + expect(segs(visibleNavSegs(ACCOUNT_NAV, access('MEMBER', ALL_FEATURES)))).toEqual([ + 'profile', + 'security', + 'appearance', + 'notifications', + 'tokens', + ]); + }); + }); +}); diff --git a/src/app/layout/shell/shell-nav.ts b/src/app/layout/shell/shell-nav.ts new file mode 100644 index 0000000..f6fa89c --- /dev/null +++ b/src/app/layout/shell/shell-nav.ts @@ -0,0 +1,77 @@ +import { OrgRole } from '../../core/authz/permissions.models'; +import { FeatureKey } from '../../core/features/feature-flags'; + +/** + * A sidebar nav item before its link is resolved. `seg` is the route segment, the i18n key + * (`nav.`) and the nav-icon name. `soon` marks a disabled placeholder. `permission` / + * `role` (when set) gate the item to callers who hold that project permission / org role, and + * `feature` hides it while that feature flag is off — so the aside never offers a dead end + * (the guards + backend still enforce). + */ +export interface NavSeg { + seg: string; + soon?: boolean; + permission?: string; + role?: OrgRole; + feature?: FeatureKey; +} + +/** What {@link visibleNavSegs} needs to decide: the caller's grants and the build's flags. */ +export interface NavAccess { + isOrgOwner(): boolean; + isOrgOwnerOrAdmin(): boolean; + has(permission: string): boolean; + isEnabled(feature: FeatureKey): boolean; +} + +// Nav item segments per sidebar context; the shell resolves their links once the active +// project id is known. +export const ORG_ROOT_NAV: readonly NavSeg[] = [{ seg: 'projects' }, { seg: 'settings' }]; +export const ORG_SETTINGS_NAV: readonly NavSeg[] = [ + { seg: 'general', role: 'OWNER' }, + { seg: 'members', role: 'ADMIN', feature: 'members' }, + { seg: 'billing', role: 'OWNER', feature: 'billing' }, + { seg: 'integrations', role: 'ADMIN', feature: 'integrations' }, + { seg: 'usage', role: 'OWNER', feature: 'usage' }, +]; +export const PROJECT_ROOT_NAV: readonly NavSeg[] = [ + { seg: 'overview' }, + { seg: 'sessions', permission: 'SESSION_READ' }, + { seg: 'stories', permission: 'STORY_READ' }, + { seg: 'glossary', permission: 'GLOSSARY_READ' }, + { seg: 'constraints', permission: 'CONSTRAINT_READ' }, + { seg: 'settings' }, +]; +export const PROJECT_SETTINGS_NAV: readonly NavSeg[] = [ + { seg: 'general', permission: 'PROJECT_UPDATE' }, + { seg: 'roles', permission: 'ROLE_READ', feature: 'customRoles' }, + { seg: 'members', permission: 'MEMBER_READ', feature: 'members' }, + { seg: 'integrations', permission: 'INTEGRATION_READ', feature: 'integrations' }, + { seg: 'danger', permission: 'PROJECT_DELETE' }, +]; +export const ACCOUNT_NAV: readonly NavSeg[] = [ + { seg: 'profile' }, + { seg: 'security' }, + { seg: 'appearance' }, + { seg: 'notifications', soon: true, feature: 'notifications' }, + { seg: 'tokens', soon: true, feature: 'tokens' }, +]; + +/** + * Keeps only the nav segments the caller may reach: an item with a `feature` needs that flag + * on, an item with a `role` needs that org role (owner always passes; `'ADMIN'` admits owner + + * admin), and an item with a `permission` needs that project permission (owner/admin bypass). + * Ungated items always show. Pure, so the shell can call it inside a `computed` and re-render + * when authorization arrives. + */ +export function visibleNavSegs(segs: readonly NavSeg[], access: NavAccess): NavSeg[] { + return segs.filter((s) => { + if (s.feature && !access.isEnabled(s.feature)) return false; + if (s.role) { + const ok = s.role === 'OWNER' ? access.isOrgOwner() : access.isOrgOwnerOrAdmin(); + if (!ok) return false; + } + if (s.permission && !access.has(s.permission)) return false; + return true; + }); +} diff --git a/src/app/layout/shell/shell.ts b/src/app/layout/shell/shell.ts index 0d26d53..b332407 100644 --- a/src/app/layout/shell/shell.ts +++ b/src/app/layout/shell/shell.ts @@ -16,7 +16,7 @@ import { lucideChevronLeft, lucideMenu, lucideSearch, lucideX } from '@ng-icons/ import { TranslocoPipe } from '@jsverse/transloco'; import { AuthStore } from '../../core/auth/auth.store'; import { PermissionsStore } from '../../core/authz/permissions.store'; -import { OrgRole } from '../../core/authz/permissions.models'; +import { FeatureFlags } from '../../core/features/feature-flags'; import { PageTitleService } from '../../core/layout/page-title.service'; import { modLabel } from '../../core/platform/shortcut'; import { WorkspaceStore } from '../../features/workspace/data/workspace.store'; @@ -29,6 +29,16 @@ import { ToastHost } from '../../shared/toast/toast-host'; import { RecordingMinibar } from '../../features/discovery/components/recording-minibar/recording-minibar'; import { IntegrationJobsBanner } from '../../features/workspace/components/integration-jobs-banner/integration-jobs-banner'; import { HlmIcon } from '../../shared/ui'; +import { + ACCOUNT_NAV, + NavAccess, + NavSeg, + ORG_ROOT_NAV, + ORG_SETTINGS_NAV, + PROJECT_ROOT_NAV, + PROJECT_SETTINGS_NAV, + visibleNavSegs, +} from './shell-nav'; interface NavItem { /** Route segment + i18n key (`nav.`) + nav-icon name. */ @@ -39,18 +49,6 @@ interface NavItem { soon?: boolean; } -/** - * A nav item template before its link is resolved. `permission` / `role` (when set) - * gate the item to callers who hold that project permission / org role — used to hide - * settings entries a member can't reach. - */ -interface NavSeg { - seg: string; - soon?: boolean; - permission?: string; - role?: OrgRole; -} - /** The sidebar context derived from the URL: which nav list, back link and heading to show. */ interface NavContext { /** A back link shown above the list (e.g. "← All Projects"); null in the root contexts. */ @@ -243,7 +241,9 @@ interface Crumb { - + @if (integrationsEnabled) { + + }
@@ -291,6 +291,7 @@ export class Shell { protected readonly auth = inject(AuthStore); protected readonly workspace = inject(WorkspaceStore); private readonly permissions = inject(PermissionsStore); + private readonly flags = inject(FeatureFlags); private readonly router = inject(Router); private readonly pageTitle = inject(PageTitleService); @@ -298,6 +299,8 @@ export class Shell { protected readonly shortcut = modLabel('K'); protected readonly mobileOpen = signal(false); protected readonly paletteOpen = signal(false); + /** The Jira job banner only exists while the `integrations` feature is on. */ + protected readonly integrationsEnabled = this.flags.isEnabled('integrations'); /** Open the command palette on Cmd/Ctrl+K from anywhere in the shell. */ @HostListener('document:keydown', ['$event']) @@ -308,40 +311,13 @@ export class Shell { } } - // Nav item segments per context; links are resolved in `navContext()` where the - // active project id is known. `soon` marks disabled placeholder items. `permission` - // / `role` gate the item to what the caller may reach — items they can't use are - // filtered out so the aside never offers a dead end (the guards + backend still enforce). - private readonly orgRootSegs: NavSeg[] = [{ seg: 'projects' }, { seg: 'settings' }]; - private readonly orgSettingsSegs: NavSeg[] = [ - { seg: 'general', role: 'OWNER' }, - { seg: 'members', role: 'ADMIN' }, - { seg: 'billing', role: 'OWNER' }, - { seg: 'integrations', role: 'ADMIN' }, - { seg: 'usage', role: 'OWNER' }, - ]; - private readonly projectRootSegs: NavSeg[] = [ - { seg: 'overview' }, - { seg: 'sessions', permission: 'SESSION_READ' }, - { seg: 'stories', permission: 'STORY_READ' }, - { seg: 'glossary', permission: 'GLOSSARY_READ' }, - { seg: 'constraints', permission: 'CONSTRAINT_READ' }, - { seg: 'settings' }, - ]; - private readonly projectSettingsSegs: NavSeg[] = [ - { seg: 'general', permission: 'PROJECT_UPDATE' }, - { seg: 'roles', permission: 'ROLE_READ' }, - { seg: 'members', permission: 'MEMBER_READ' }, - { seg: 'integrations', permission: 'INTEGRATION_READ' }, - { seg: 'danger', permission: 'PROJECT_DELETE' }, - ]; - private readonly accountSegs: NavSeg[] = [ - { seg: 'profile' }, - { seg: 'security' }, - { seg: 'appearance' }, - { seg: 'notifications', soon: true }, - { seg: 'tokens', soon: true }, - ]; + /** The caller's grants and the build's feature flags, as the nav filter reads them. */ + private readonly navAccess: NavAccess = { + isOrgOwner: () => this.permissions.isOrgOwner(), + isOrgOwnerOrAdmin: () => this.permissions.isOrgOwnerOrAdmin(), + has: (permission) => this.permissions.has(permission), + isEnabled: (feature) => this.flags.isEnabled(feature), + }; private readonly url = toSignal( this.router.events.pipe( @@ -388,7 +364,7 @@ export class Shell { back: { link: ['/projects'], labelKey: 'nav.allProjects' }, headingKey: 'nav.account', ariaKey: 'nav.accountAria', - items: this.accountSegs.map((s) => ({ + items: this.visibleSegs(ACCOUNT_NAV).map((s) => ({ seg: s.seg, link: ['/account', s.seg], soon: s.soon, @@ -407,21 +383,21 @@ export class Shell { }, headingKey: 'nav.settings', ariaKey: 'nav.projectAria', - items: this.visibleSegs(this.projectSettingsSegs).map((s) => ({ + items: this.visibleSegs(PROJECT_SETTINGS_NAV).map((s) => ({ seg: s.seg, link: ['/projects', pid, 'settings', s.seg], soon: s.soon, })), }; } - // Project root. + // Project root. "Settings" is dropped when no settings sub-page is reachable. + const hasSettings = this.visibleSegs(PROJECT_SETTINGS_NAV).length > 0; return { back: { link: ['/projects'], labelKey: 'nav.allProjects' }, ariaKey: 'nav.projectAria', - items: this.visibleSegs(this.projectRootSegs).map((s) => ({ - seg: s.seg, - link: ['/projects', pid, s.seg], - })), + items: this.visibleSegs(PROJECT_ROOT_NAV) + .filter((s) => s.seg !== 'settings' || hasSettings) + .map((s) => ({ seg: s.seg, link: ['/projects', pid, s.seg] })), }; } @@ -431,7 +407,7 @@ export class Shell { back: { link: ['/projects'], labelKey: 'nav.allProjects' }, headingKey: 'nav.settings', ariaKey: 'nav.orgAria', - items: this.visibleSegs(this.orgSettingsSegs).map((s) => ({ + items: this.visibleSegs(ORG_SETTINGS_NAV).map((s) => ({ seg: s.seg, link: ['/settings', s.seg], soon: s.soon, @@ -439,30 +415,26 @@ export class Shell { }; } - // Org root. + // Org root. "Settings" opens the first org settings page the caller can reach (the + // fixed `general` default is owner-only) and is dropped when there is none. + const settingsLanding = this.visibleSegs(ORG_SETTINGS_NAV)[0]?.seg; return { back: null, ariaKey: 'nav.orgAria', - items: this.orgRootSegs.map((s) => ({ seg: s.seg, link: ['/' + s.seg] })), + items: ORG_ROOT_NAV.flatMap((s) => { + if (s.seg !== 'settings') return [{ seg: s.seg, link: ['/' + s.seg] }]; + return settingsLanding ? [{ seg: s.seg, link: ['/settings', settingsLanding] }] : []; + }), }; }); /** - * Keeps only the nav segments the caller may reach: an item with a `role` needs that - * org role (owner always passes; `'ADMIN'` admits owner + admin), an item with a - * `permission` needs that project permission (owner/admin bypass). Ungated items always - * show. Reads the permission signals so the list re-renders when authorization arrives. + * Keeps only the nav segments the caller may reach (feature flag, org role, project + * permission — see {@link visibleNavSegs}). Reads the permission signals so the list + * re-renders when authorization arrives. */ - private visibleSegs(segs: NavSeg[]): NavSeg[] { - return segs.filter((s) => { - if (s.role) { - const ok = - s.role === 'OWNER' ? this.permissions.isOrgOwner() : this.permissions.isOrgOwnerOrAdmin(); - if (!ok) return false; - } - if (s.permission && !this.permissions.has(s.permission)) return false; - return true; - }); + private visibleSegs(segs: readonly NavSeg[]): NavSeg[] { + return visibleNavSegs(segs, this.navAccess); } /** Breadcrumb trail for the top bar: the context ancestors (clickable) then the From fc0e476dcd0ff7c35705c12b9546a6d19145e089 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:22 -0500 Subject: [PATCH 04/38] feat(shared): drop flagged entries from the command palette and user menu The palette's "Members" quick action and backend MEMBER hits (both open /settings/members) only appear while `members` is on; the user menu's "Upgrade" CTA only while `billing` is on. --- .../command-palette/command-palette.spec.ts | 94 +++++++++++++++++++ .../command-palette/command-palette.ts | 45 +++++++-- .../shared/components/user-menu/user-menu.ts | 27 +++--- 3 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 src/app/shared/components/command-palette/command-palette.spec.ts diff --git a/src/app/shared/components/command-palette/command-palette.spec.ts b/src/app/shared/components/command-palette/command-palette.spec.ts new file mode 100644 index 0000000..60a2f79 --- /dev/null +++ b/src/app/shared/components/command-palette/command-palette.spec.ts @@ -0,0 +1,94 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { of } from 'rxjs'; +import { AuthService } from '../../../core/auth/auth.service'; +import { FeatureFlagMap, provideFeatureFlags } from '../../../core/features/feature-flags'; +import { ThemeService } from '../../../core/theme/theme.service'; +import { WorkspaceStore } from '../../../features/workspace/data/workspace.store'; +import { CommandRegistry } from '../../search/command-registry'; +import { SearchHitResponse } from '../../search/search-api.service'; +import { CommandPalette } from './command-palette'; + +const HITS: SearchHitResponse[] = [ + { type: 'PROJECT', id: 'p1', title: 'Alpha', subtitle: null, projectId: 'p1' }, + { type: 'MEMBER', id: 'm1', title: 'Alice', subtitle: null, projectId: null }, +]; + +describe('CommandPalette feature flags', () => { + let registry: CommandRegistry; + let palette: CommandPalette; + + function setup(flags: Partial): void { + TestBed.configureTestingModule({ + imports: [ + CommandPalette, + TranslocoTestingModule.forRoot({ + langs: { en: {} }, + translocoConfig: { availableLangs: ['en'], defaultLang: 'en' }, + preloadLangs: true, + }), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + provideFeatureFlags(flags), + // The palette only reads these; stubs keep the test free of their side effects. + { provide: AuthService, useValue: { switchOrganization: () => of(null) } }, + { provide: ThemeService, useValue: { toggle: () => undefined } }, + { + provide: WorkspaceStore, + useValue: { + organizations: signal([]), + projects: signal([]), + loadProjects: () => undefined, + }, + }, + ], + }); + palette = TestBed.createComponent(CommandPalette).componentInstance; + registry = TestBed.inject(CommandRegistry); + } + + const ids = () => registry.items().map((item) => item.id); + + /** Types `q`, lets the debounced backend search fire, and answers it with `hits`. */ + async function searchBackend(q: string, hits: SearchHitResponse[]): Promise { + palette['onQuery'](q); + TestBed.tick(); + await new Promise((resolve) => setTimeout(resolve, 250)); + TestBed.inject(HttpTestingController) + .expectOne((req) => req.url === '/api/search') + .flush(hits); + } + + it('omits the Members quick action while the members feature is off', () => { + setup({ members: false }); + expect(ids()).toContain('action:new-project'); + expect(ids()).toContain('action:settings'); + expect(ids()).not.toContain('action:members'); + }); + + it('offers the Members quick action while the members feature is on', () => { + setup({ members: true }); + expect(ids()).toContain('action:members'); + }); + + it('drops backend member hits while the members feature is off', async () => { + setup({ members: false }); + await searchBackend('al', HITS); + expect(ids()).toContain('project:p1'); + expect(ids()).not.toContain('member:m1'); + }); + + it('keeps backend member hits while the members feature is on', async () => { + setup({ members: true }); + await searchBackend('al', HITS); + expect(ids()).toContain('project:p1'); + expect(ids()).toContain('member:m1'); + }); +}); diff --git a/src/app/shared/components/command-palette/command-palette.ts b/src/app/shared/components/command-palette/command-palette.ts index 1ca5a25..17b91fe 100644 --- a/src/app/shared/components/command-palette/command-palette.ts +++ b/src/app/shared/components/command-palette/command-palette.ts @@ -31,12 +31,17 @@ import { catchError, debounceTime, distinctUntilChanged, map, of, switchMap } fr import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { AuthService } from '../../../core/auth/auth.service'; import { AuthStore } from '../../../core/auth/auth.store'; +import { FeatureFlags, FeatureKey } from '../../../core/features/feature-flags'; import { ThemeService } from '../../../core/theme/theme.service'; import { WorkspaceStore } from '../../../features/workspace/data/workspace.store'; import { Avatar } from '../avatar/avatar'; import { HlmIcon } from '../../ui'; import { CommandRegistry, SearchItem } from '../../search/command-registry'; -import { SearchApiService, SearchHitResponse } from '../../search/search-api.service'; +import { + SearchApiService, + SearchHitResponse, + SearchHitType, +} from '../../search/search-api.service'; import { translateFn } from '../../../core/i18n/translate-fn'; /** localStorage key for the small most-recently-used list of activated item ids. */ @@ -44,6 +49,12 @@ const RECENT_KEY = 'commandPalette.recent'; /** How many recent items to remember / surface. */ const RECENT_MAX = 5; +/** + * Backend search-hit types whose result opens a feature-flagged page; hits of a disabled + * feature are dropped so the palette never jumps to a route that no longer matches. + */ +const HIT_FEATURES: Partial> = { MEMBER: 'members' }; + /** A search item paired with its flat index across all visible groups (for keyboard nav). */ interface IndexedItem { item: SearchItem; @@ -179,6 +190,7 @@ export class CommandPalette { private readonly transloco = inject(TranslocoService); private readonly registry = inject(CommandRegistry); private readonly searchApi = inject(SearchApiService); + private readonly flags = inject(FeatureFlags); /** Two-way bound visibility — the shell flips this on ⌘K / search focus. */ readonly open = model(false); @@ -213,13 +225,18 @@ export class CommandPalette { icon: 'lucidePlus', run: () => this.go(['/projects/new']), }, - { - id: 'action:members', - label: t('commandPalette.actions.members'), - group: 'commandPalette.groups.actions', - icon: 'lucideUsers', - run: () => this.go(['/settings/members']), - }, + // Org members live behind the `members` feature flag. + ...(this.flags.isEnabled('members') + ? [ + { + id: 'action:members', + label: t('commandPalette.actions.members'), + group: 'commandPalette.groups.actions', + icon: 'lucideUsers', + run: () => this.go(['/settings/members']), + }, + ] + : []), { id: 'action:settings', label: t('commandPalette.actions.settings'), @@ -347,7 +364,11 @@ export class CommandPalette { ), takeUntilDestroyed(), ) - .subscribe(([q, hits]) => this.backendResults.set(hits.map((h) => this.toItem(h, q)))); + .subscribe(([q, hits]) => + this.backendResults.set( + hits.filter((h) => this.hitEnabled(h)).map((h) => this.toItem(h, q)), + ), + ); // On open: reset query/selection and focus the input. effect(() => { if (this.open()) { @@ -455,6 +476,12 @@ export class CommandPalette { }); } + /** False for a hit whose page belongs to a disabled feature (see {@link HIT_FEATURES}). */ + private hitEnabled(hit: SearchHitResponse): boolean { + const feature = HIT_FEATURES[hit.type]; + return !feature || this.flags.isEnabled(feature); + } + /** Map a backend search hit to a palette item. `keywords: q` guarantees it survives the client * fuzzy filter (a backend match can be typo-tolerant and need not be a subsequence of the label). * Project/organization ids mirror the local source ids so echoes de-dupe (the richer local wins). */ diff --git a/src/app/shared/components/user-menu/user-menu.ts b/src/app/shared/components/user-menu/user-menu.ts index ea8d8b1..5350e15 100644 --- a/src/app/shared/components/user-menu/user-menu.ts +++ b/src/app/shared/components/user-menu/user-menu.ts @@ -15,6 +15,7 @@ import { import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { AuthService } from '../../../core/auth/auth.service'; import { AuthStore } from '../../../core/auth/auth.store'; +import { FeatureFlags } from '../../../core/features/feature-flags'; import { ThemeMode, ThemeService } from '../../../core/theme/theme.service'; import { Lang, SUPPORTED_LANGS, saveLang } from '../../../core/i18n/language'; import { Avatar } from '../avatar/avatar'; @@ -157,17 +158,19 @@ import { HlmIcon } from '../../ui'; -
- -
+ @if (billingEnabled) { +
+ +
+ }
`, @@ -191,6 +194,8 @@ export class UserMenu { protected readonly positions = ABOVE_START; protected readonly open = signal(false); + /** The "Upgrade" CTA belongs to the `billing` feature. */ + protected readonly billingEnabled = inject(FeatureFlags).isEnabled('billing'); protected toggle(): void { this.open.update((v) => !v); From 1179f04a6165da86a64c7e06d90494f1c41d7ffb Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:23 -0500 Subject: [PATCH 05/38] feat(discovery): hide Jira actions on stories while integrations are off Wrap the backlog's Import from Jira / Push all / Push selected buttons and the story detail's Push to Jira in the `integrations` flag, skip the backlog's Jira-target lookup, and only let INTEGRATION_SYNC reveal the row-selection column while bulk push exists (bulk delete still needs STORY_DELETE). --- .../discovery/pages/stories/stories.ts | 176 ++++++++++-------- .../discovery/pages/stories/story-detail.ts | 38 ++-- 2 files changed, 117 insertions(+), 97 deletions(-) diff --git a/src/app/features/discovery/pages/stories/stories.ts b/src/app/features/discovery/pages/stories/stories.ts index a4656c2..423fd15 100644 --- a/src/app/features/discovery/pages/stories/stories.ts +++ b/src/app/features/discovery/pages/stories/stories.ts @@ -29,6 +29,7 @@ import { } from '../../../workspace/data/integrations.models'; import { ToastService } from '../../../../shared/toast/toast.service'; import { messageForError } from '../../../../core/errors/error-message'; +import { FeatureFlags } from '../../../../core/features/feature-flags'; import { HttpErrorResponse } from '@angular/common/http'; import { Modal } from '../../../../shared/components/modal/modal'; import { Indeterminate } from '../../../../shared/directives/indeterminate'; @@ -91,54 +92,56 @@ type SortValue = `${StorySort}:${StorySortDirection}`;

{{ 'stories.subtitle' | transloco }}

- - + @if (integrationsEnabled) { + + + }
- + @if (integrationsEnabled) { + + } + @if (integrationsEnabled) { + + } -
- + + @if (membersEnabled) { +
+
+

{{ 'orgSettings.transfer' | transloco }}

+

+ {{ 'orgSettings.transferDesc' | transloco }} +

+
+
+ +
+
+ }
@@ -683,6 +687,8 @@ export class OrgSettings { protected readonly languageOptions = LANGUAGE_OPTIONS; protected readonly skeletonCards = [0, 1, 2]; + /** Ownership transfer needs other members, so it follows the `members` feature flag. */ + protected readonly membersEnabled = inject(FeatureFlags).isEnabled('members'); protected readonly state = signal<'loading' | 'ready' | 'error'>('loading'); protected readonly saving = signal(null); @@ -815,9 +821,12 @@ export class OrgSettings { }, error: () => this.state.set('error'), }); - this.api.listMembers(orgId).subscribe({ - next: (members) => this.members.set(members), - }); + // The member list only feeds the ownership-transfer picker. + if (this.membersEnabled) { + this.api.listMembers(orgId).subscribe({ + next: (members) => this.members.set(members), + }); + } } protected saveField(field: OrgField): void { From c565b3fcb54a4b80c4e9d80e6299aefa3897ed92 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:33 -0500 Subject: [PATCH 07/38] feat(billing): show the usage page's Manage plan link only with billing Usage and billing are separate flags; keep the usage page free of a dead link to /settings/billing when only `usage` is turned on. --- src/app/features/billing/pages/usage/usage.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/features/billing/pages/usage/usage.ts b/src/app/features/billing/pages/usage/usage.ts index 7c22dfe..d8ed2d9 100644 --- a/src/app/features/billing/pages/usage/usage.ts +++ b/src/app/features/billing/pages/usage/usage.ts @@ -5,6 +5,7 @@ import { lucideCircleAlert } from '@ng-icons/lucide'; import { TranslocoPipe } from '@jsverse/transloco'; import { RouterLink } from '@angular/router'; import { AuthStore } from '../../../../core/auth/auth.store'; +import { FeatureFlags } from '../../../../core/features/feature-flags'; import { HlmButton, HlmIcon, HlmSkeleton } from '../../../../shared/ui'; import { BillingStore } from '../../data/billing.store'; @@ -98,9 +99,11 @@ import { BillingStore } from '../../data/billing.store'; {{ 'billing.planName.' + u.planType | transloco }}

- - {{ 'usage.managePlan' | transloco }} - + @if (billingEnabled) { + + {{ 'usage.managePlan' | transloco }} + + } } @@ -113,6 +116,8 @@ export class Usage { protected readonly usage = this.store.usage; protected readonly usageState = this.store.usageState; + /** "Manage plan" opens the billing page, so it needs the `billing` feature as well. */ + protected readonly billingEnabled = inject(FeatureFlags).isEnabled('billing'); protected readonly clampedPercentage = computed(() => { const pct = this.usage()?.usagePercentage ?? 0; From 1be8be99ae378e28a0024b39a12ad5c6e33dd5d4 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:34 -0500 Subject: [PATCH 08/38] docs: document the MVP feature flags Add docs/FEATURE-FLAGS.md (each flag, what it hides, how to turn it on or add one, the invitation/ownership decisions and the backend FREE-plan limits) and link it from the README environments section. --- README.md | 6 ++++ docs/FEATURE-FLAGS.md | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 docs/FEATURE-FLAGS.md diff --git a/README.md b/README.md index 1dc7e2f..131862d 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,14 @@ export const environment = { production: false, apiUrl: '', wsUrl: '', + features: { billing: false, usage: false, integrations: false, /* … */ } satisfies FeatureFlagMap, } as const; ``` +`features` son los feature flags de build: el MVP oculta billing, usage, integraciones (Jira), +miembros/invitaciones, roles personalizados y los placeholders de cuenta. Ponlos en `true` en +**ambos** archivos para reactivarlos — ver [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md). + --- ## Arquitectura @@ -130,6 +135,7 @@ Detalle en [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). |------------------------------------------------------|-----------------------------------------------------| | [`docs/adr/`](docs/adr/) | Architecture Decision Records (el *por qué*) | | [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Docker · AWS S3 + CloudFront · variables de entorno | +| [`docs/FEATURE-FLAGS.md`](docs/FEATURE-FLAGS.md) | Feature flags del MVP y cómo reactivarlos | | [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md) | Flujo de trabajo, ramas, commits, PR | | [`CHANGELOG.md`](CHANGELOG.md) | Historial de cambios (Keep a Changelog) | | [`.github/SECURITY.md`](.github/SECURITY.md) | Política de seguridad y reporte de vulnerabilidades | diff --git a/docs/FEATURE-FLAGS.md b/docs/FEATURE-FLAGS.md new file mode 100644 index 0000000..7464267 --- /dev/null +++ b/docs/FEATURE-FLAGS.md @@ -0,0 +1,74 @@ +# Feature Flags — Reqs-AI Web (Frontend) + +The deployed product ships only the **MVP** ("Podar el árbol"): capture a discovery session, +turn it into Gherkin user stories, and ground the AI with the project glossary and constraints. +Everything else stays in the codebase — and in the backend — but is switched off in the UI by a +build-time feature flag. Turning a flag on brings the feature back with **no other code change**. + +## Flags + +All flags default to `false` in **both** `src/environments/environment.ts` and +`src/environments/environment.prod.ts`. + +| Flag | Hides (routes → fall back to `/projects`) | Hides (UI) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `billing` | `/settings/billing`, `/billing/success`, `/billing/cancel` | Org settings nav entry · user-menu **Upgrade** CTA · Usage page **Manage plan** link | +| `usage` | `/settings/usage` | Org settings nav entry | +| `integrations` | `/settings/integrations`, `/settings/integrations/jira/callback` (Atlassian OAuth), `/projects/:id/settings/integrations` | Org + project settings nav entries · global Jira job banner (and its job polling / STOMP topic) · backlog **Import from Jira**, **Push all to Jira**, **Push to Jira (n)** · story detail **Push to Jira** · the backlog's Jira-target lookup | +| `members` | `/settings/members`, `/projects/:id/settings/members`, `/invitations/accept` (plus the legacy `/members` redirects) | Org + project settings nav entries · project overview **Members** card · command-palette **Members** action and member search hits · org General **Transfer ownership** card (and its member fetch) | +| `customRoles` | `/projects/:id/settings/roles`, `…/roles/new`, `…/roles/:roleId/edit` | Project settings nav entry (and the settings landing / overview "Settings" card no longer count it) | +| `notifications` | `/account/notifications` | Account nav "Soon" placeholder | +| `tokens` | `/account/tokens` | Account nav "Soon" placeholder | + +A hidden route does not match at all (`canMatch` returns `false`), so it behaves exactly like an +unknown URL: the router falls through to the wildcard `**` → `/projects` (query params are kept by +that redirect, as for any unknown URL). No blank page, no "no access" toast, and the lazy chunk is +never downloaded. + +The sidebar also hides a **Settings** entry when none of its sub-pages is reachable (e.g. an org +admin while `members` and `integrations` are off), and the org-level entry opens the first +reachable sub-page instead of the owner-only General. + +### Decisions + +- **Invitation links** (`/invitations/accept`) are behind `members`. While it is off nobody can + send an invitation from the UI, and an owner has no screen to see or remove whoever joins, so a + stale invite link must not add people to an org. Turning `members` on restores the flow. +- **Transfer ownership** (org General settings) is behind `members`: without members there is + nobody to pick, and its empty state asks the owner to invite someone. +- The story status filter still lists `EXPORTED`: it is data a story may already carry, not a link. + +## Turning a feature on + +1. Set the flag to `true` in **both** environment files (the production build uses + `environment.prod.ts` via `fileReplacements`). +2. Rebuild / redeploy. Nothing else changes: routes, nav entries and buttons come back as they were. + +Tests override flags with `provideFeatureFlags({ members: true })` in the TestBed providers. + +## Adding a flag + +1. Add the key to the `FeatureKey` union in `src/app/core/features/feature-flags.ts`; both + environment files then fail to compile until they define it (`satisfies FeatureFlagMap`). +2. Routes: `canMatch: [featureGuard('key')]`. When the route already has a `canMatch` guard, chain + it — `featureGuard('key', requirePermissionMatch('X'))` — because Angular runs every guard of a + `canMatch` array eagerly. +3. Sidebar: add `feature: 'key'` to the item in `src/app/layout/shell/shell-nav.ts`. +4. Anything else (buttons, cards, palette actions, data fetches): + `inject(FeatureFlags).isEnabled('key')`. +5. Extend the URL lists in `src/app/app.routes.spec.ts` and update this table. + +## Backend coupling + +Flags are **UI-only**: they are not a security boundary, and every backend endpoint stays live +(role/permission checks still apply server-side). + +- **Plan limits are enforced by the backend.** Each new organization gets a FREE subscription and + the workspace module rejects work beyond its limits with `422`: 25 projects + (`PROJECT_PLAN_LIMIT_EXCEEDED`), 50 glossary terms per project + (`GLOSSARY_TERM_PLAN_LIMIT_EXCEEDED`), 10 documents per project + (`PROJECT_DOCUMENT_PLAN_LIMIT_EXCEEDED`) and 3 members (`MEMBER_PLAN_LIMIT_EXCEEDED`). The UI shows + the localized error toast; with `billing` off there is no upgrade path, so raise the FREE limits + server-side if the MVP needs more. +- **AI token usage is metered, not enforced**: the backend records consumption against the plan + quota but no MVP flow is blocked by it, and no frontend guard or banner depends on billing. From 78ab854e91ff6c3e383c54cd8a1814c40b88d172 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:35:34 -0500 Subject: [PATCH 09/38] docs(changelog): note the MVP feature flags --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab50ae..12f28cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress. ### Added +- **MVP feature flags** (`feature/mvp-feature-flags`): a typed `features` map in both environment + files (`FeatureKey` union + `FeatureFlags.isEnabled()`), all **off** by default, so the deployed + product shows only the MVP. A `featureGuard()` `canMatch` makes a disabled route behave like an + unknown URL (falls through to `/projects`), and the sidebar, command palette, buttons and cards + drop what it hides: `billing` (billing pages, checkout returns, Upgrade CTA), `usage`, + `integrations` (Jira pages + OAuth callback, job banner, import/push actions), `members` (org + + project members, invitation landing, palette action/hits, ownership transfer), `customRoles` + (project roles), and the `notifications` / `tokens` account placeholders. The sidebar also hides + a **Settings** entry with no reachable sub-page. Flipping a flag restores the feature with no + other change; see `docs/FEATURE-FLAGS.md` (incl. the backend's FREE-plan limits). - **Discovery — live session presence** (`feature/discovery-presence`): the discovery chat now shows who else is viewing the **live** session — an overlapping avatar stack with a live pulse, a "+N" overflow bubble and a viewer count, in both the page header and the live session bar. It is fed by a new From 903703cdaf34c0436b18fbc26b8ff2d2c3313214 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:38:16 -0500 Subject: [PATCH 10/38] feat(discovery): mix shared meeting audio with the mic in the recorder Adds a `meeting` audio source for virtual calls (Zoom / Meet / Teams): the recorder opens the screen-share picker first (it needs the click's transient activation), keeps the shared surface's audio with voice processing off, stops the unused video track and then takes the mic. Both sources feed one mono GainNode mixer (explicit 1 channel, speakers down-mix) that drives the level meter and the PCM worklet, so `/ws/stt` still gets 16 kHz Int16 mono. - Sharing without "Share tab audio" stops the tracks and sets a notice; a dismissed picker stays idle without messages. - "Stop sharing" (audio track `ended`) disconnects the meeting audio and keeps streaming the mic with a notice; `shareMeetingAudio()` re-shares. - `pauseStreaming()` keeps the shared surface so a resume never re-opens the picker; `stopStreaming()` releases everything. - Socket handlers are detached before closing so a late close event can't tear down the next socket, and a session that fails to start releases the capture. --- public/i18n/en.json | 6 +- public/i18n/es.json | 6 +- src/app/core/audio/audio-recorder.service.ts | 262 +++++++++++++++--- src/app/core/audio/audio-source.ts | 71 +++++ .../data/session-recording.service.ts | 19 +- 5 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 src/app/core/audio/audio-source.ts diff --git a/public/i18n/en.json b/public/i18n/en.json index 3639804..5f1b31e 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1095,7 +1095,11 @@ "wsError": "Could not connect to the transcription server.", "wsClosed": "The transcription connection was closed.", "initFailed": "Could not start recording.", - "audioInitFailed": "Could not initialize the local audio processor." + "audioInitFailed": "Could not initialize the local audio processor.", + "noMeetingAudio": "No audio was shared. Pick the meeting tab and tick \"Share tab audio\".", + "meetingAudioEnded": "Meeting audio sharing stopped. Recording continues with the microphone only.", + "meetingUnsupported": "Your browser cannot capture meeting audio. Use desktop Chrome or Edge.", + "shareFailed": "Could not capture the selected tab, window or screen." }, "queue": { "pendingBadge": "{{count}} suggestions pending", diff --git a/public/i18n/es.json b/public/i18n/es.json index 1b55c02..21bd2de 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -1095,7 +1095,11 @@ "wsError": "No se pudo conectar con el servidor de transcripción.", "wsClosed": "Se cerró la conexión de transcripción.", "initFailed": "No se pudo iniciar la grabación.", - "audioInitFailed": "No se pudo inicializar el procesador de audio local." + "audioInitFailed": "No se pudo inicializar el procesador de audio local.", + "noMeetingAudio": "No se compartió audio. Elige la pestaña de la reunión y marca «Compartir audio de la pestaña».", + "meetingAudioEnded": "Se dejó de compartir el audio de la reunión. La grabación continúa solo con el micrófono.", + "meetingUnsupported": "Tu navegador no puede capturar el audio de la reunión. Usa Chrome o Edge de escritorio.", + "shareFailed": "No se pudo capturar la pestaña, ventana o pantalla elegida." }, "queue": { "pendingBadge": "{{count}} sugerencias pendientes", diff --git a/src/app/core/audio/audio-recorder.service.ts b/src/app/core/audio/audio-recorder.service.ts index fbc1cb0..794e5d6 100644 --- a/src/app/core/audio/audio-recorder.service.ts +++ b/src/app/core/audio/audio-recorder.service.ts @@ -1,26 +1,42 @@ import { Injectable, inject, signal } from '@angular/core'; import { AuthStore } from '../auth/auth.store'; import { environment } from '../../../environments/environment'; +import { AudioSource, MEETING_DISPLAY_MEDIA_OPTIONS, isCaptureDismissed } from './audio-source'; /** Number of bars exposed by the live input-level meter. */ const LEVEL_BARS = 12; /** How often the level meter refreshes (ms) — cheap enough to leave running. */ const LEVEL_INTERVAL_MS = 100; +/** Outcome of asking the user to share the meeting's tab, window or screen. */ +type MeetingShare = 'shared' | 'dismissed' | 'no-audio' | 'unsupported' | 'failed'; + /** - * Handles browser microphone recording, Web Audio downsampling to 16kHz 16-bit Mono PCM, + * Handles browser audio capture, Web Audio downsampling to 16kHz 16-bit Mono PCM, * and real-time binary WebSocket streaming to the backend STT endpoint `/ws/stt`. * + * Two sources (see {@link AudioSource}): the microphone alone, or — for virtual + * meetings — the microphone mixed with the audio of a surface shared through + * the screen-share picker. Both feed one mono mixer, so the backend always gets + * the same PCM stream. The shared surface survives pauses (resuming never + * re-opens the picker); if the user stops sharing, the mic keeps streaming. + * * `error` holds an i18n key (`discovery.rec.*`) — the UI translates it, so this - * service stays locale-agnostic. `levels` is a small AnalyserNode-driven input - * meter (0..1 per bar) for a real waveform while streaming. + * service stays locale-agnostic; `notice` is the non-fatal counterpart (e.g. + * meeting audio lost while the recording carries on). `levels` is a small + * AnalyserNode-driven input meter (0..1 per bar) of the mix while streaming. */ @Injectable({ providedIn: 'root' }) export class AudioRecorderService { private readonly auth = inject(AuthStore); - private mediaStream: MediaStream | null = null; + private micStream: MediaStream | null = null; + /** The shared meeting surface (its video track already stopped); outlives pauses. */ + private displayStream: MediaStream | null = null; private audioContext: AudioContext | null = null; + /** Mono summing node every source feeds; drives the level meter and the worklet. */ + private mixer: GainNode | null = null; + private displaySource: MediaStreamAudioSourceNode | null = null; private audioWorkletNode: AudioWorkletNode | null = null; private analyser: AnalyserNode | null = null; private levelTimer: ReturnType | null = null; @@ -29,32 +45,67 @@ export class AudioRecorderService { /** i18n key of the current error, or null. */ readonly error = signal(null); + /** i18n key of a non-fatal warning (the recording, if any, carries on), or null. */ + readonly notice = signal(null); readonly streaming = signal(false); /** Live input levels (0..1 per bar); empty while not streaming. */ readonly levels = signal([]); + /** Source of the current recording; back to `mic` once it stops. */ + readonly source = signal('mic'); + /** True while shared meeting audio is held (and mixed in whenever streaming). */ + readonly meetingAudio = signal(false); /** - * Request microphone permission. Returns true if granted. + * Secures the capture for `source` before a recording starts. Returns true if granted. + * For `meeting` the screen-share picker opens FIRST: it needs the click's transient + * activation, which any earlier await could use up. The microphone follows. */ - async requestPermission(): Promise { - try { - this.error.set(null); - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - // Store the stream so we can reuse or stop it. - this.mediaStream = stream; - return true; - } catch (err) { - console.error('Microphone permission denied/failed:', err); - this.error.set('discovery.rec.micDenied'); + async requestPermission(source: AudioSource = 'mic'): Promise { + this.error.set(null); + this.notice.set(null); + this.releaseMic(); + + if (source === 'meeting') { + const share = await this.shareDisplay(); + if (share !== 'shared') { + // Nothing starts: the user stays idle and can retry with one click. + this.reportShare(share); + return false; + } + } else { + this.releaseDisplay(); + } + + if (!(await this.acquireMic())) { + this.releaseDisplay(); return false; } + this.source.set(source); + return true; } /** - * Connects to `/ws/stt` and begins downsampling and streaming microphone input. + * Re-opens the screen-share picker during a session (e.g. after "Stop sharing") + * and mixes the new meeting audio into the live recording, or into the next + * resume while paused. Call it straight from a click handler. + */ + async shareMeetingAudio(): Promise { + this.error.set(null); + const share = await this.shareDisplay(); + if (share !== 'shared') { + this.reportShare(share); + return false; + } + this.notice.set(null); + this.source.set('meeting'); + return true; + } + + /** + * Connects to `/ws/stt` and begins downsampling and streaming the captured audio. */ async startStreaming(sessionId: string): Promise { - this.stopStreaming(); + this.stopPipeline(); this.error.set(null); const token = this.auth.accessToken(); @@ -64,11 +115,15 @@ export class AudioRecorderService { } try { - // Ensure microphone access has been requested - if (!this.mediaStream) { - const granted = await this.requestPermission(); + // The mic is released on pause, so a resume re-acquires it (no prompt once granted). + if (!this.micStream) { + const granted = await this.acquireMic(); if (!granted) return; } + if (this.source() === 'meeting' && !this.displayStream) { + // Sharing ended earlier (e.g. while paused): carry on with the mic only. + this.notice.set('discovery.rec.meetingAudioEnded'); + } const url = sttUrl(sessionId, token); this.webSocket = new WebSocket(url); @@ -79,10 +134,12 @@ export class AudioRecorderService { void this.startAudioProcessing(); }; + // Transport failures keep the shared meeting surface, so pause/resume can + // reconnect without the picker; stopping the session releases it. this.webSocket.onerror = (e) => { console.error('STT WebSocket error:', e); this.error.set('discovery.rec.wsError'); - this.stopStreaming(); + this.pauseStreaming(); }; this.webSocket.onclose = (event) => { @@ -91,7 +148,7 @@ export class AudioRecorderService { console.warn(`STT WebSocket closed abnormally: ${event.code} (${event.reason})`); this.error.set('discovery.rec.wsClosed'); } - this.stopStreaming(); + this.pauseStreaming(); }; } catch (err) { console.error('Failed to start streaming:', err); @@ -101,9 +158,119 @@ export class AudioRecorderService { } /** - * Stops recording, releases the microphone, and closes the WebSocket connection. + * Pauses: closes the WebSocket and the audio graph and releases the microphone, + * but keeps the shared meeting surface so resuming never re-opens the picker. + */ + pauseStreaming(): void { + this.stopPipeline(); + this.releaseMic(); + } + + /** + * Stops recording, releases the microphone and the shared meeting surface, + * and closes the WebSocket connection. */ stopStreaming(): void { + this.pauseStreaming(); + this.releaseDisplay(); + this.source.set('mic'); + this.notice.set(null); + } + + private async acquireMic(): Promise { + try { + this.micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + return true; + } catch (err) { + console.error('Microphone permission denied/failed:', err); + this.error.set('discovery.rec.micDenied'); + return false; + } + } + + /** + * Opens the screen-share picker and keeps the chosen surface's audio. Its first + * await is `getDisplayMedia` itself, so the caller's click activation is intact. + */ + private async shareDisplay(): Promise { + if (typeof navigator.mediaDevices?.getDisplayMedia !== 'function') return 'unsupported'; + + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getDisplayMedia(MEETING_DISPLAY_MEDIA_OPTIONS); + } catch (err) { + if (isCaptureDismissed(err)) return 'dismissed'; + console.error('Meeting audio capture failed:', err); + return 'failed'; + } + + const [audioTrack] = stream.getAudioTracks(); + if (!audioTrack) { + // Shared without ticking "Share tab audio" (or a surface that carries none). + stream.getTracks().forEach((track) => track.stop()); + return 'no-audio'; + } + + // Only the audio is used. Stopping the video track ends the frame capture while + // the audio track stays live, and "Stop sharing" still ends the audio track. + stream.getVideoTracks().forEach((track) => track.stop()); + this.releaseDisplay(); + this.displayStream = stream; + audioTrack.addEventListener('ended', () => this.onMeetingAudioEnded(stream), { once: true }); + this.meetingAudio.set(true); + this.connectMeetingAudio(); + return 'shared'; + } + + /** + * Surfaces a failed share. A dismissed picker is the user's own choice, so it + * reports nothing (and keeps any current notice, e.g. "meeting audio lost"). + */ + private reportShare(share: Exclude): void { + switch (share) { + case 'dismissed': + break; + case 'no-audio': + this.notice.set('discovery.rec.noMeetingAudio'); + break; + case 'unsupported': + this.error.set('discovery.rec.meetingUnsupported'); + break; + case 'failed': + this.error.set('discovery.rec.shareFailed'); + break; + } + } + + /** "Stop sharing" or the shared tab closing: drop the meeting audio, keep the mic. */ + private onMeetingAudioEnded(stream: MediaStream): void { + if (this.displayStream !== stream) return; + this.releaseDisplay(); + this.notice.set('discovery.rec.meetingAudioEnded'); + } + + /** Feeds the held meeting audio into the live mixer (no-op until the graph exists). */ + private connectMeetingAudio(): void { + if (!this.audioContext || !this.mixer || !this.displayStream || this.displaySource) return; + this.displaySource = this.audioContext.createMediaStreamSource(this.displayStream); + this.displaySource.connect(this.mixer); + } + + private releaseMic(): void { + this.micStream?.getTracks().forEach((track) => track.stop()); + this.micStream = null; + } + + private releaseDisplay(): void { + this.displaySource?.disconnect(); + this.displaySource = null; + this.displayStream?.getTracks().forEach((track) => track.stop()); + this.displayStream = null; + this.meetingAudio.set(false); + } + + /** Tears down the WebSocket and the audio graph, leaving the captured streams alone. */ + private stopPipeline(): void { this.streaming.set(false); if (this.levelTimer !== null) { @@ -119,6 +286,8 @@ export class AudioRecorderService { this.audioWorkletNode = null; } + this.displaySource = null; + this.mixer = null; if (this.audioContext) { if (this.audioContext.state !== 'closed') { void this.audioContext.close(); @@ -126,12 +295,11 @@ export class AudioRecorderService { this.audioContext = null; } - if (this.mediaStream) { - this.mediaStream.getTracks().forEach((track) => track.stop()); - this.mediaStream = null; - } - if (this.webSocket) { + // Detach first: a late close event from this socket must not tear down the next one. + this.webSocket.onopen = null; + this.webSocket.onerror = null; + this.webSocket.onclose = null; if ( this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING @@ -145,20 +313,30 @@ export class AudioRecorderService { } private async startAudioProcessing(): Promise { - if (!this.mediaStream) return; + if (!this.micStream) return; try { const AudioCtx = window.AudioContext || (window as unknown as Window & { webkitAudioContext: typeof AudioContext }) .webkitAudioContext; - this.audioContext = new AudioCtx(); - const source = this.audioContext.createMediaStreamSource(this.mediaStream); - - // Small analyser tap for the UI level meter (real waveform, no fakery). - this.analyser = this.audioContext.createAnalyser(); + const context = new AudioCtx(); + this.audioContext = context; + + // Every source sums into one mono mixer; the stereo meeting audio is down-mixed + // (speakers interpretation) so the rest of the chain sees a single channel. + const mixer = context.createGain(); + mixer.channelCount = 1; + mixer.channelCountMode = 'explicit'; + mixer.channelInterpretation = 'speakers'; + this.mixer = mixer; + context.createMediaStreamSource(this.micStream).connect(mixer); + this.connectMeetingAudio(); + + // Small analyser tap for the UI level meter (real waveform of the mix, no fakery). + this.analyser = context.createAnalyser(); this.analyser.fftSize = 64; - source.connect(this.analyser); + mixer.connect(this.analyser); this.startLevelMeter(); // Define AudioWorklet inline to keep code self-contained and run on audio thread @@ -177,14 +355,18 @@ export class AudioRecorderService { const blob = new Blob([workletCode], { type: 'application/javascript' }); const blobURL = URL.createObjectURL(blob); - await this.audioContext.audioWorklet.addModule(blobURL); + await context.audioWorklet.addModule(blobURL); URL.revokeObjectURL(blobURL); + // Paused or stopped while the module loaded: this graph is already torn down. + if (this.audioContext !== context) return; - this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'audio-processor'); - source.connect(this.audioWorkletNode); - this.audioWorkletNode.connect(this.audioContext.destination); + this.audioWorkletNode = new AudioWorkletNode(context, 'audio-processor'); + mixer.connect(this.audioWorkletNode); + // The processor never writes its output, so this only keeps the node pulled by + // the render graph: the speakers get silence, never an echo of the mix. + this.audioWorkletNode.connect(context.destination); - const inRate = this.audioContext.sampleRate; + const inRate = context.sampleRate; const outRate = 16000; const CHUNK_SIZE = 2048; // 2048 samples = 4096 bytes (since 1 sample is 2 bytes/16-bit) diff --git a/src/app/core/audio/audio-source.ts b/src/app/core/audio/audio-source.ts new file mode 100644 index 0000000..919f90a --- /dev/null +++ b/src/app/core/audio/audio-source.ts @@ -0,0 +1,71 @@ +/** + * Where the recorder takes its audio from: `mic` for face-to-face meetings, or + * `meeting` for virtual calls (Zoom / Meet / Teams), which mixes the microphone + * with the audio of a shared tab, window or screen. + */ +export type AudioSource = 'mic' | 'meeting'; + +/** Chrome's `suppressLocalAudioPlayback` audio constraint (not in lib.dom yet). */ +interface DisplayAudioConstraints extends MediaTrackConstraints { + suppressLocalAudioPlayback?: boolean; +} + +/** Chrome's display-capture extensions on top of the standard options (not in lib.dom yet). */ +interface MeetingDisplayMediaOptions extends Omit { + audio: DisplayAudioConstraints; + systemAudio?: 'include' | 'exclude'; + windowAudio?: 'exclude' | 'window' | 'system'; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; +} + +/** User-Agent Client Hints, exposed by Chromium-based browsers only (not in lib.dom yet). */ +interface NavigatorUAData { + readonly brands: readonly { readonly brand: string }[]; + readonly mobile: boolean; +} + +/** + * The screen-share picker request for meeting audio. Chrome rejects audio-only + * requests, so `video` stays true (the recorder stops that track right away). + * The meeting audio is already clean digital audio, so the voice-call + * processing is disabled; the tab keeps playing locally so the analyst still + * hears the call. ReqsAI's own tab is hidden from the picker. + */ +export const MEETING_DISPLAY_MEDIA_OPTIONS: MeetingDisplayMediaOptions = { + video: true, + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + suppressLocalAudioPlayback: false, + }, + systemAudio: 'include', + windowAudio: 'system', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', +}; + +/** + * True when this browser can capture a meeting's audio through the screen-share + * picker: desktop Chromium (Chrome, Edge…). Firefox and Safari implement + * `getDisplayMedia` without tab/system audio, and mobile browsers lack it, so + * the "virtual meeting" source is offered on desktop Chromium only. + */ +export function supportsMeetingAudio(nav: Navigator | undefined = globalThis.navigator): boolean { + if (typeof nav?.mediaDevices?.getDisplayMedia !== 'function') return false; + const uaData = (nav as Navigator & { userAgentData?: NavigatorUAData }).userAgentData; + if (!uaData || uaData.mobile) return false; + return uaData.brands.some((b) => b.brand === 'Chromium'); +} + +/** + * True when a capture request failed because the user dismissed the picker or + * the permission prompt (`NotAllowedError`, or `AbortError` in some browsers), + * as opposed to a real failure worth reporting. + */ +export function isCaptureDismissed(err: unknown): boolean { + return ( + err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError') + ); +} diff --git a/src/app/features/discovery/data/session-recording.service.ts b/src/app/features/discovery/data/session-recording.service.ts index 2e73aab..69dc8f7 100644 --- a/src/app/features/discovery/data/session-recording.service.ts +++ b/src/app/features/discovery/data/session-recording.service.ts @@ -11,8 +11,9 @@ import { /** * Singleton owner of the active recording session's lifecycle, so navigating * away from the discovery chat never kills a recording. Pressing record - * creates a session implicitly (create → start) and begins mic streaming; - * pause/resume/stop drive both the REST lifecycle and the local microphone. + * creates a session implicitly (create → start) and begins audio streaming; + * pause/resume/stop drive both the REST lifecycle and the local capture. A + * pause keeps a shared meeting surface alive, so resuming needs no picker. * * `attach()` adopts a session that is already active elsewhere (409 * SESSION_ALREADY_ACTIVE): the bar and timer work, but this tab does not @@ -69,8 +70,9 @@ export class SessionRecordingService { /** * Implicit session start: creates the session and immediately starts it, - * then begins mic streaming. The caller should have secured mic permission - * first (so we never leave an orphan RECORDING session without audio). + * then begins audio streaming. The caller should have secured the capture + * first (so we never leave an orphan RECORDING session without audio); it is + * released again if the session cannot start. */ start( projectId: string, @@ -87,7 +89,10 @@ export class SessionRecordingService { void this.recorder.startStreaming(session.id); this.busy.set(false); }, - error: () => this.busy.set(false), + error: () => { + this.recorder.stopStreaming(); + this.busy.set(false); + }, }), ); } @@ -115,7 +120,7 @@ export class SessionRecordingService { next: (updated) => { this._session.set(updated); this.pauseTimer(); - this.recorder.stopStreaming(); + this.recorder.pauseStreaming(); this.busy.set(false); }, error: () => this.busy.set(false), @@ -168,7 +173,7 @@ export class SessionRecordingService { if (session.status !== 'RECORDING') this.resumeTimer(); } else if (status === 'PAUSED') { this.pauseTimer(); - if (this.hasMic) this.recorder.stopStreaming(); + if (this.hasMic) this.recorder.pauseStreaming(); } else { this.teardown(); return; From 6809cbb3834852a04f49d4e1ebcf22b9c9344fe1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:38:33 -0500 Subject: [PATCH 11/38] test(discovery): cover meeting capture, fallbacks and mixing in the recorder Mocks navigator.mediaDevices to cover the picker-before-mic ordering (no await before getDisplayMedia), the display constraints, the dropped video track, the no-audio-track warning, silent picker dismissal, real capture failures, a missing getDisplayMedia, mic denial releasing the shared tab, the "Stop sharing" mic-only fallback and re-share, and pause vs. stop release. A real Web Audio graph with a fake socket checks that both sources feed the mono mixer, which drives the meter and the worklet. Also covers the desktop-Chromium support check and dismissal detection. --- .../core/audio/audio-recorder.service.spec.ts | 385 ++++++++++++++++++ src/app/core/audio/audio-source.spec.ts | 64 +++ 2 files changed, 449 insertions(+) create mode 100644 src/app/core/audio/audio-recorder.service.spec.ts create mode 100644 src/app/core/audio/audio-source.spec.ts diff --git a/src/app/core/audio/audio-recorder.service.spec.ts b/src/app/core/audio/audio-recorder.service.spec.ts new file mode 100644 index 0000000..3f175ec --- /dev/null +++ b/src/app/core/audio/audio-recorder.service.spec.ts @@ -0,0 +1,385 @@ +import { TestBed } from '@angular/core/testing'; +import { MockInstance, vi } from 'vitest'; +import { AuthStore } from '../auth/auth.store'; +import { AuthResponse } from '../auth/auth.model'; +import { AudioRecorderService } from './audio-recorder.service'; + +/** A capture track double: `stop()` is observable and `ended` can be dispatched. */ +class FakeTrack extends EventTarget { + readyState: MediaStreamTrackState = 'live'; + readonly stop = vi.fn(() => { + this.readyState = 'ended'; + }); + + constructor(readonly kind: 'audio' | 'video') { + super(); + } +} + +function fakeStream(...tracks: FakeTrack[]): MediaStream { + return { + getTracks: () => tracks, + getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'), + getVideoTracks: () => tracks.filter((t) => t.kind === 'video'), + } as unknown as MediaStream; +} + +/** The share picker result: a live audio + video pair, as Chrome returns for "Share tab audio". */ +function sharedTab(): { stream: MediaStream; audio: FakeTrack; video: FakeTrack } { + const audio = new FakeTrack('audio'); + const video = new FakeTrack('video'); + return { stream: fakeStream(video, audio), audio, video }; +} + +function micStream(): { stream: MediaStream; track: FakeTrack } { + const track = new FakeTrack('audio'); + return { stream: fakeStream(track), track }; +} + +describe('AudioRecorderService — audio sources', () => { + let recorder: AudioRecorderService; + let getDisplayMedia: MockInstance; + let getUserMedia: MockInstance; + + beforeEach(() => { + TestBed.configureTestingModule({}); + recorder = TestBed.inject(AudioRecorderService); + // Never reach the real devices: each test scripts what the browser answers. + getDisplayMedia = vi + .spyOn(navigator.mediaDevices, 'getDisplayMedia') + .mockRejectedValue(new Error('unscripted getDisplayMedia')); + getUserMedia = vi + .spyOn(navigator.mediaDevices, 'getUserMedia') + .mockRejectedValue(new Error('unscripted getUserMedia')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + recorder.stopStreaming(); + vi.restoreAllMocks(); + }); + + describe('in person (microphone)', () => { + it('captures the microphone only', async () => { + getUserMedia.mockResolvedValue(micStream().stream); + + expect(await recorder.requestPermission('mic')).toBe(true); + + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(recorder.source()).toBe('mic'); + expect(recorder.meetingAudio()).toBe(false); + }); + + it('defaults to the microphone', async () => { + getUserMedia.mockResolvedValue(micStream().stream); + + expect(await recorder.requestPermission()).toBe(true); + + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(recorder.source()).toBe('mic'); + }); + }); + + describe('virtual meeting (microphone + meeting audio)', () => { + it('opens the share picker synchronously, before the microphone, to keep the click activation', async () => { + const tab = sharedTab(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(micStream().stream); + + const pending = recorder.requestPermission('meeting'); + // No await has happened yet: the picker must already be requested. + expect(getDisplayMedia).toHaveBeenCalledTimes(1); + expect(getUserMedia).not.toHaveBeenCalled(); + + expect(await pending).toBe(true); + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); + expect(getDisplayMedia.mock.invocationCallOrder[0]).toBeLessThan( + getUserMedia.mock.invocationCallOrder[0], + ); + }); + + it('asks for unprocessed system/tab audio, hides its own tab and allows switching', async () => { + getDisplayMedia.mockResolvedValue(sharedTab().stream); + getUserMedia.mockResolvedValue(micStream().stream); + + await recorder.requestPermission('meeting'); + + expect(getDisplayMedia).toHaveBeenCalledWith( + expect.objectContaining({ + video: true, + audio: expect.objectContaining({ + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }), + systemAudio: 'include', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', + }), + ); + }); + + it('keeps the meeting audio and drops the unused video track', async () => { + const tab = sharedTab(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(micStream().stream); + + expect(await recorder.requestPermission('meeting')).toBe(true); + + expect(tab.video.stop).toHaveBeenCalled(); + expect(tab.audio.stop).not.toHaveBeenCalled(); + expect(recorder.source()).toBe('meeting'); + expect(recorder.meetingAudio()).toBe(true); + expect(recorder.error()).toBeNull(); + expect(recorder.notice()).toBeNull(); + }); + + it('warns and does not start when the user shared without ticking "Share tab audio"', async () => { + const video = new FakeTrack('video'); + getDisplayMedia.mockResolvedValue(fakeStream(video)); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(video.stop).toHaveBeenCalled(); + expect(getUserMedia).not.toHaveBeenCalled(); + expect(recorder.notice()).toBe('discovery.rec.noMeetingAudio'); + expect(recorder.error()).toBeNull(); + expect(recorder.meetingAudio()).toBe(false); + expect(recorder.source()).toBe('mic'); + }); + + it.each(['NotAllowedError', 'AbortError'])( + 'stays idle without any message when the picker is dismissed (%s)', + async (name) => { + getDisplayMedia.mockRejectedValue(new DOMException('dismissed', name)); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(getUserMedia).not.toHaveBeenCalled(); + expect(recorder.error()).toBeNull(); + expect(recorder.notice()).toBeNull(); + expect(console.error).not.toHaveBeenCalled(); + }, + ); + + it('reports a real capture failure', async () => { + getDisplayMedia.mockRejectedValue(new DOMException('busy', 'NotReadableError')); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(recorder.error()).toBe('discovery.rec.shareFailed'); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + + it('reports an unsupported browser when getDisplayMedia is missing', async () => { + Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', { + value: undefined, + configurable: true, + }); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(recorder.error()).toBe('discovery.rec.meetingUnsupported'); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + + it('releases the shared tab when the microphone is denied', async () => { + const tab = sharedTab(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockRejectedValue(new DOMException('denied', 'NotAllowedError')); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(tab.audio.stop).toHaveBeenCalled(); + expect(recorder.error()).toBe('discovery.rec.micDenied'); + expect(recorder.meetingAudio()).toBe(false); + }); + }); + + describe('"Stop sharing" mid-session', () => { + it('falls back to the microphone with a notice instead of ending the recording', async () => { + const tab = sharedTab(); + const mic = micStream(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(mic.stream); + await recorder.requestPermission('meeting'); + + tab.audio.dispatchEvent(new Event('ended')); + + expect(recorder.meetingAudio()).toBe(false); + expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded'); + expect(mic.track.stop).not.toHaveBeenCalled(); + // Still a meeting recording: the bar offers to share again. + expect(recorder.source()).toBe('meeting'); + }); + + it('re-shares the meeting audio from the session bar', async () => { + const first = sharedTab(); + const second = sharedTab(); + getDisplayMedia.mockResolvedValueOnce(first.stream).mockResolvedValueOnce(second.stream); + getUserMedia.mockResolvedValue(micStream().stream); + await recorder.requestPermission('meeting'); + first.audio.dispatchEvent(new Event('ended')); + + expect(await recorder.shareMeetingAudio()).toBe(true); + + expect(recorder.meetingAudio()).toBe(true); + expect(recorder.notice()).toBeNull(); + // A late event from the old share never drops the new one. + first.audio.dispatchEvent(new Event('ended')); + expect(recorder.meetingAudio()).toBe(true); + expect(second.audio.stop).not.toHaveBeenCalled(); + }); + + it('keeps the "meeting audio lost" notice when the re-share picker is dismissed', async () => { + const tab = sharedTab(); + getDisplayMedia + .mockResolvedValueOnce(tab.stream) + .mockRejectedValueOnce(new DOMException('dismissed', 'NotAllowedError')); + getUserMedia.mockResolvedValue(micStream().stream); + await recorder.requestPermission('meeting'); + tab.audio.dispatchEvent(new Event('ended')); + + expect(await recorder.shareMeetingAudio()).toBe(false); + + expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded'); + expect(recorder.meetingAudio()).toBe(false); + }); + }); + + describe('pause and stop', () => { + it('keeps the shared tab across a pause (no picker on resume) but releases the microphone', async () => { + const tab = sharedTab(); + const mic = micStream(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(mic.stream); + await recorder.requestPermission('meeting'); + + recorder.pauseStreaming(); + + expect(mic.track.stop).toHaveBeenCalled(); + expect(tab.audio.stop).not.toHaveBeenCalled(); + expect(recorder.meetingAudio()).toBe(true); + }); + + it('releases everything on stop and returns to the microphone source', async () => { + const tab = sharedTab(); + const mic = micStream(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(mic.stream); + await recorder.requestPermission('meeting'); + tab.audio.dispatchEvent(new Event('ended')); + + recorder.stopStreaming(); + + expect(mic.track.stop).toHaveBeenCalled(); + expect(tab.audio.stop).toHaveBeenCalled(); + expect(recorder.meetingAudio()).toBe(false); + expect(recorder.source()).toBe('mic'); + expect(recorder.notice()).toBeNull(); + }); + }); +}); + +/** Minimal `/ws/stt` socket that opens on the next macrotask and records what is sent. */ +class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static last: FakeWebSocket | null = null; + + readyState = FakeWebSocket.CONNECTING; + binaryType = 'blob'; + onopen: (() => void) | null = null; + onerror: ((e: Event) => void) | null = null; + onclose: ((e: CloseEvent) => void) | null = null; + readonly close = vi.fn(() => { + this.readyState = FakeWebSocket.CLOSED; + }); + + constructor(readonly url: string) { + FakeWebSocket.last = this; + setTimeout(() => { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + }); + } + + send(): void { + // PCM chunks are irrelevant to the graph-wiring assertions. + } +} + +describe('AudioRecorderService — mixing', () => { + let recorder: AudioRecorderService; + let toneContext: AudioContext; + + /** A real, silent audio track (the Web Audio graph rejects doubles). */ + function realAudioStream(): MediaStream { + return toneContext.createMediaStreamDestination().stream; + } + + beforeEach(() => { + TestBed.configureTestingModule({}); + TestBed.inject(AuthStore).setSession({ + accessToken: 'token', + user: { id: 'user-1' }, + organizationId: null, + } as unknown as AuthResponse); + recorder = TestBed.inject(AudioRecorderService); + toneContext = new AudioContext(); + vi.stubGlobal('WebSocket', FakeWebSocket); + }); + + afterEach(async () => { + recorder.stopStreaming(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await toneContext.close(); + }); + + it('sums the mic and the meeting audio into one mono mixer and keeps the mic when sharing stops', async () => { + const display = realAudioStream(); + const mic = realAudioStream(); + const displayTrack = display.getAudioTracks()[0]; + vi.spyOn(navigator.mediaDevices, 'getDisplayMedia').mockResolvedValue(display); + vi.spyOn(navigator.mediaDevices, 'getUserMedia').mockResolvedValue(mic); + const createGain = vi.spyOn(AudioContext.prototype, 'createGain'); + const createSource = vi.spyOn(AudioContext.prototype, 'createMediaStreamSource'); + const connect = vi.spyOn(AudioNode.prototype, 'connect'); + /** The nodes `from` feeds, in connection order. */ + const targetsOf = (from: AudioNode): unknown[] => + connect.mock.calls.filter((_, i) => connect.mock.contexts[i] === from).map(([to]) => to); + + expect(await recorder.requestPermission('meeting')).toBe(true); + await recorder.startStreaming('sess-1'); + // The worklet is wired last, once its module has loaded. + await vi.waitFor(() => + expect(connect.mock.calls.some(([to]) => to instanceof AudioWorkletNode)).toBe(true), + ); + + const mixer = createGain.mock.results[0].value as GainNode; + expect(mixer.channelCount).toBe(1); + expect(mixer.channelCountMode).toBe('explicit'); + expect(mixer.channelInterpretation).toBe('speakers'); + expect(createSource.mock.calls.map(([stream]) => stream)).toEqual([mic, display]); + for (const { value: source } of createSource.mock.results) { + expect(targetsOf(source as AudioNode)).toEqual([mixer]); + } + // Both the level meter and the PCM worklet read the mix, not a single source. + const [meter, worklet] = targetsOf(mixer); + expect(meter).toBeInstanceOf(AnalyserNode); + expect(worklet).toBeInstanceOf(AudioWorkletNode); + + const displaySource = createSource.mock.results[1].value as MediaStreamAudioSourceNode; + const disconnect = vi.spyOn(displaySource, 'disconnect'); + displayTrack.dispatchEvent(new Event('ended')); + + expect(disconnect).toHaveBeenCalled(); + expect(recorder.streaming()).toBe(true); + expect(FakeWebSocket.last?.close).not.toHaveBeenCalled(); + expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded'); + }); +}); diff --git a/src/app/core/audio/audio-source.spec.ts b/src/app/core/audio/audio-source.spec.ts new file mode 100644 index 0000000..d3c6429 --- /dev/null +++ b/src/app/core/audio/audio-source.spec.ts @@ -0,0 +1,64 @@ +import { isCaptureDismissed, supportsMeetingAudio } from './audio-source'; + +/** A navigator stub: optional getDisplayMedia and optional UA Client Hints. */ +function fakeNavigator(options: { + displayMedia?: boolean; + brands?: string[]; + mobile?: boolean; + mediaDevices?: boolean; +}): Navigator { + const { displayMedia = true, brands, mobile = false, mediaDevices = true } = options; + return { + mediaDevices: mediaDevices + ? { getDisplayMedia: displayMedia ? () => Promise.resolve() : undefined } + : undefined, + userAgentData: brands ? { brands: brands.map((brand) => ({ brand })), mobile } : undefined, + } as unknown as Navigator; +} + +describe('supportsMeetingAudio', () => { + it('is available on desktop Chrome and Edge', () => { + expect( + supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Google Chrome', 'Not.A/Brand'] })), + ).toBe(true); + expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Microsoft Edge'] }))).toBe( + true, + ); + }); + + it('is unavailable on Firefox and Safari (no UA Client Hints, no display audio)', () => { + expect(supportsMeetingAudio(fakeNavigator({}))).toBe(false); + }); + + it('is unavailable on mobile Chromium', () => { + expect( + supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Google Chrome'], mobile: true })), + ).toBe(false); + }); + + it('is unavailable without getDisplayMedia or mediaDevices', () => { + expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium'], displayMedia: false }))).toBe( + false, + ); + expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium'], mediaDevices: false }))).toBe( + false, + ); + }); + + it('is unavailable on non-Chromium engines exposing client hints', () => { + expect(supportsMeetingAudio(fakeNavigator({ brands: ['SomeOtherEngine'] }))).toBe(false); + }); +}); + +describe('isCaptureDismissed', () => { + it('treats a closed picker or a denied prompt as a dismissal', () => { + expect(isCaptureDismissed(new DOMException('Permission denied', 'NotAllowedError'))).toBe(true); + expect(isCaptureDismissed(new DOMException('Aborted', 'AbortError'))).toBe(true); + }); + + it('reports real failures', () => { + expect(isCaptureDismissed(new DOMException('Busy', 'NotReadableError'))).toBe(false); + expect(isCaptureDismissed(new TypeError('bad constraints'))).toBe(false); + expect(isCaptureDismissed(null)).toBe(false); + }); +}); From 4a0c26f0e3adee757de8e00467b8f50d209faa37 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:39:01 -0500 Subject: [PATCH 12/38] feat(discovery): add an in-person / virtual-meeting audio source picker A compact segmented control next to the record button picks where the next recording takes its audio from: "In person (microphone)" or "Virtual meeting (microphone + meeting audio)". The choice is remembered per user in localStorage (try/catch) and locked while starting or once a session is live; the record click passes it to the recorder before any await so the screen-share picker keeps the click's activation, and a second click can't stack another picker. The virtual option stays visible but inert outside desktop Chromium, with the reason as tooltip and, on tap, as the composer hint. With the virtual source selected the hint explains to tick "Share tab audio" and to wear headphones; recorder notices (amber) render in the same line. --- public/i18n/en.json | 7 ++ public/i18n/es.json | 7 ++ .../audio-source-picker.ts | 85 ++++++++++++++ .../discovery/data/audio-source-preference.ts | 25 ++++ .../pages/discovery-chat/discovery-chat.ts | 108 +++++++++++++++++- 5 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts create mode 100644 src/app/features/discovery/data/audio-source-preference.ts diff --git a/public/i18n/en.json b/public/i18n/en.json index 5f1b31e..e3add7f 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1082,6 +1082,13 @@ "empty": "No language found.", "locked": "The language is fixed once recording starts." }, + "source": { + "label": "Audio source", + "mic": "In person (microphone)", + "meeting": "Virtual meeting (microphone + meeting audio)", + "unsupported": "Virtual meeting: available in desktop Chrome or Edge.", + "hint": "When sharing, pick the meeting tab and tick \"Share tab audio\". Use headphones to avoid a duplicated transcript." + }, "bar": { "recording": "Recording", "paused": "Paused", diff --git a/public/i18n/es.json b/public/i18n/es.json index 21bd2de..f31a704 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -1082,6 +1082,13 @@ "empty": "No se encontró el idioma.", "locked": "El idioma queda fijo al iniciar la grabación." }, + "source": { + "label": "Fuente de audio", + "mic": "Presencial (micrófono)", + "meeting": "Reunión virtual (micrófono + audio de la reunión)", + "unsupported": "Reunión virtual: disponible en Chrome o Edge de escritorio.", + "hint": "Al compartir, elige la pestaña de la reunión y marca «Compartir audio de la pestaña». Usa audífonos para no duplicar la transcripción." + }, "bar": { "recording": "Grabando", "paused": "En pausa", diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts new file mode 100644 index 0000000..d8f8039 --- /dev/null +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { provideIcons } from '@ng-icons/core'; +import { lucideMic, lucideMonitorSpeaker } from '@ng-icons/lucide'; +import { AudioSource } from '../../../../core/audio/audio-source'; +import { HlmIcon } from '../../../../shared/ui'; + +interface SourceOption { + value: AudioSource; + icon: string; + label: string; +} + +/** + * Compact segmented control next to the record button: where the next + * recording takes its audio from — the microphone (in person) or the + * microphone plus the meeting's audio (virtual call). Two-way bound via + * `[(value)]`. Where the browser cannot capture meeting audio the virtual + * option stays visible but inert, with the reason as its tooltip; tapping it + * emits `unavailablePicked` so touch users get the reason too. + */ +@Component({ + selector: 'app-audio-source-picker', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmIcon, TranslocoPipe], + viewProviders: [provideIcons({ lucideMic, lucideMonitorSpeaker })], + template: ` +
+ @for (opt of options; track opt.value) { + @let unavailable = opt.value === 'meeting' && !meetingSupported(); + + } +
+ `, +}) +export class AudioSourcePicker { + readonly value = model('mic'); + /** False where the browser cannot capture meeting audio (the virtual option goes inert). */ + readonly meetingSupported = input(true); + /** Locks the choice (e.g. while a session is live). */ + readonly disabled = input(false); + readonly unavailablePicked = output(); + + protected readonly options: SourceOption[] = [ + { value: 'mic', icon: 'lucideMic', label: 'discovery.source.mic' }, + { value: 'meeting', icon: 'lucideMonitorSpeaker', label: 'discovery.source.meeting' }, + ]; + + protected choose(source: AudioSource): void { + if (this.disabled()) return; + if (source === 'meeting' && !this.meetingSupported()) { + this.unavailablePicked.emit(); + return; + } + this.value.set(source); + } + + protected optionClass(source: AudioSource, unavailable: boolean): string { + if (this.disabled() || unavailable) { + return this.value() === source + ? 'cursor-not-allowed bg-secondary text-foreground opacity-60' + : 'cursor-not-allowed text-muted-foreground opacity-50'; + } + return this.value() === source + ? 'cursor-pointer bg-secondary text-foreground' + : 'cursor-pointer text-muted-foreground hover:text-foreground'; + } +} diff --git a/src/app/features/discovery/data/audio-source-preference.ts b/src/app/features/discovery/data/audio-source-preference.ts new file mode 100644 index 0000000..846d013 --- /dev/null +++ b/src/app/features/discovery/data/audio-source-preference.ts @@ -0,0 +1,25 @@ +import { AudioSource } from '../../../core/audio/audio-source'; + +/** + * Per-user audio-source preference (in person vs. virtual meeting), persisted + * in localStorage so the analyst's last choice survives reloads. Pure helpers + * so the fallback rule stays unit-testable. + */ + +const KEY_PREFIX = 'reqsai.discovery.audioSource.'; + +/** The localStorage key holding one user's last audio-source choice. */ +export function audioSourceStorageKey(userId: string): string { + return `${KEY_PREFIX}${userId}`; +} + +/** + * Resolves the source picker's initial value: a stored `meeting` choice wins + * only where this browser can capture meeting audio; anything else is `mic`. + */ +export function resolveAudioSource( + stored: string | null | undefined, + meetingSupported: boolean, +): AudioSource { + return stored === 'meeting' && meetingSupported ? 'meeting' : 'mic'; +} diff --git a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts index 57da0e0..bb38c3a 100644 --- a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts +++ b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts @@ -24,11 +24,14 @@ import { lucideCheck, lucideCircleHelp, lucideClock, + lucideHeadphones, lucideHistory, + lucideInfo, lucideLanguages, lucideMic, lucidePanelRight, lucideSparkles, + lucideTriangleAlert, lucideX, } from '@ng-icons/lucide'; import { AuthStore } from '../../../../core/auth/auth.store'; @@ -36,6 +39,7 @@ import { WorkspaceStore } from '../../../workspace/data/workspace.store'; import { ToastService } from '../../../../shared/toast/toast.service'; import { messageForError } from '../../../../core/errors/error-message'; import { AudioRecorderService } from '../../../../core/audio/audio-recorder.service'; +import { AudioSource, supportsMeetingAudio } from '../../../../core/audio/audio-source'; import { DiscoveryChatStore, RenderBlock } from '../../data/discovery-chat.store'; import { SessionRecordingService } from '../../data/session-recording.service'; import { SpeakerDisplay } from '../../data/feed'; @@ -46,6 +50,7 @@ import { SuggestionResponse, } from '../../data/discovery.models'; import { SessionBar } from '../../components/session-bar/session-bar'; +import { AudioSourcePicker } from '../../components/audio-source-picker/audio-source-picker'; import { ActiveParticipants } from '../../components/active-participants/active-participants'; import { DecisionQueue } from '../../components/decision-queue/decision-queue'; import { SidePanel } from '../../components/side-panel/side-panel'; @@ -53,6 +58,7 @@ import { Select, SelectOption } from '../../../../shared/components/select/selec import { Modal } from '../../../../shared/components/modal/modal'; import { DISCOVERY_LANGUAGES } from '../../data/discovery-languages'; import { languageStorageKey, resolveInitialLanguage } from '../../data/language-preference'; +import { audioSourceStorageKey, resolveAudioSource } from '../../data/audio-source-preference'; import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; /** @@ -60,7 +66,8 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; * chronological, read-only stream of transcript bubbles and resolved decision * cards, chunked by session; scrolling to the top lazily loads older sessions. * The composer at the bottom pairs a disabled ("coming soon") text input with - * the record button. Pending AI suggestions surface in the floating decision + * the audio-source picker (in person vs. virtual meeting) and the record + * button. Pending AI suggestions surface in the floating decision * queue; the side panel exposes the project's stories/info/glossary/constraints. */ @Component({ @@ -71,6 +78,7 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; DatePipe, TranslocoPipe, SessionBar, + AudioSourcePicker, ActiveParticipants, DecisionQueue, SidePanel, @@ -87,11 +95,14 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; lucideCheck, lucideCircleHelp, lucideClock, + lucideHeadphones, lucideHistory, + lucideInfo, lucideLanguages, lucideMic, lucidePanelRight, lucideSparkles, + lucideTriangleAlert, lucideX, }), ], @@ -501,16 +512,24 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; /> @if (canRecord()) { + + + } + @if (recording.status() === 'RECORDING') { - + } + {{ 'discovery.queue.pendingBadgeLabel' | transloco }} + } @else { - -
- -
- + +
+ +
+
- - @if (store.queue().length > 1) { - - - } - -
+
@for (layer of stackLayers(); track layer) { +
}
diff --git a/src/app/features/discovery/components/suggestion-card/suggestion-card.ts b/src/app/features/discovery/components/suggestion-card/suggestion-card.ts index 4c527f8..e8c052d 100644 --- a/src/app/features/discovery/components/suggestion-card/suggestion-card.ts +++ b/src/app/features/discovery/components/suggestion-card/suggestion-card.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, linkedSignal, output, @@ -9,9 +10,15 @@ import { } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { TranslocoPipe } from '@jsverse/transloco'; +import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { provideIcons } from '@ng-icons/core'; -import { lucideCircleHelp, lucidePlus, lucideSparkles, lucideTrash2 } from '@ng-icons/lucide'; +import { + lucideArrowUpRight, + lucideCircleHelp, + lucidePlus, + lucideSparkles, + lucideTrash2, +} from '@ng-icons/lucide'; import { AcceptSuggestionRequest, DisplayStory, @@ -24,49 +31,85 @@ import { emptyEditableCriterion, suggestionCriteria, } from '../../data/discovery.models'; +import { Select, SelectOption } from '../../../../shared/components/select/select'; +import { translateFn } from '../../../../core/i18n/translate-fn'; import { HlmButton, HlmIcon, HlmInput, HlmSpinner } from '../../../../shared/ui'; +import { GherkinSteps } from '../gherkin-steps/gherkin-steps'; +import { PriorityBadge } from '../story-badges/story-badges'; const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +/** Story fields an UPDATE_STORY suggestion can change (highlighted on the proposed side). */ +type StoryField = 'title' | 'role' | 'action' | 'benefit'; + /** * One AI suggestion rendered per type (draft story, story update diff, edge - * case, clarifying question) with an inline edit-before-accept flow. Clicking - * "Edit" opens an editable form inside the card; accepting sends only the - * changed fields to the backend. Read-only when `canDecide` is false. - * `openTarget` asks the page to reveal the target story in the side panel. + * case, clarifying question) with an inline edit-before-accept flow. Everything + * the AI proposed carries the violet "AI" provenance (type chip, card border), + * so it never reads like content an analyst already validated. The story reads + * as its canonical sentence ("Como …, quiero …, para …") and criteria as + * Given/When/Then steps. Clicking "Edit" opens labelled, auto-growing fields; + * accepting sends only the changed fields to the backend. Read-only when + * `canDecide` is false. `openTarget` asks the page to reveal the target story. */ @Component({ selector: 'app-suggestion-card', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgTemplateOutlet, FormsModule, HlmButton, HlmInput, HlmIcon, HlmSpinner, TranslocoPipe], - viewProviders: [provideIcons({ lucideCircleHelp, lucidePlus, lucideSparkles, lucideTrash2 })], + imports: [ + NgTemplateOutlet, + FormsModule, + Select, + GherkinSteps, + PriorityBadge, + HlmButton, + HlmInput, + HlmIcon, + HlmSpinner, + TranslocoPipe, + ], + viewProviders: [ + provideIcons({ + lucideArrowUpRight, + lucideCircleHelp, + lucidePlus, + lucideSparkles, + lucideTrash2, + }), + ], + host: { class: 'flex min-h-0 flex-col' }, template: ` -
- -
+ +
- @if (suggestion().type === 'CLARIFYING_QUESTION') { - - } @else { - - } + @if (suggestion().type !== 'CLARIFYING_QUESTION') { - - {{ 'discovery.suggestion.priority.' + displayPriority() | transloco }} - + @if (displayStoryPoints() !== null) { {{ 'discovery.panel.points' | transloco: { n: displayStoryPoints() } }} @@ -75,76 +118,72 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; } @if (suggestion().relatedTopic; as topic) { {{ topic }} } -
+
@switch (suggestion().type) { @case ('CLARIFYING_QUESTION') { -

{{ suggestion().question }}

+

+ {{ suggestion().question }} +

+

+ {{ 'discovery.suggestion.questionHint' | transloco }} +

} @case ('UPDATE_STORY') { + (editable) version, every changed field highlighted. -->

- {{ 'discovery.suggestion.updates' | transloco }} {{ targetStory()?.title ?? ('discovery.suggestion.storyNotFound' | transloco) }}

-
-

+

+

{{ 'discovery.suggestion.current' | transloco }}

@if (targetStory(); as cur) { -

{{ cur.title }}

-
-
-
- {{ 'discovery.suggestion.role' | transloco }} -
-
{{ cur.role }}
-
-
-
- {{ 'discovery.suggestion.action' | transloco }} -
-
{{ cur.action }}
-
-
-
- {{ 'discovery.suggestion.benefit' | transloco }} -
-
{{ cur.benefit }}
-
-
+

{{ cur.title }}

+

+ {{ 'discovery.story.as' | transloco + }}{{ cur.role }}{{ 'discovery.story.want' | transloco + }}{{ cur.action }}{{ 'discovery.story.soThat' | transloco + }}{{ cur.benefit }}. +

} @else {

{{ 'discovery.suggestion.storyNotFound' | transloco }}

}
-
-

+

+

{{ 'discovery.suggestion.proposed' | transloco }}

+ @if (!editing() && hasChanges()) { +

+ + {{ 'discovery.suggestion.changedHint' | transloco }} +

+ } @@ -154,7 +193,7 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (targetStory(); as cur) {

- {{ 'discovery.suggestion.forStory' | transloco }} {{ cur.title }}

@@ -163,8 +202,8 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; {{ 'discovery.suggestion.storyNotFound' | transloco }}

} -
-

+

+

{{ 'discovery.suggestion.scenarioToAdd' | transloco }}

@@ -179,32 +218,14 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (!editing() && suggestion().type !== 'EDGE_CASE' && criteria().length > 0) { -
-

- {{ 'discovery.suggestion.criteria' | transloco }} +

+

+ {{ 'discovery.suggestion.criteriaCount' | transloco: { count: criteria().length } }}

    @for (criterion of criteria(); track $index) { -
  • - @if (criterion.scenario) { -

    {{ criterion.scenario }}

    - } -

    - {{ - 'discovery.suggestion.criteriaGiven' | transloco - }} - {{ criterion.given }} · - {{ - 'discovery.suggestion.criteriaWhen' | transloco - }} - {{ criterion.when }} · - {{ - 'discovery.suggestion.criteriaThen' | transloco - }} - {{ criterion.then }} -

    +
  • +
  • }
@@ -214,17 +235,18 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (suggestion().targetStoryId && suggestion().type !== 'NEW_STORY') { }
@if (canDecide()) { -
+
-
+ } -
+ @@ -279,83 +303,72 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; }} -
- - - -
-
-
+
} @else if (firstCriterion(); as c) { -
- @if (c.scenario) { -

{{ c.scenario }}

- } -

- {{ - 'discovery.suggestion.criteriaGiven' | transloco - }} - {{ c.given }} · - {{ - 'discovery.suggestion.criteriaWhen' | transloco - }} - {{ c.when }} · - {{ - 'discovery.suggestion.criteriaThen' | transloco - }} - {{ c.then }} -

-
+ } @else {

{{ 'discovery.suggestion.noCriterion' | transloco }} @@ -437,19 +425,19 @@ const PRIORITIES: SuggestionPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; @if (editing()) { -

-

+

+

{{ 'discovery.suggestion.criteria' | transloco }}

@for (criterion of model().criteria; track $index) { -
-
- +
+
+ {{ 'discovery.suggestion.criterion' | transloco }} {{ $index + 1 }} - } +
+ @for (t of tabs; track t) { + + } +
@@ -212,33 +224,13 @@ const QUICK_VIEW_SIZE = 100; >.

@if (story.acceptanceCriteria.length > 0) { -

+

{{ 'discovery.suggestion.criteria' | transloco }}

    @for (c of story.acceptanceCriteria; track $index) { -
  • - @if (c.scenario) { -

    {{ c.scenario }}

    - } -

    - {{ - 'discovery.suggestion.criteriaGiven' | transloco - }} - {{ c.given }} · - {{ - 'discovery.suggestion.criteriaWhen' | transloco - }} - {{ c.when }} · - {{ - 'discovery.suggestion.criteriaThen' | transloco - }} - {{ c.then }} -

    +
  • +
  • }
@@ -515,16 +507,6 @@ export class SidePanel { this.priorityFilter.update((current) => (current === priority ? null : priority)); } - protected priorityClass(priority: string): string { - const classes: Record = { - CRITICAL: 'bg-destructive/20 text-destructive', - HIGH: 'bg-destructive/15 text-destructive', - MEDIUM: 'bg-amber-500/15 text-amber-600', - LOW: 'bg-secondary text-muted-foreground', - }; - return classes[priority?.toUpperCase()] ?? 'bg-secondary text-muted-foreground'; - } - private loadGlossary(): void { const orgId = this.auth.organizationId(); if (!orgId) { diff --git a/src/app/features/discovery/data/ai-activity.spec.ts b/src/app/features/discovery/data/ai-activity.spec.ts new file mode 100644 index 0000000..ff59355 --- /dev/null +++ b/src/app/features/discovery/data/ai-activity.spec.ts @@ -0,0 +1,63 @@ +import { aiActivityFor } from './ai-activity'; +import { SessionStatus } from './discovery.models'; + +const NOW = Date.parse('2026-10-07T15:00:00Z'); +const session = (status: SessionStatus, processingError: string | null = null) => ({ + status, + processingError, +}); + +describe('aiActivityFor', () => { + it('reports the AI as listening while recording, without a last suggestion when none is pending', () => { + expect(aiActivityFor(session('RECORDING'), [], NOW)).toEqual({ + state: 'listening', + detail: null, + last: null, + }); + }); + + it('dates "last suggestion" from the newest pending suggestion of the session', () => { + const activity = aiActivityFor( + session('RECORDING'), + [{ createdAt: '2026-10-07T14:50:00Z' }, { createdAt: '2026-10-07T14:57:00Z' }], + NOW, + ); + + expect(activity?.last).toEqual({ + kind: 'relative', + key: 'discovery.time.minutes', + params: { n: 3 }, + }); + }); + + it('ignores unparseable timestamps when picking the newest suggestion', () => { + const activity = aiActivityFor(session('RECORDING'), [{ createdAt: 'not-a-date' }], NOW); + + expect(activity?.last).toBeNull(); + }); + + it('is paused while the recording is paused', () => { + expect( + aiActivityFor(session('PAUSED'), [{ createdAt: '2026-10-07T14:57:00Z' }], NOW)?.state, + ).toBe('paused'); + }); + + it('is processing between Stop and the final stories (STOPPED and PROCESSING)', () => { + expect(aiActivityFor(session('STOPPED'), [], NOW)?.state).toBe('processing'); + expect(aiActivityFor(session('PROCESSING'), [], NOW)?.state).toBe('processing'); + }); + + it('surfaces the backend reason when processing failed', () => { + expect(aiActivityFor(session('FAILED', ' Transcript too short '), [], NOW)).toEqual({ + state: 'failed', + detail: 'Transcript too short', + last: null, + }); + expect(aiActivityFor(session('FAILED', ' '), [], NOW)?.detail).toBeNull(); + }); + + it('shows nothing for settled or not-yet-started sessions', () => { + expect(aiActivityFor(session('COMPLETED'), [], NOW)).toBeNull(); + expect(aiActivityFor(session('DRAFT'), [], NOW)).toBeNull(); + }); +}); diff --git a/src/app/features/discovery/data/ai-activity.ts b/src/app/features/discovery/data/ai-activity.ts new file mode 100644 index 0000000..f3b4d64 --- /dev/null +++ b/src/app/features/discovery/data/ai-activity.ts @@ -0,0 +1,56 @@ +import { DiscoverySessionResponse, SuggestionResponse } from './discovery.models'; +import { RelativeTime, relativeTime } from './relative-time'; + +/** What the AI is doing for a session, as shown at the live edge of its feed block. */ +type AiActivityState = 'listening' | 'paused' | 'processing' | 'failed'; + +export interface AiActivity { + state: AiActivityState; + /** Extra line that replaces the default hint (the processing failure reason). */ + detail: string | null; + /** When the newest still-pending suggestion of this session was proposed (listening only). */ + last: Extract | null; +} + +/** + * Maps a session's lifecycle to the AI status line of its feed block, or null once + * the session has settled (COMPLETED) or never started (DRAFT): + * + * - RECORDING → listening (real-time suggestions are being produced), plus how long + * ago the newest pending suggestion arrived, when there is one; + * - PAUSED → paused (no audio, so no analysis until resumed); + * - STOPPED / PROCESSING → processing (the final stories are being generated); + * - FAILED → failed, carrying the backend's reason when it sent one. + */ +export function aiActivityFor( + session: Pick, + pending: readonly Pick[], + now: number, +): AiActivity | null { + switch (session.status) { + case 'RECORDING': + return { state: 'listening', detail: null, last: newestPending(pending, now) }; + case 'PAUSED': + return { state: 'paused', detail: null, last: null }; + case 'STOPPED': + case 'PROCESSING': + return { state: 'processing', detail: null, last: null }; + case 'FAILED': + return { state: 'failed', detail: session.processingError?.trim() || null, last: null }; + default: + return null; + } +} + +function newestPending( + pending: readonly Pick[], + now: number, +): AiActivity['last'] { + const newest = pending + .map((s) => Date.parse(s.createdAt)) + .filter((t) => !Number.isNaN(t)) + .reduce((max, t) => (max === null || t > max ? t : max), null); + if (newest === null) return null; + const label = relativeTime(new Date(newest).toISOString(), now); + return label.kind === 'relative' ? label : null; +} diff --git a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts index bb38c3a..0979a4f 100644 --- a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts +++ b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts @@ -21,8 +21,10 @@ import { provideIcons } from '@ng-icons/core'; import { lucideArrowDown, lucideArrowUpRight, - lucideCheck, - lucideCircleHelp, + lucideCircleAlert, + lucideCircleCheck, + lucideCircleDashed, + lucideCircleX, lucideClock, lucideHeadphones, lucideHistory, @@ -30,9 +32,11 @@ import { lucideLanguages, lucideMic, lucidePanelRight, + lucidePause, + lucideRotateCw, + lucideScreenShare, lucideSparkles, lucideTriangleAlert, - lucideX, } from '@ng-icons/lucide'; import { AuthStore } from '../../../../core/auth/auth.store'; import { WorkspaceStore } from '../../../workspace/data/workspace.store'; @@ -42,13 +46,14 @@ import { AudioRecorderService } from '../../../../core/audio/audio-recorder.serv import { AudioSource, supportsMeetingAudio } from '../../../../core/audio/audio-source'; import { DiscoveryChatStore, RenderBlock } from '../../data/discovery-chat.store'; import { SessionRecordingService } from '../../data/session-recording.service'; -import { SpeakerDisplay } from '../../data/feed'; +import { DecisionEntry, SpeakerDisplay } from '../../data/feed'; import { RelativeTime, relativeTime } from '../../data/relative-time'; import { AcceptSuggestionRequest, SessionTranscriptSegmentMessage, SuggestionResponse, } from '../../data/discovery.models'; +import { AiActivity, aiActivityFor } from '../../data/ai-activity'; import { SessionBar } from '../../components/session-bar/session-bar'; import { AudioSourcePicker } from '../../components/audio-source-picker/audio-source-picker'; import { ActiveParticipants } from '../../components/active-participants/active-participants'; @@ -62,13 +67,15 @@ import { audioSourceStorageKey, resolveAudioSource } from '../../data/audio-sour import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; /** - * The default discovery view: a GPT/Claude-style chat. The center feed is a - * chronological, read-only stream of transcript bubbles and resolved decision - * cards, chunked by session; scrolling to the top lazily loads older sessions. - * The composer at the bottom pairs a disabled ("coming soon") text input with - * the audio-source picker (in person vs. virtual meeting) and the record - * button. Pending AI suggestions surface in the floating decision - * queue; the side panel exposes the project's stories/info/glossary/constraints. + * The default discovery view. The center feed is a chronological, read-only + * stream chunked by session: neutral transcript bubbles, compact human decision + * rows (accepted = validated by the analyst), AI-generated stories and, at the + * live edge, a status line saying what the AI is doing (listening, paused, + * processing, failed). Scrolling to the top lazily loads older sessions. Pending + * AI suggestions wait in a review tray docked over the top of the feed column — + * never over the session bar or the side panel. The composer pairs the + * audio-source picker (in person vs. virtual meeting) with the record button; + * the side panel exposes the project's stories/info/glossary/constraints. */ @Component({ selector: 'app-discovery-chat', @@ -92,8 +99,10 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; provideIcons({ lucideArrowDown, lucideArrowUpRight, - lucideCheck, - lucideCircleHelp, + lucideCircleAlert, + lucideCircleCheck, + lucideCircleDashed, + lucideCircleX, lucideClock, lucideHeadphones, lucideHistory, @@ -101,9 +110,11 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; lucideLanguages, lucideMic, lucidePanelRight, + lucidePause, + lucideRotateCw, + lucideScreenShare, lucideSparkles, lucideTriangleAlert, - lucideX, }), ], host: { class: 'flex min-h-0 flex-1 flex-col' }, @@ -204,9 +215,9 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; > {{ 'discovery.live.banner' | transloco: { language: liveLabel } }}
@@ -217,7 +228,7 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; } - -
- @switch (store.state()) { - @case ('loading') { -
- -
- } - @case ('error') { -

- {{ 'discovery.loadError' | transloco }} -

- } - @default { - @if (store.loadingOlder()) { + +
+
+ @switch (store.state()) { + @case ('loading') { +
+ +
+ } + @case ('error') {
- - {{ 'discovery.loadingOlder' | transloco }} -
- } @else if (!store.hasOlder() && store.blocks().length > 0 && projectCreatedAt()) { -
- - - {{ - 'discovery.startMarker' - | transloco: { date: projectCreatedAt() | date: 'mediumDate' } - }} - - + +
} - - @if (store.blocks().length === 0) { -
- - - -
-

{{ 'discovery.emptyTitle' | transloco }}

-

- {{ 'discovery.emptyBody' | transloco }} -

+ + {{ 'discovery.loadingOlder' | transloco }}
-
- } - - @for (block of store.blocks(); track block.session.id) { -
- -
+ } @else if (!store.hasOlder() && store.blocks().length > 0 && projectCreatedAt()) { +
- @let sessionAt = block.session.startedAt ?? block.session.createdAt; + + {{ + 'discovery.startMarker' + | transloco: { date: projectCreatedAt() | date: 'mediumDate' } + }} + + +
+ } + + @if (store.blocks().length === 0) { +
- {{ 'discovery.sessionSeparator' | transloco }} - @let sessionTime = timeLabel(sessionAt); - @if (sessionTime.kind === 'relative') { - {{ sessionTime.key | transloco: sessionTime.params }} - } @else { - {{ sessionAt | date: 'MMM d · HH:mm' }} - } - @if ( - block.session.storiesGeneratedCount !== null && - block.session.storiesGeneratedCount !== undefined - ) { - · - {{ - 'discovery.sessionStories' - | transloco: { count: block.session.storiesGeneratedCount } - }} - } + - +
+

{{ 'discovery.emptyTitle' | transloco }}

+

+ {{ 'discovery.emptyBody' | transloco }} +

+
+ } - @if (!block.loaded) { -
- } + @for (block of store.blocks(); track block.session.id) { +
+ +
+ @let sessionAt = block.session.startedAt ?? block.session.createdAt; + + {{ 'discovery.sessionSeparator' | transloco }} + @let sessionTime = timeLabel(sessionAt); + @if (sessionTime.kind === 'relative') { + {{ sessionTime.key | transloco: sessionTime.params }} + } @else { + {{ sessionAt | date: 'MMM d · HH:mm' }} + } + @if ( + block.session.storiesGeneratedCount !== null && + block.session.storiesGeneratedCount !== undefined + ) { + · + {{ + 'discovery.sessionStories' + | transloco: { count: block.session.storiesGeneratedCount } + }} + } + +
- @for (item of block.items; track item.id) { - @switch (item.kind) { - @case ('paragraph') { -
-

{{ item.text }}

-
- } - @case ('segment') { - @let speaker = speakerFor(block, item.segment); -
- @if (speaker) { + @if (!block.loaded) { +
+ } + + @for (item of block.items; track item.id) { + @switch (item.kind) { + @case ('paragraph') { +
+

{{ item.text }}

+
+ } + @case ('segment') { + + @let speaker = speakerFor(block, item.segment); +
- {{ 'discovery.speaker' | transloco: { n: speaker.index } }} + @if (speaker) { + {{ + 'discovery.speaker' | transloco: { n: speaker.index } + }} + + } + - } +
+

{{ item.segment.text }}

+
+
+ } + @case ('decision') { + + @let accepted = item.decision.outcome === 'ACCEPTED';
-

{{ item.segment.text }}

- -

- {{ item.segment.occurredAt | date: 'HH:mm' }} -

+
-
- } - @case ('decision') { -
- -
-
- - @if (item.decision.outcome === 'ACCEPTED') { - - {{ 'discovery.decision.accepted' | transloco }} - } @else { - - {{ 'discovery.decision.dismissed' | transloco }} - } + } + @case ('story') { + +
+
+ + - @if (item.decision.type === 'CLARIFYING_QUESTION') { - - } - {{ 'discovery.suggestion.type.' + item.decision.type | transloco }} +
- @if (item.decision.storyId) { +

{{ item.story.title }}

+

+ {{ 'discovery.story.as' | transloco }} + {{ item.story.role }}{{ 'discovery.story.want' | transloco }} + {{ item.story.action }}{{ 'discovery.story.soThat' | transloco }} + {{ item.story.benefit }}. +

+
+ @if (item.story.createdAt; as storyAt) { + @let storyTime = timeLabel(storyAt); + + } - } +
+ } + } + } - - @if (item.decision.label) { -

+ @if (activityFor(block); as activity) { +

+ @switch (activity.state) { + @case ('listening') { + } - - - @let decisionTime = timeLabel(item.decision.occurredAt); - - @if (decisionTime.kind === 'relative') { - {{ decisionTime.key | transloco: decisionTime.params }} - } @else { - {{ item.decision.occurredAt | date: 'd MMM' }} - } - -
- } - @case ('story') { -
- - - {{ 'discovery.generatedStory' | transloco }} - -

{{ item.story.title }}

-

- {{ 'discovery.story.as' | transloco }} - {{ item.story.role }}{{ 'discovery.story.want' | transloco }} - {{ item.story.action }}{{ 'discovery.story.soThat' | transloco }} - {{ item.story.benefit }}. + @case ('paused') { + + } + @case ('processing') { + + } + @case ('failed') { + + } + } +

+

+ {{ 'discovery.ai.' + activity.state | transloco }}

-
- @if (item.story.createdAt; as storyAt) { - - @let storyTime = timeLabel(storyAt); -

- @if (storyTime.kind === 'relative') { - {{ storyTime.key | transloco: storyTime.params }} - } @else { - {{ storyAt | date: 'd MMM' }} - } -

- } - -
+ @if (activity.detail; as detail) { +

+ {{ detail }} +

+ } @else if (activity.state === 'listening' && activity.last) { +

+ {{ + 'discovery.ai.lastSuggestion' + | transloco + : { + time: (activity.last.key | transloco: activity.last.params), + } + }} +

+ } @else { +

+ {{ 'discovery.ai.' + activity.state + 'Hint' | transloco }} +

+ }
- } +
} - } -
+
+ } } } - } - @if (!atBottom()) { - - } + @if (!atBottom()) { + + } +
+ +
- +
-
+ @if (recorder.error(); as errKey) { -

{{ errKey | transloco }}

+ } @else if (recorder.notice(); as noticeKey) { -

- - {{ noticeKey | transloco }} -

- } @else if (sourceHint(); as hintKey) { -

- - {{ hintKey | transloco }} -

+ + {{ noticeKey | transloco }} + @if (canRecord() && noticeKey === 'discovery.rec.noMeetingAudio') { + + } +
+ } @else if (sourceHint(); as hint) { + @if (hint === 'unsupported') { +

+ + {{ 'discovery.source.unsupported' | transloco }} +

+ } @else { + +
    +
  • + + {{ 'discovery.source.hintShare' | transloco }} +
  • +
  • + + {{ 'discovery.source.hintHeadphones' | transloco }} +
  • +
+ } }
@@ -592,13 +740,45 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; }
- - + +

+ @if (store.queue().length > 0) { + {{ 'discovery.queue.announce' | transloco: { count: store.queue().length } }} + } +

+ + @@ -757,10 +937,10 @@ export class DiscoveryChat implements OnInit { * The composer's guidance line before recording: why the virtual option is * unavailable, or how to share the meeting audio (and to wear headphones). */ - protected readonly sourceHint = computed(() => { + protected readonly sourceHint = computed<'unsupported' | 'meeting' | null>(() => { if (!this.canRecord() || this.recording.isActive()) return null; - if (this.meetingUnavailableHint()) return 'discovery.source.unsupported'; - return this.audioSource() === 'meeting' ? 'discovery.source.hint' : null; + if (this.meetingUnavailableHint()) return 'unsupported'; + return this.audioSource() === 'meeting' ? 'meeting' : null; }); /** Uppercased primary subtag of the editable language, for the select's mobile trigger. */ @@ -894,6 +1074,16 @@ export class DiscoveryChat implements OnInit { protected accept(suggestion: SuggestionResponse, body: AcceptSuggestionRequest): void { this.store.decide(suggestion, 'ACCEPTED', body).subscribe({ + // Confirm where the accepted content went: a question is only marked as + // addressed, everything else lands in the backlog as a draft. + next: () => + this.toast.success( + this.transloco.translate( + suggestion.type === 'CLARIFYING_QUESTION' + ? 'discovery.decision.resolvedToast' + : 'discovery.decision.acceptedToast', + ), + ), error: (err: HttpErrorResponse) => this.handleDecideError(err, suggestion.id), }); } @@ -951,8 +1141,56 @@ export class DiscoveryChat implements OnInit { protected decisionClass(outcome: 'ACCEPTED' | 'DISMISSED'): string { return outcome === 'ACCEPTED' - ? 'border-emerald-500/30 bg-emerald-500/10' - : 'border-border bg-secondary/60 text-muted-foreground'; + ? 'bg-verified-soft ring-1 ring-inset ring-verified-border' + : 'bg-muted/70 text-muted-foreground'; + } + + /** "Accepted/resolved by the analyst" makes the human validation explicit; questions are resolved. */ + protected decisionLabel(decision: DecisionEntry): string { + if (decision.outcome === 'DISMISSED') return 'discovery.decision.dismissed'; + return decision.type === 'CLARIFYING_QUESTION' + ? 'discovery.decision.resolvedBy' + : 'discovery.decision.acceptedBy'; + } + + /** + * What the AI is doing for a session block (live/paused/processing/failed), or + * null for a settled session. "Last suggestion" is the newest pending one of + * this session still in the review queue. + */ + protected activityFor(block: RenderBlock): AiActivity | null { + const pending = this.store.queue().filter((s) => s.sessionId === block.session.id); + return aiActivityFor(block.session, pending, this.now()); + } + + protected activityClass(state: AiActivity['state']): string { + switch (state) { + case 'listening': + case 'processing': + return 'bg-ai-soft text-ai'; + case 'failed': + return 'border border-destructive/30 bg-destructive/5 text-destructive'; + default: + return 'bg-muted/70 text-muted-foreground'; + } + } + + /** Re-runs the initial feed load after an error. */ + protected retryLoad(): void { + this.store.init(this.projectId()); + } + + /** + * "Share again" from the no-meeting-audio notice: mid-session it re-opens the + * share picker for the running recording; before one, it simply retries the + * record flow (both straight from the click, which the picker needs). + */ + protected retryMeetingShare(): void { + if (this.recording.isActive()) { + void this.recorder.shareMeetingAudio(); + return; + } + void this.record(); } /** @@ -968,9 +1206,14 @@ export class DiscoveryChat implements OnInit { return label ? block.speakers.get(label) : undefined; } - /** Bubble styling per side: the right (2nd) speaker gets the primary tint to read as "the other side". */ + /** + * Bubble styling per side, kept neutral so the transcript recedes behind AI and + * human decisions: the left speaker is filled, the right (2nd) one outlined. + */ protected segmentBubbleClass(speaker: SpeakerDisplay | undefined): string { - return speaker?.side === 'right' ? 'bg-primary/10' : 'bg-secondary'; + return speaker?.side === 'right' + ? 'rounded-tr-md border border-border bg-card' + : 'rounded-tl-md bg-secondary'; } /** Picks a language and persists it as this user's per-project override. */ From a1586b64ead1a967e03b03b1d3900cf6a9cf1459 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 01:36:23 -0500 Subject: [PATCH 23/38] feat(discovery): label the audio source picker and keep the bar's state announced The picker becomes a labelled segmented control (In person / Virtual meeting) so it is never confused with the record button, which now shows a record dot and a "Record" label. The virtual-meeting guidance is a calm two-step note (share the meeting tab with its audio; wear headphones), and the missing-audio notice offers "Share again" in place. The session bar keeps its status label for screen readers on mobile and shows the meeting-audio badge on every width. --- .../audio-source-picker.spec.ts | 10 +++++ .../audio-source-picker.ts | 39 +++++++++++++------ .../session-bar/session-bar.spec.ts | 16 ++++++-- .../components/session-bar/session-bar.ts | 22 +++++++---- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts index 29e34e8..771802a 100644 --- a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts @@ -29,6 +29,16 @@ describe('AudioSourcePicker', () => { expect(option(el, 'mic').getAttribute('aria-pressed')).toBe('false'); }); + it('labels each source with visible text, not an icon alone', () => { + const { el } = render(); + + expect(option(el, 'mic').textContent).toContain('discovery.source.micShort'); + expect(option(el, 'meeting').textContent).toContain('discovery.source.meetingShort'); + // The full description stays the accessible name and tooltip. + expect(option(el, 'meeting').getAttribute('aria-label')).toContain('discovery.source.meeting'); + expect(option(el, 'meeting').getAttribute('aria-label')).not.toContain('meetingShort'); + }); + it('switches to the virtual meeting source', () => { const { fixture, el } = render(); const changes: AudioSource[] = []; diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts index d8f8039..b3a338f 100644 --- a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts @@ -8,13 +8,17 @@ import { HlmIcon } from '../../../../shared/ui'; interface SourceOption { value: AudioSource; icon: string; + /** Full description: the accessible name and tooltip. */ label: string; + /** Short visible label (always a prefix of the full one, so the name matches what is seen). */ + short: string; } /** - * Compact segmented control next to the record button: where the next - * recording takes its audio from — the microphone (in person) or the - * microphone plus the meeting's audio (virtual call). Two-way bound via + * Labelled segmented control next to the record button: where the next + * recording takes its audio from — "In person" (microphone) or "Virtual + * meeting" (microphone plus the meeting's audio). Visible text, not icons + * alone, so it is never confused with the record button. Two-way bound via * `[(value)]`. Where the browser cannot capture meeting audio the virtual * option stays visible but inert, with the reason as its tooltip; tapping it * emits `unavailablePicked` so touch users get the reason too. @@ -24,11 +28,12 @@ interface SourceOption { changeDetection: ChangeDetectionStrategy.OnPush, imports: [HlmIcon, TranslocoPipe], viewProviders: [provideIcons({ lucideMic, lucideMonitorSpeaker })], + host: { class: 'block' }, template: `
@for (opt of options; track opt.value) { @@ -40,11 +45,12 @@ interface SourceOption { [attr.aria-disabled]="disabled() || unavailable" [attr.aria-label]="opt.label | transloco" [title]="(unavailable ? 'discovery.source.unsupported' : opt.label) | transloco" - class="grid h-full w-9 place-items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + class="flex h-full min-w-0 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" [class]="optionClass(opt.value, unavailable)" [attr.data-testid]="'audio-source-' + opt.value" > - +
@@ -59,8 +65,18 @@ export class AudioSourcePicker { readonly unavailablePicked = output(); protected readonly options: SourceOption[] = [ - { value: 'mic', icon: 'lucideMic', label: 'discovery.source.mic' }, - { value: 'meeting', icon: 'lucideMonitorSpeaker', label: 'discovery.source.meeting' }, + { + value: 'mic', + icon: 'lucideMic', + label: 'discovery.source.mic', + short: 'discovery.source.micShort', + }, + { + value: 'meeting', + icon: 'lucideMonitorSpeaker', + label: 'discovery.source.meeting', + short: 'discovery.source.meetingShort', + }, ]; protected choose(source: AudioSource): void { @@ -73,13 +89,14 @@ export class AudioSourcePicker { } protected optionClass(source: AudioSource, unavailable: boolean): string { + const selected = 'bg-accent text-accent-foreground ring-1 ring-inset ring-primary/25'; if (this.disabled() || unavailable) { return this.value() === source - ? 'cursor-not-allowed bg-secondary text-foreground opacity-60' + ? `cursor-not-allowed opacity-60 ${selected}` : 'cursor-not-allowed text-muted-foreground opacity-50'; } return this.value() === source - ? 'cursor-pointer bg-secondary text-foreground' - : 'cursor-pointer text-muted-foreground hover:text-foreground'; + ? `cursor-pointer ${selected}` + : 'cursor-pointer text-muted-foreground hover:bg-secondary/70 hover:text-foreground'; } } diff --git a/src/app/features/discovery/components/session-bar/session-bar.spec.ts b/src/app/features/discovery/components/session-bar/session-bar.spec.ts index a1c202f..e4e76a8 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.spec.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.spec.ts @@ -69,20 +69,28 @@ describe('SessionBar', () => { expect(stop.getAttribute('title')).toBeTruthy(); }); - it('hides the pause/stop button labels and the status label below the sm breakpoint', () => { + it('hides the pause/stop button labels below the sm breakpoint', () => { const { el } = render(); const pause = el.querySelector('[data-testid="session-bar-pause"]') as HTMLButtonElement; const stop = el.querySelector('[data-testid="session-bar-stop"]') as HTMLButtonElement; - const status = el.querySelector('[data-testid="session-bar-status"]') as HTMLElement; // "hidden sm:inline" is the app's established mobile-icon-only pattern // (see the discovery header's history/panel-toggle buttons). expect(pause.querySelector('span')?.className).toContain('hidden'); expect(pause.querySelector('span')?.className).toContain('sm:inline'); expect(stop.querySelector('span')?.className).toContain('hidden'); - expect(status.className).toContain('hidden'); - expect(status.className).toContain('sm:inline'); + }); + + it('keeps the status label announced on mobile (visually hidden, not removed)', () => { + const { el } = render(); + + const status = el.querySelector('[data-testid="session-bar-status"]') as HTMLElement; + + expect(status.className).toContain('sr-only'); + expect(status.className).toContain('sm:not-sr-only'); + expect(status.className).not.toContain('hidden'); + expect(status.getAttribute('aria-live')).toBe('polite'); }); it('always renders the icon and the elapsed timer regardless of viewport', () => { diff --git a/src/app/features/discovery/components/session-bar/session-bar.ts b/src/app/features/discovery/components/session-bar/session-bar.ts index 6e1d28f..7896ada 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.ts @@ -67,13 +67,17 @@ export function formatElapsed(ms: number): string { > } @else { - + } - -