diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e55d843..fa31a37 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,59 +5,24 @@ on: branches: [ main ] workflow_dispatch: -env: - AWS_REGION: ${{ vars.AWS_REGION }} - S3_BUCKET: ${{ vars.S3_BUCKET }} - CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} +permissions: + contents: read jobs: deploy: + name: Trigger the MVP deploy + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - permissions: - contents: read - id-token: write + timeout-minutes: 5 steps: - - uses: actions/checkout@v7 - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build for production - run: bun run build - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} - aws-region: ${{ env.AWS_REGION }} - - - name: Sync static assets to S3 (long cache — hashed filenames) - run: | - aws s3 sync dist/reqsai-web/browser/ s3://${{ env.S3_BUCKET }}/ \ - --delete \ - --exclude "index.html" \ - --exclude "i18n/*" \ - --cache-control "public,max-age=31536000,immutable" - - - name: Upload index.html and i18n to S3 (no cache — must always be fresh) - run: | - # index.html and the i18n JSON have STABLE, non-fingerprinted names, so they must - # never be served "immutable" — otherwise a browser caches them for a year and never - # picks up new translations after a deploy. Serve them revalidatable instead. - aws s3 cp dist/reqsai-web/browser/index.html \ - s3://${{ env.S3_BUCKET }}/index.html \ - --cache-control "no-cache,no-store,must-revalidate" - aws s3 cp dist/reqsai-web/browser/i18n/ \ - s3://${{ env.S3_BUCKET }}/i18n/ --recursive \ - --cache-control "no-cache,must-revalidate" - - - name: Invalidate CloudFront cache + - name: Dispatch deploy-mvp in reqsai-infra + env: + GH_TOKEN: ${{ secrets.INFRA_DEPLOY_TOKEN }} + SHA: ${{ github.sha }} run: | - aws cloudfront create-invalidation \ - --distribution-id ${{ env.CLOUDFRONT_DISTRIBUTION_ID }} \ - --paths "/*" + if [ -z "$GH_TOKEN" ]; then + echo "::notice title=Deploy not triggered::INFRA_DEPLOY_TOKEN is not set. Add a fine-grained PAT with Actions read and write on Kntro-Soft/reqsai-infra to deploy main automatically." + exit 0 + fi + gh workflow run deploy-mvp.yml --repo Kntro-Soft/reqsai-infra --ref main -f web_ref="$SHA" -f api_ref=keep + echo "Requested the deploy of reqsai-web $SHA: https://github.com/Kntro-Soft/reqsai-infra/actions/workflows/deploy-mvp.yml" >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab50ae..7cfa02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,29 @@ _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 — virtual-meeting audio capture** (`feature/system-audio-capture`): the analyst can now + record a Zoom / Google Meet / Teams call, not only a face-to-face meeting. A compact **audio-source + picker** next to the record button chooses **In person (microphone)** or **Virtual meeting (microphone + + meeting audio)**, remembered per user in localStorage. The virtual source opens the browser's + screen-share picker straight from the record click (`getDisplayMedia` with tab/window/system audio, voice + processing off and ReqsAI's own tab hidden), drops the unused video track and mixes the shared audio with + the mic in one mono Web Audio mixer — the backend keeps receiving the same 16 kHz Int16 PCM on `/ws/stt`, + and the level meter shows the mix. Sharing without ticking "Share tab audio" warns and lets the user + retry; a dismissed picker stays idle silently; "Stop sharing" mid-session keeps recording the mic with a + notice and a **Share again** action in the session bar; pausing keeps the shared tab, so resuming never + re-opens the picker. The virtual option is disabled (with the reason) outside desktop Chrome/Edge, and a + hint recommends headphones to avoid a duplicated transcript (`discovery.source.*` plus new + `discovery.rec.*` / `discovery.bar.*` keys, en/es). - **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 @@ -194,6 +217,55 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress. ### Changed +- **CI — deploy through reqsai-infra** (`ci/deploy-via-infra`): `deploy.yml` no longer syncs to S3 and + invalidates CloudFront; that stack no longer exists, so the old workflow would fail on the next push to + `main`. A push to `main` now asks `Kntro-Soft/reqsai-infra` to run its `deploy-mvp.yml` workflow with + `web_ref` set to the pushed commit; that workflow builds the linux/arm64 nginx image and deploys it to the + single-EC2 MVP host over SSM, and rebuilds reqsai-api from its `main`. Needs the repository secret + `INFRA_DEPLOY_TOKEN` (fine-grained PAT with Actions read and write on `reqsai-infra` only); without it the + job logs a notice and succeeds, so `main` never goes red. Manual runs only dispatch from `main`. +- **UX — MVP usability polish** (`feature/mvp-ux-polish`): a refinement pass on the MVP surfaces driven by + a heuristic evaluation with 6 users (Nielsen, impeccable critique: live session 20 → 26/40, stories + 20 → 24/40), keeping the brand, behaviour and copy. + - *Live session*: the AI suggestion queue docks to the feed column as a framed **review tray** ("AI + suggestions to review", counter with prev/next, minimize) instead of floating over the session bar, + header actions and side panel; from `sm` it stops short of the feed bottom so the latest lines stay + visible. ←/→ browse it, Esc minimizes it, focus returns to it after a decision, and focusing a feed + control it covers minimizes it (WCAG 2.4.11). A status line at the live edge says what the AI is doing — listening + (with the last suggestion's age), paused, **processing the final stories after Stop**, or failed with + the backend reason. The transcript recedes into neutral bubbles with visible speaker/time + ("Participante n" in Spanish); human decisions become compact "accepted/resolved by the analyst" + rows; accepting confirms that the story landed in the backlog as a draft. + - *AI vs human provenance*: new `ai` / `verified` / `pending` tokens. Everything the AI proposed reads + violet (suggestion cards, generated stories, "IA" origin chip); human-validated content reads emerald; + drafts awaiting review read amber. Priority gets its own glyph scale (critical red, high orange, + medium/low neutral), so red is no longer brand, AI, danger and priority at once. + - *Readable stories*: `hlmInput` textareas grow with their content (they were clipped at a fixed + height); stories read as their sentence and criteria as **Gherkin** steps with a keyword gutter in the + suggestion card, side panel, story detail and create page. The story detail leads with the title, + review status (with what it means) and origin, links an AI story to its capture session, and flags + unsaved story and criterion edits. + - *Backlog order*: sortable headers with a direction arrow and `aria-sort`, a visible "sorted by" + caption, priority re-ordered by meaning on the page (the API sorts the enum alphabetically), origin + and status chips (draft as an amber dashed outline), a page-scrolling chip-row layout below `sm`, + unambiguous dates, an empty state with next steps and retry on load errors. Story forms flag empty + required fields inline. + - *Audio source*: the picker is a labelled segmented control (In person / Virtual meeting) next to a + record button with a record dot and 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. + - *Project forms*: only the name is required and both forms say so; the optional technical profile is + one group (business context + tech stack, example placeholders, Enter-to-add hint). Settings go from + eight one-field cards to General and Technical profile, and the logo card is titled for the project. + - *Accessibility*: the brand red fill is `#dc2626` so white labels pass AA (4.83:1), red text in dark + uses a lighter step, muted text and the default focus outline meet AA, filter inputs show focus, + story titles are keyboard links, panel tabs/sort chips expose their state, the mobile session-bar + status stays announced, and new suggestions are announced politely. All measured chips are ≥ 4.5:1 + in both themes. + - Report screenshots (light, 1440×900, Spanish demo data) in `docs/screenshots/mvp/`. New + `discovery.ai.*`, `discovery.queue.*`, `discovery.suggestion.*`, `stories.statusHint.*`, + `stories.origin.*`, `storyForm.*`, `projectCreate.*`, `projectSettings.*` and `common.optional/required` + keys (EN + ES); `discovery.source.hint` is replaced by `hintShare` / `hintHeadphones`. - **Core — centralized backend error handling** (`feature/frontend-error-handling`): a shared `messageForError` helper resolves backend errors by their machine-readable `code` against a single top-level `errors.` i18n block (network / per-status / generic fallback chain). Extended from the 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. diff --git a/docs/screenshots/mvp/01-proyectos.png b/docs/screenshots/mvp/01-proyectos.png new file mode 100644 index 0000000..ad019ec Binary files /dev/null and b/docs/screenshots/mvp/01-proyectos.png differ diff --git a/docs/screenshots/mvp/02-nuevo-proyecto.png b/docs/screenshots/mvp/02-nuevo-proyecto.png new file mode 100644 index 0000000..b448507 Binary files /dev/null and b/docs/screenshots/mvp/02-nuevo-proyecto.png differ diff --git a/docs/screenshots/mvp/03-ajustes-proyecto.png b/docs/screenshots/mvp/03-ajustes-proyecto.png new file mode 100644 index 0000000..6eeec8d Binary files /dev/null and b/docs/screenshots/mvp/03-ajustes-proyecto.png differ diff --git a/docs/screenshots/mvp/04-captura-sesion-en-vivo.png b/docs/screenshots/mvp/04-captura-sesion-en-vivo.png new file mode 100644 index 0000000..046dc46 Binary files /dev/null and b/docs/screenshots/mvp/04-captura-sesion-en-vivo.png differ diff --git a/docs/screenshots/mvp/05-captura-editar-sugerencia.png b/docs/screenshots/mvp/05-captura-editar-sugerencia.png new file mode 100644 index 0000000..06b0fc6 Binary files /dev/null and b/docs/screenshots/mvp/05-captura-editar-sugerencia.png differ diff --git a/docs/screenshots/mvp/06-captura-actualizar-historia.png b/docs/screenshots/mvp/06-captura-actualizar-historia.png new file mode 100644 index 0000000..151fb28 Binary files /dev/null and b/docs/screenshots/mvp/06-captura-actualizar-historia.png differ diff --git a/docs/screenshots/mvp/07-captura-pregunta-ia.png b/docs/screenshots/mvp/07-captura-pregunta-ia.png new file mode 100644 index 0000000..aba5051 Binary files /dev/null and b/docs/screenshots/mvp/07-captura-pregunta-ia.png differ diff --git a/docs/screenshots/mvp/08-captura-procesando.png b/docs/screenshots/mvp/08-captura-procesando.png new file mode 100644 index 0000000..eb3fc02 Binary files /dev/null and b/docs/screenshots/mvp/08-captura-procesando.png differ diff --git a/docs/screenshots/mvp/09-selector-audio-reunion-virtual.png b/docs/screenshots/mvp/09-selector-audio-reunion-virtual.png new file mode 100644 index 0000000..e6cf2e8 Binary files /dev/null and b/docs/screenshots/mvp/09-selector-audio-reunion-virtual.png differ diff --git a/docs/screenshots/mvp/10-selector-audio-presencial.png b/docs/screenshots/mvp/10-selector-audio-presencial.png new file mode 100644 index 0000000..70dc18c Binary files /dev/null and b/docs/screenshots/mvp/10-selector-audio-presencial.png differ diff --git a/docs/screenshots/mvp/11-backlog-historias.png b/docs/screenshots/mvp/11-backlog-historias.png new file mode 100644 index 0000000..d029f97 Binary files /dev/null and b/docs/screenshots/mvp/11-backlog-historias.png differ diff --git a/docs/screenshots/mvp/12-backlog-orden-prioridad.png b/docs/screenshots/mvp/12-backlog-orden-prioridad.png new file mode 100644 index 0000000..9ca529f Binary files /dev/null and b/docs/screenshots/mvp/12-backlog-orden-prioridad.png differ diff --git a/docs/screenshots/mvp/13-detalle-historia.png b/docs/screenshots/mvp/13-detalle-historia.png new file mode 100644 index 0000000..6caecdd Binary files /dev/null and b/docs/screenshots/mvp/13-detalle-historia.png differ diff --git a/docs/screenshots/mvp/14-detalle-historia-gherkin.png b/docs/screenshots/mvp/14-detalle-historia-gherkin.png new file mode 100644 index 0000000..382b647 Binary files /dev/null and b/docs/screenshots/mvp/14-detalle-historia-gherkin.png differ diff --git a/public/i18n/en.json b/public/i18n/en.json index 3639804..d28dcf1 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -7,7 +7,9 @@ "create": "Create", "loading": "Loading", "back": "Back", - "remove": "Remove" + "remove": "Remove", + "optional": "Optional", + "required": "Required" }, "toast": { "dismiss": "Dismiss", @@ -374,8 +376,14 @@ "projectCreate": { "title": "New project", "subtitle": "Give it a name. You can add the tech profile later — it helps the AI but is optional.", - "advanced": "Advanced options", - "advancedHint": "Optional context the AI uses to tailor its suggestions." + "advanced": "Tech profile", + "advancedHint": "Optional context the AI uses to tailor its suggestions.", + "namePlaceholder": "e.g. Patient portal", + "descriptionPlaceholder": "What problem does it solve, and for whom?", + "contextGroup": "Business context", + "stackGroup": "Tech stack", + "chipHint": "Type a value and press Enter to add it.", + "example": "e.g. {{value}}" }, "orgPicker": { "title": "Choose an organization", @@ -670,7 +678,10 @@ "saved": "Changes saved.", "save": "Save changes", "errorValidation": "Check the data and try again.", - "errorGeneric": "Could not save the changes." + "errorGeneric": "Could not save the changes.", + "logo": "Project logo", + "general": "General", + "techProfile": "Tech profile" }, "projectRoles": { "title": "Roles", @@ -882,7 +893,23 @@ "REJECTED": "Rejected", "MERGED": "Merged", "EXPORTED": "Exported" - } + }, + "statusHint": { + "DRAFT": "Draft: not approved yet; you can still edit it before approving.", + "APPROVED": "Approved: validated by the team.", + "REJECTED": "Rejected: discarded by the team.", + "MERGED": "Merged: folded into another story.", + "EXPORTED": "Exported: pushed to an external tracker (e.g. Jira)." + }, + "origin": { + "ai": "AI", + "manual": "Manual", + "aiHint": "Generated by the AI in a capture session", + "manualHint": "Created manually" + }, + "colOrigin": "Origin", + "sortedBy": "Sorted by: {{sort}}", + "emptyRecord": "Record a session" }, "storyForm": { "back": "Back to stories", @@ -918,7 +945,16 @@ "criteriaSaved": "Criterion saved.", "criteriaDeleted": "Criterion removed.", "criteriaIncomplete": "Fill in Given, When and Then for each criterion.", - "criteriaPartial": "Story created, but {{count}} criterion(s) could not be saved. Add them here." + "criteriaPartial": "Story created, but {{count}} criterion(s) could not be saved. Add them here.", + "storySection": "User story", + "leadRole": "As a…", + "leadAction": "I want…", + "leadBenefit": "so that…", + "viewSession": "View source session", + "unsaved": "Unsaved changes", + "criteriaNew": "New · not saved", + "criteriaHint": "Write each criterion as a Gherkin scenario: Given (context), When (action), Then (expected outcome).", + "required": "Fill in this field." }, "glossaryPage": { "title": "Glossary", @@ -1044,7 +1080,7 @@ "sessionSeparator": "Session", "sessionStories": "{{count}} stories", "sessionDefaultTitle": "Discovery session — {{date}}", - "generatedStory": "Generated story", + "generatedStory": "Generated by AI", "speaker": "Speaker {{n}}", "goToStory": "Go to story", "time": { @@ -1062,7 +1098,11 @@ }, "decision": { "accepted": "Accepted", - "dismissed": "Dismissed" + "dismissed": "Dismissed", + "acceptedBy": "Accepted by the analyst", + "resolvedBy": "Resolved by the analyst", + "acceptedToast": "Suggestion accepted: the story is in the backlog as a draft.", + "resolvedToast": "Question marked as resolved." }, "live": { "banner": "Live session in progress — {{language}}" @@ -1074,7 +1114,8 @@ "composer": { "placeholder": "Message discovery…", "comingSoon": "Text to pipeline is coming soon.", - "record": "Start recording" + "record": "Start recording", + "recordShort": "Record" }, "language": { "label": "Meeting language", @@ -1082,12 +1123,26 @@ "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.", + "micShort": "In person", + "meetingShort": "Virtual meeting", + "hintShare": "When you press Record, pick the meeting tab and turn on “Share tab audio”.", + "hintHeadphones": "Wear headphones so your microphone doesn't pick up the meeting again and duplicate the transcript." + }, "bar": { "recording": "Recording", "paused": "Paused", "pause": "Pause", "resume": "Resume", - "stop": "Stop" + "stop": "Stop", + "meetingAudio": "Recording microphone + meeting audio", + "meetingAudioLost": "Meeting audio is not being captured. Share the meeting tab again.", + "reshare": "Share again", + "meetingAudioShort": "Meeting audio" }, "rec": { "micDenied": "Microphone permission denied. Enable it in your browser settings.", @@ -1095,17 +1150,25 @@ "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", - "pendingBadgeLabel": "pending suggestions", + "pendingBadgeLabel": "suggestions to review", "counter": "{{n}} of {{m}}", "prev": "Previous suggestion", "next": "Next suggestion", "collapse": "Collapse", "minimize": "Minimize", - "dragHint": "Drag to browse suggestions" + "dragHint": "Drag to browse suggestions", + "title": "AI suggestions to review", + "announce": "{{count}} AI suggestions to review", + "expand": "Show the suggestions to review", + "titleShort": "AI · to review" }, "suggestion": { "type": { @@ -1137,7 +1200,7 @@ "saveAndAccept": "Save & accept", "dismiss": "Dismiss", "edit": "Edit", - "cancelEdit": "Cancel", + "cancelEdit": "Cancel editing", "done": "Done", "titleField": "Title", "scenario": "Scenario", @@ -1151,7 +1214,16 @@ "removeCriterion": "Remove criterion", "given": "Given", "when": "when", - "then": "then" + "then": "then", + "aiLabel": "AI suggestion", + "aiHint": "Proposed by the AI from the conversation. Awaiting your review.", + "questionHint": "Resolving it only marks it as addressed; the backlog doesn't change.", + "changedHint": "Highlighted: what the AI proposes to change", + "criteriaCount": "Acceptance criteria ({{count}})", + "roleField": "Role · As a…", + "actionField": "Action · I want…", + "benefitField": "Benefit · so that…", + "scenarioPlaceholder": "Optional" }, "panel": { "stories": "Stories", @@ -1213,6 +1285,19 @@ "body": "Leave this page anyway? The session keeps recording in the background and stays active.", "stay": "Stay", "leave": "Leave anyway" + }, + "retry": "Retry", + "pendingReview": "Awaiting review", + "ai": { + "listening": "The AI is listening to the conversation", + "listeningHint": "It proposes stories, edge cases and questions as the meeting goes on; they appear above for your review.", + "lastSuggestion": "Last suggestion {{time}} · review it in the tray above.", + "paused": "Recording paused", + "pausedHint": "The AI resumes its analysis when you resume recording.", + "processing": "Processing the session", + "processingHint": "The AI is generating the final stories. This can take a few minutes.", + "failed": "The session could not be processed", + "failedHint": "Check the session history or try again later." } }, "integrations": { diff --git a/public/i18n/es.json b/public/i18n/es.json index 1b55c02..f28d1ac 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -7,7 +7,9 @@ "create": "Crear", "loading": "Cargando", "back": "Volver", - "remove": "Quitar" + "remove": "Quitar", + "optional": "Opcional", + "required": "Obligatorio" }, "toast": { "dismiss": "Descartar", @@ -374,8 +376,14 @@ "projectCreate": { "title": "Nuevo proyecto", "subtitle": "Dale un nombre. Puedes agregar el perfil técnico luego — ayuda a la IA pero es opcional.", - "advanced": "Opciones avanzadas", - "advancedHint": "Contexto opcional que la IA usa para afinar sus sugerencias." + "advanced": "Perfil técnico", + "advancedHint": "Contexto opcional que la IA usa para afinar sus sugerencias.", + "namePlaceholder": "p. ej. Portal de citas", + "descriptionPlaceholder": "¿Qué problema resuelve y para quién?", + "contextGroup": "Contexto del negocio", + "stackGroup": "Stack técnico", + "chipHint": "Escribe un valor y pulsa Enter para añadirlo.", + "example": "p. ej. {{value}}" }, "orgPicker": { "title": "Elige una organización", @@ -670,7 +678,10 @@ "saved": "Cambios guardados.", "save": "Guardar cambios", "errorValidation": "Revisa los datos e intenta de nuevo.", - "errorGeneric": "No se pudieron guardar los cambios." + "errorGeneric": "No se pudieron guardar los cambios.", + "logo": "Logo del proyecto", + "general": "General", + "techProfile": "Perfil técnico" }, "projectRoles": { "title": "Roles", @@ -882,7 +893,23 @@ "REJECTED": "Rechazada", "MERGED": "Fusionada", "EXPORTED": "Exportada" - } + }, + "statusHint": { + "DRAFT": "Borrador: todavía no está aprobada; puedes editarla antes de aprobarla.", + "APPROVED": "Aprobada: validada por el equipo.", + "REJECTED": "Rechazada: el equipo la descartó.", + "MERGED": "Fusionada: se integró en otra historia.", + "EXPORTED": "Exportada: enviada a un gestor externo (p. ej., Jira)." + }, + "origin": { + "ai": "IA", + "manual": "Manual", + "aiHint": "Generada por la IA en una sesión de captura", + "manualHint": "Creada manualmente" + }, + "colOrigin": "Origen", + "sortedBy": "Ordenadas por: {{sort}}", + "emptyRecord": "Grabar una sesión" }, "storyForm": { "back": "Volver a historias", @@ -918,7 +945,16 @@ "criteriaSaved": "Criterio guardado.", "criteriaDeleted": "Criterio eliminado.", "criteriaIncomplete": "Completa Dado, Cuando y Entonces en cada criterio.", - "criteriaPartial": "Historia creada, pero {{count}} criterio(s) no se pudieron guardar. Añádelos aquí." + "criteriaPartial": "Historia creada, pero {{count}} criterio(s) no se pudieron guardar. Añádelos aquí.", + "storySection": "Historia de usuario", + "leadRole": "Como…", + "leadAction": "quiero…", + "leadBenefit": "para…", + "viewSession": "Ver la sesión de origen", + "unsaved": "Cambios sin guardar", + "criteriaNew": "Nuevo · sin guardar", + "criteriaHint": "Escribe cada criterio como escenario Gherkin: Dado (contexto), Cuando (acción), Entonces (resultado esperado).", + "required": "Completa este campo." }, "glossaryPage": { "title": "Glosario", @@ -1044,8 +1080,8 @@ "sessionSeparator": "Sesión", "sessionStories": "{{count}} historias", "sessionDefaultTitle": "Sesión de discovery — {{date}}", - "generatedStory": "Historia generada", - "speaker": "Usuario {{n}}", + "generatedStory": "Generada por la IA", + "speaker": "Participante {{n}}", "goToStory": "Ir a la historia", "time": { "justNow": "hace un momento", @@ -1062,7 +1098,11 @@ }, "decision": { "accepted": "Aceptada", - "dismissed": "Descartada" + "dismissed": "Descartada", + "acceptedBy": "Aceptada por el analista", + "resolvedBy": "Resuelta por el analista", + "acceptedToast": "Sugerencia aceptada: la historia quedó en el backlog como borrador.", + "resolvedToast": "Pregunta marcada como resuelta." }, "live": { "banner": "Sesión en vivo en curso — {{language}}" @@ -1074,7 +1114,8 @@ "composer": { "placeholder": "Escribe a discovery…", "comingSoon": "La entrada de texto llegará pronto.", - "record": "Iniciar grabación" + "record": "Iniciar grabación", + "recordShort": "Grabar" }, "language": { "label": "Idioma de la reunión", @@ -1082,12 +1123,26 @@ "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.", + "micShort": "Presencial", + "meetingShort": "Reunión virtual", + "hintShare": "Al pulsar Grabar, elige la pestaña de la reunión y activa «Compartir audio de la pestaña».", + "hintHeadphones": "Usa audífonos: así tu micrófono no vuelve a captar la reunión y la transcripción no se duplica." + }, "bar": { "recording": "Grabando", "paused": "En pausa", "pause": "Pausar", "resume": "Reanudar", - "stop": "Detener" + "stop": "Detener", + "meetingAudio": "Grabando micrófono + audio de la reunión", + "meetingAudioLost": "No se está capturando el audio de la reunión. Vuelve a compartir la pestaña de la reunión.", + "reshare": "Compartir de nuevo", + "meetingAudioShort": "Audio de la reunión" }, "rec": { "micDenied": "Permiso de micrófono denegado. Habilítalo en los ajustes de tu navegador.", @@ -1095,17 +1150,25 @@ "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", - "pendingBadgeLabel": "sugerencias pendientes", + "pendingBadgeLabel": "sugerencias por revisar", "counter": "{{n}} de {{m}}", "prev": "Sugerencia anterior", "next": "Sugerencia siguiente", "collapse": "Contraer", "minimize": "Minimizar", - "dragHint": "Arrastra para navegar las sugerencias" + "dragHint": "Arrastra para navegar las sugerencias", + "title": "Sugerencias de la IA por revisar", + "announce": "{{count}} sugerencias de la IA por revisar", + "expand": "Mostrar las sugerencias por revisar", + "titleShort": "IA · por revisar" }, "suggestion": { "type": { @@ -1137,7 +1200,7 @@ "saveAndAccept": "Guardar y aceptar", "dismiss": "Descartar", "edit": "Editar", - "cancelEdit": "Cancelar", + "cancelEdit": "Cancelar edición", "done": "Listo", "titleField": "Título", "scenario": "Escenario", @@ -1151,7 +1214,16 @@ "removeCriterion": "Eliminar criterio", "given": "Dado", "when": "cuando", - "then": "entonces" + "then": "entonces", + "aiLabel": "Sugerencia de la IA", + "aiHint": "Propuesta por la IA a partir de la conversación. Pendiente de tu revisión.", + "questionHint": "Resolverla solo la marca como atendida; no cambia el backlog.", + "changedHint": "Resaltado: lo que la IA propone cambiar", + "criteriaCount": "Criterios de aceptación ({{count}})", + "roleField": "Rol · Como…", + "actionField": "Acción · quiero…", + "benefitField": "Beneficio · para…", + "scenarioPlaceholder": "Opcional" }, "panel": { "stories": "Historias", @@ -1213,6 +1285,19 @@ "body": "¿Salir de esta página de todos modos? La sesión sigue grabando en segundo plano y permanece activa.", "stay": "Quedarse", "leave": "Salir de todos modos" + }, + "retry": "Reintentar", + "pendingReview": "Pendiente de revisión", + "ai": { + "listening": "La IA está escuchando la conversación", + "listeningHint": "Propone historias, casos límite y preguntas a medida que avanza la reunión; aparecerán arriba para tu revisión.", + "lastSuggestion": "Última sugerencia {{time}} · revísala en la bandeja de arriba.", + "paused": "Grabación en pausa", + "pausedHint": "La IA retomará el análisis cuando reanudes la grabación.", + "processing": "Procesando la sesión", + "processingHint": "La IA está generando las historias finales. Puede tardar unos minutos.", + "failed": "No se pudo procesar la sesión", + "failedHint": "Revisa el historial de sesiones o vuelve a intentarlo más tarde." } }, "integrations": { 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/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-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.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); + }); +}); 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/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/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 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; 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 new file mode 100644 index 0000000..771802a --- /dev/null +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { AudioSourcePicker } from './audio-source-picker'; +import { AudioSource } from '../../../../core/audio/audio-source'; + +describe('AudioSourcePicker', () => { + function render( + inputs: { value?: AudioSource; meetingSupported?: boolean; disabled?: boolean } = {}, + ): { fixture: ComponentFixture; el: HTMLElement } { + TestBed.configureTestingModule({ + imports: [AudioSourcePicker, TranslocoTestingModule.forRoot({ langs: { en: {} } })], + }); + const fixture = TestBed.createComponent(AudioSourcePicker); + fixture.componentRef.setInput('value', inputs.value ?? 'mic'); + fixture.componentRef.setInput('meetingSupported', inputs.meetingSupported ?? true); + fixture.componentRef.setInput('disabled', inputs.disabled ?? false); + fixture.detectChanges(); + return { fixture, el: fixture.nativeElement as HTMLElement }; + } + + function option(el: HTMLElement, source: AudioSource): HTMLButtonElement { + return el.querySelector(`[data-testid="audio-source-${source}"]`) as HTMLButtonElement; + } + + it('marks the current source as pressed', () => { + const { el } = render({ value: 'meeting' }); + + expect(option(el, 'meeting').getAttribute('aria-pressed')).toBe('true'); + 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[] = []; + fixture.componentInstance.value.subscribe((v) => changes.push(v)); + + option(el, 'meeting').click(); + fixture.detectChanges(); + + expect(changes).toEqual(['meeting']); + expect(option(el, 'meeting').getAttribute('aria-pressed')).toBe('true'); + }); + + it('keeps the virtual option visible but inert where meeting audio is unsupported', () => { + const { fixture, el } = render({ meetingSupported: false }); + let unavailable = 0; + fixture.componentInstance.unavailablePicked.subscribe(() => unavailable++); + + const meeting = option(el, 'meeting'); + expect(meeting.getAttribute('aria-disabled')).toBe('true'); + expect(meeting.getAttribute('title')).toContain('discovery.source.unsupported'); + + meeting.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toBe('mic'); + expect(unavailable).toBe(1); + }); + + it('ignores clicks while locked by a live session', () => { + const { fixture, el } = render({ disabled: true }); + + option(el, 'meeting').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toBe('mic'); + expect(option(el, 'mic').getAttribute('aria-disabled')).toBe('true'); + }); +}); 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..4db5b3a --- /dev/null +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts @@ -0,0 +1,102 @@ +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; + /** 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; +} + +/** + * 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. + */ +@Component({ + selector: 'app-audio-source-picker', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmIcon, TranslocoPipe], + viewProviders: [provideIcons({ lucideMic, lucideMonitorSpeaker })], + host: { class: 'block' }, + 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', + short: 'discovery.source.micShort', + }, + { + value: 'meeting', + icon: 'lucideMonitorSpeaker', + label: 'discovery.source.meeting', + short: 'discovery.source.meetingShort', + }, + ]; + + 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 { + 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 opacity-60 ${selected}` + : 'cursor-not-allowed text-muted-foreground opacity-50'; + } + return this.value() === source + ? `cursor-pointer ${selected}` + : 'cursor-pointer text-muted-foreground hover:bg-secondary/70 hover:text-foreground'; + } +} diff --git a/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts b/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts index 01d7ce8..1c5d5ba 100644 --- a/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts +++ b/src/app/features/discovery/components/decision-queue/decision-queue.spec.ts @@ -168,15 +168,68 @@ describe('DecisionQueue', () => { expect(el.querySelector('[data-testid="queue-badge-count"]')?.textContent?.trim()).toBe('3'); }); - it('shows the "n de m" counter in the corner tab of the active card', () => { + it('shows the "n de m" counter in the tray header, outside the card body', () => { store.setQueue([suggestion({ id: 'a' }), suggestion({ id: 'b' })]); const el = render(); - const tab = el.querySelector('[data-testid="queue-counter"]'); - expect(tab).not.toBeNull(); + const counter = el.querySelector('[data-testid="queue-counter"]'); + expect(counter).not.toBeNull(); // The Transloco test module has no messages, so it echoes the key; the point - // is the counter lives in a dedicated tab element, not overlapping the body. - expect(tab?.classList.contains('queue-tab')).toBe(true); + // is the counter sits in the tray header (between prev/next), never over the card. + expect(el.querySelector('[data-testid="queue-card"]')?.contains(counter)).toBe(false); + expect(counter?.getAttribute('aria-live')).toBe('polite'); + }); + + it('browses with the arrow keys and minimizes on Escape', () => { + store.setQueue([suggestion({ id: 'a' }), suggestion({ id: 'b' })]); + const fixture = TestBed.createComponent(DecisionQueue); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const tray = el.querySelector('section.queue-card')!; + + tray.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(store.queueIndex()).toBe(1); + tray.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(store.queueIndex()).toBe(0); + + tray.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + expect(el.querySelector('[data-testid="queue-badge"]')).not.toBeNull(); + }); + + it('leaves arrow keys alone while a field is being edited', () => { + store.setQueue([suggestion({ id: 'a' }), suggestion({ id: 'b' })]); + const fixture = TestBed.createComponent(DecisionQueue); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const tray = el.querySelector('section.queue-card')!; + const field = document.createElement('textarea'); + tray.appendChild(field); + + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fixture.detectChanges(); + + expect(store.queueIndex()).toBe(0); + expect(el.querySelector('[data-testid="queue-badge"]')).toBeNull(); + }); + + it('docks inside the feed column instead of floating over the page', () => { + store.setQueue([suggestion()]); + const el = render(); + + const tray = el.querySelector('[data-testid="decision-queue"]'); + expect(tray?.classList.contains('absolute')).toBe(true); + expect(tray?.classList.contains('fixed')).toBe(false); + }); + + it('labels every pending card as an AI suggestion', () => { + store.setQueue([suggestion({ type: 'EDGE_CASE' })]); + const el = render(); + + const card = el.querySelector('[data-testid="suggestion-card"]'); + expect(card?.getAttribute('aria-label')).toContain('discovery.suggestion.aiLabel'); + expect(el.querySelector('[data-testid="suggestion-type"]')).not.toBeNull(); }); it('renders no decorative stack behind a single pending card', () => { @@ -250,7 +303,9 @@ describe('DecisionQueue', () => { swipe(handle, -300); fixture.detectChanges(); expect( - el.querySelector('[data-testid="queue-card"]')!.classList.contains('card-exiting'), + el + .querySelector('[data-testid="queue-card"]')! + .classList.contains('card-exiting'), ).toBe(true); // A second swipe mid-fling must be ignored — the index still hasn't advanced. diff --git a/src/app/features/discovery/components/decision-queue/decision-queue.ts b/src/app/features/discovery/components/decision-queue/decision-queue.ts index e23c592..275d8d2 100644 --- a/src/app/features/discovery/components/decision-queue/decision-queue.ts +++ b/src/app/features/discovery/components/decision-queue/decision-queue.ts @@ -1,12 +1,16 @@ import { ChangeDetectionStrategy, Component, + ElementRef, computed, effect, inject, input, + model, output, signal, + untracked, + viewChild, } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; import { provideIcons } from '@ng-icons/core'; @@ -44,11 +48,12 @@ const EXIT_DURATION_MS = 200; const DRAG_HORIZONTAL_LOCK_PX = 8; /** - * The decision queue: pending AI suggestions as floating, non-modal cards - * anchored top-center over the feed. One card at a time with prev/next arrows - * (or a horizontal drag) and an "n of m" counter. The user can minimize it to a - * compact badge that expands on click; new suggestions only bump the count and - * never change that state. While a decision is in flight the card stays visible + * The decision queue: pending AI suggestions as a non-modal review tray docked + * to the top of the feed column (never over the session controls or the side + * panel). A tray header names it ("AI suggestions to review") and carries the + * "n of m" counter with prev/next; a horizontal drag on the card's top handle + * also browses. The user can minimize it to a compact badge that expands on + * click; new suggestions only bump the count and never change that state. While a decision is in flight the card stays visible * with a spinner and disabled actions, and leaves only once the store confirms * the resolution. */ @@ -59,84 +64,115 @@ const DRAG_HORIZONTAL_LOCK_PX = 8; viewProviders: [ provideIcons({ lucideChevronLeft, lucideChevronRight, lucideMinus, lucideSparkles, lucideX }), ], + host: { class: 'contents' }, template: ` @if (store.queue().length > 0) { +
@if (collapsed()) { - -
- -
+ } + {{ 'discovery.queue.pendingBadgeLabel' | transloco }} + } @else { - -
- -
- + + +
+ +
+
- - @if (store.queue().length > 1) { - - - } - -
+
@for (layer of stackLayers(); track layer) { +
}
@@ -309,7 +319,10 @@ export class DecisionQueue { readonly decideDismiss = output(); readonly openTarget = output(); - protected readonly collapsed = signal(false); + /** Minimized to the badge. Two-way bound so the page can fold it (e.g. focus moved under it). */ + readonly collapsed = model(false); + + private readonly tray = viewChild>('tray'); protected readonly safeIndex = computed(() => { const length = this.store.queue().length; @@ -520,6 +533,36 @@ export class DecisionQueue { effect(() => { if (this.store.queue().length === 0) this.collapsed.set(false); }); + // A decided card leaves the DOM with the focused button in it, dropping focus to the + // page body. Bring it back to the tray so keyboard review can carry on. + let previousLength = 0; + effect(() => { + const length = this.store.queue().length; + const shrank = length < previousLength; + previousLength = length; + if (!shrank || length === 0 || untracked(() => this.collapsed())) return; + setTimeout(() => { + const active = document.activeElement; + if (!active || active === document.body) this.tray()?.nativeElement.focus(); + }); + }); + } + + /** ←/→ browse the deck and Esc minimizes — unless the key belongs to a field being edited. */ + protected onTrayKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLElement | null; + if (target?.closest('input, textarea, select, [contenteditable="true"], [role="listbox"]')) + return; + if (event.key === 'Escape') { + event.preventDefault(); + this.collapsed.set(true); + } else if (event.key === 'ArrowLeft' && this.safeIndex() > 0) { + event.preventDefault(); + this.prev(); + } else if (event.key === 'ArrowRight' && this.safeIndex() < this.store.queue().length - 1) { + event.preventDefault(); + this.next(); + } } /** Applies a committed drag outcome as a carousel navigation. */ diff --git a/src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts b/src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts new file mode 100644 index 0000000..b006234 --- /dev/null +++ b/src/app/features/discovery/components/gherkin-steps/gherkin-steps.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { AcceptanceCriterion } from '../../data/discovery.models'; + +/** + * Read-only Given/When/Then rendering of one acceptance criterion: the optional + * scenario as a heading, then one step per line behind a fixed keyword gutter, so + * a criterion reads like a Gherkin scenario instead of a run-on sentence. Shared + * by the suggestion card and the side panel's story cards. + */ +@Component({ + selector: 'app-gherkin-steps', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoPipe], + template: ` + @let c = criterion(); + @if (c.scenario) { +

+ {{ c.scenario }} +

+ } +
+ @for (step of steps; track step.key) { +
+ {{ step.label | transloco }} +
+
+ {{ c[step.key] }} +
+ } +
+ `, +}) +export class GherkinSteps { + readonly criterion = input.required(); + /** `sm` for dense lists (side panel), `md` for the suggestion card. */ + readonly size = input<'sm' | 'md'>('md'); + + protected readonly steps = [ + { key: 'given', label: 'discovery.suggestion.criteriaGiven' }, + { key: 'when', label: 'discovery.suggestion.criteriaWhen' }, + { key: 'then', label: 'discovery.suggestion.criteriaThen' }, + ] as const; +} 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 21395a0..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 @@ -1,9 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; import { TranslocoTestingModule } from '@jsverse/transloco'; +import { vi } from 'vitest'; import { SessionBar } from './session-bar'; import { SessionRecordingService } from '../../data/session-recording.service'; import { AudioRecorderService } from '../../../../core/audio/audio-recorder.service'; +import { AudioSource } from '../../../../core/audio/audio-source'; import { DiscoverySessionResponse, SessionStatus } from '../../data/discovery.models'; function session(status: SessionStatus): DiscoverySessionResponse { @@ -31,18 +33,23 @@ class FakeRecordingService { class FakeAudioRecorderService { readonly levels = signal([]); + readonly source = signal('mic'); + readonly meetingAudio = signal(false); + readonly shareMeetingAudio = vi.fn(() => Promise.resolve(true)); } describe('SessionBar', () => { let recording: FakeRecordingService; + let recorder: FakeAudioRecorderService; function render(): { fixture: ComponentFixture; el: HTMLElement } { recording = new FakeRecordingService(); + recorder = new FakeAudioRecorderService(); TestBed.configureTestingModule({ imports: [SessionBar, TranslocoTestingModule.forRoot({ langs: { en: {} } })], providers: [ { provide: SessionRecordingService, useValue: recording }, - { provide: AudioRecorderService, useValue: new FakeAudioRecorderService() }, + { provide: AudioRecorderService, useValue: recorder }, ], }); const fixture = TestBed.createComponent(SessionBar); @@ -62,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', () => { @@ -103,4 +118,39 @@ describe('SessionBar', () => { expect(el.querySelector('[data-testid="session-bar-resume"]')).not.toBeNull(); expect(el.querySelector('[data-testid="session-bar-pause"]')).toBeNull(); }); + + describe('virtual meeting source', () => { + it('shows no meeting controls for an in-person recording', () => { + const { el } = render(); + + expect(el.querySelector('[data-testid="session-bar-meeting-audio"]')).toBeNull(); + expect(el.querySelector('[data-testid="session-bar-reshare"]')).toBeNull(); + }); + + it('shows the meeting-audio badge while the meeting audio is captured', () => { + const { fixture, el } = render(); + recorder.source.set('meeting'); + recorder.meetingAudio.set(true); + fixture.detectChanges(); + + expect(el.querySelector('[data-testid="session-bar-meeting-audio"]')).not.toBeNull(); + expect(el.querySelector('[data-testid="session-bar-reshare"]')).toBeNull(); + }); + + it('offers to share the meeting audio again after "Stop sharing"', () => { + const { fixture, el } = render(); + recorder.source.set('meeting'); + recorder.meetingAudio.set(false); + fixture.detectChanges(); + + const reshare = el.querySelector('[data-testid="session-bar-reshare"]') as HTMLButtonElement; + expect(reshare).not.toBeNull(); + expect(reshare.getAttribute('aria-label')).toBeTruthy(); + expect(el.querySelector('[data-testid="session-bar-meeting-audio"]')).toBeNull(); + + reshare.click(); + + expect(recorder.shareMeetingAudio).toHaveBeenCalledTimes(1); + }); + }); }); 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 ff96a58..7896ada 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.ts @@ -1,7 +1,14 @@ import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; import { provideIcons } from '@ng-icons/core'; -import { lucideCircle, lucidePause, lucidePlay, lucideSquare } from '@ng-icons/lucide'; +import { + lucideCircle, + lucideMonitorSpeaker, + lucidePause, + lucidePlay, + lucideScreenShare, + lucideSquare, +} from '@ng-icons/lucide'; import { AudioRecorderService } from '../../../../core/audio/audio-recorder.service'; import { SessionRecordingService } from '../../data/session-recording.service'; import { HlmButton, HlmIcon } from '../../../../shared/ui'; @@ -21,13 +28,24 @@ export function formatElapsed(ms: number): string { * The persistent session bar shown while a recording is live: status pulse, * elapsed timer, a real input-level meter (AnalyserNode-driven) and * pause/resume/stop controls. State lives in {@link SessionRecordingService}, - * so the bar renders the truth from anywhere in the app. + * so the bar renders the truth from anywhere in the app. Virtual-meeting + * recordings show a meeting-audio badge, or a "share again" action once the + * user stops sharing (the recording carries on with the mic meanwhile). */ @Component({ selector: 'app-session-bar', changeDetection: ChangeDetectionStrategy.OnPush, imports: [HlmButton, HlmIcon, TranslocoPipe], - viewProviders: [provideIcons({ lucideCircle, lucidePause, lucidePlay, lucideSquare })], + viewProviders: [ + provideIcons({ + lucideCircle, + lucideMonitorSpeaker, + lucidePause, + lucidePlay, + lucideScreenShare, + lucideSquare, + }), + ], template: ` @if (recording.session(); as session) {
@if (recording.status() === 'RECORDING') { @@ -47,13 +67,17 @@ export function formatElapsed(ms: number): string { > } @else { - + } - -