diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index 25e7b905..299a2214 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -257,7 +257,7 @@ jobs: # Returns: 0 = completed (CONCLUSION set) · 1 = timed out · 2 = EVICTED (retryable) poll_run() { - local i started=0 strikes=0 any_job=0 exec_ticks=0 last="" phases active busy s + local i started=0 idle_since="" any_job=0 exec_ticks=0 last="" phases active busy s now echo "Phases: resolve -> baseline drift check -> sync (only if drifted) -> swap SPA -> e2e -> restore" # Two separate budgets. Queue time must NOT count against the execution timeout: with one # serialized runner and ~75-min suites, a PR queued behind one other already exceeds 2h, @@ -272,8 +272,11 @@ jobs: echo "[$(date -u +%H:%M:%SZ)] $phases"; last="$phases" fi if [ "$started" = "0" ]; then + # Count only jobs that have genuinely started executing. `!= "queued"` also + # matched transient pre-run states (waiting/pending/requested), which could + # latch started before the target runner ever picked up work. active=$(gh api "repos/$OPS/actions/runs/${RUN_ID}/jobs" \ - --jq '[.jobs[] | select(.status != "queued")] | length' 2>/dev/null || echo 0) + --jq '[.jobs[] | select(.status == "in_progress" or .status == "completed")] | length' 2>/dev/null || echo 0) if [ "${active:-0}" -gt 0 ]; then started=1; any_job=1 echo "[$(date -u +%H:%M:%SZ)] run started executing — 2h execution budget begins now" @@ -289,15 +292,23 @@ jobs: if [ "$started" = "0" ]; then # An offline runner never appears executing ANY job anywhere; a merely BUSY one does. # Elapsed time cannot tell them apart when one serialized runner handles ~74-min suites. - busy=$(for rid in $(gh run list -R "$OPS" --limit 10 --json databaseId --jq '.[].databaseId' 2>/dev/null); do + # Scan the 20 most recent runs (matches the env picker's window). With --limit 10, + # a run legitimately queued behind a long (~84-min) suite whose holder has already + # fallen out of the window reads busy=0 and gets killed with a false "offline". + busy=$(for rid in $(gh run list -R "$OPS" --limit 20 --json databaseId --jq '.[].databaseId' 2>/dev/null); do gh api "repos/$OPS/actions/runs/$rid/jobs" \ --jq '.jobs[] | select(.status=="in_progress") | .runner_name // empty' 2>/dev/null done | grep -cx "$RUNNER" || true) if [ "${busy:-0}" -gt 0 ]; then - strikes=0 + idle_since="" # runner is alive, we are simply queued behind work else - strikes=$((strikes + 1)) - if [ "$strikes" -ge 30 ]; then + # Gate on wall-clock, not iteration count: each poll makes ~20 extra `gh` calls + # (the busy probe above), so a fixed strike count drifts well past its intended + # minutes. Fire only after the runner has been continuously idle-while-queued + # for 15 real minutes. + now=$(date +%s) + [ -z "$idle_since" ] && idle_since="$now" + if [ "$((now - idle_since))" -ge 900 ]; then CONCLUSION=runner_unavailable echo "::error::${RUNNER} has been idle for 15 min while this run stayed queued — the runner is offline or not accepting jobs. https://github.com/$OPS/actions/runs/${RUN_ID}" return 1 @@ -450,7 +461,7 @@ jobs: # SUMMARY # ============================================================ summary: - needs: [build-app-image, unit-tests, e2e] + needs: [gate, build-app-image, unit-tests, e2e] if: always() runs-on: ubuntu-latest steps: @@ -471,7 +482,14 @@ jobs: # Only a real FAILURE should redden this check. `skipped` means the PR was never # labelled `run-tests`, which is the opt-in model working as designed — treating it # as failure would make every unlabelled PR unmergeable once this is a required check. + # build-app-image and gate are checked explicitly: when either fails, the jobs that + # depend on them (e2e needs build-app-image; everything needs gate) resolve to + # `skipped` rather than `failure`, so without these lines a broken image build — or a + # failed/cancelled gate — would let the required check go green with nothing run. + # `skipped` alone still stays green (the opt-in model: PR not labelled `run-tests`). if: | needs.e2e.result == 'failure' || needs.e2e.result == 'cancelled' || - needs.unit-tests.result == 'failure' || needs.unit-tests.result == 'cancelled' + needs.unit-tests.result == 'failure' || needs.unit-tests.result == 'cancelled' || + needs.build-app-image.result == 'failure' || needs.build-app-image.result == 'cancelled' || + needs.gate.result == 'failure' || needs.gate.result == 'cancelled' run: exit 1 diff --git a/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/__tests__/index.test.tsx b/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/__tests__/index.test.tsx index c5cc3b63..9e6a6571 100644 --- a/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/__tests__/index.test.tsx +++ b/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/__tests__/index.test.tsx @@ -858,7 +858,7 @@ describe('AppSidebar — rendering', () => { renderSidebar(); expect(screen.getByTestId('app-logo')).toBeInTheDocument(); // Each collapsible section trigger is a button whose accessible - // name matches the section title. Agents/Workflows/Chats/Projects/ + // name matches the section title. Agents/Workflows/Recents/Projects/ // Analytics + Documentation should all be present for an admin. expect( screen.getAllByRole('button', { name: 'Agents' }).length, @@ -867,7 +867,7 @@ describe('AppSidebar — rendering', () => { screen.getAllByRole('button', { name: 'Workflows' }).length, ).toBeGreaterThan(0); expect( - screen.getAllByRole('button', { name: 'Chats' }).length, + screen.getAllByRole('button', { name: 'Recents' }).length, ).toBeGreaterThan(0); expect( screen.getAllByRole('button', { name: 'Projects' }).length, @@ -926,19 +926,19 @@ describe('AppSidebar — rendering', () => { ).not.toBeInTheDocument(); }); - it('renders a minimal sidebar in embed mode for a LOGGED-IN user: New Chat + Chats, but no Agents/Workflows/Analytics/Projects', () => { + it('renders a minimal sidebar in embed mode for a LOGGED-IN user: New Chat + Recents, but no Agents/Workflows/Analytics/Projects', () => { // Default mock state is a logged-in ADMIN (mockUsername set) — without // embed gating they would see the full nav. Embed must hide it // regardless of role, while keeping New Chat + (logged-in) Chats. mockEmbedMode = true; renderSidebar(); - // Kept: New Chat (standalone button) + Chats history section. + // Kept: New Chat (standalone button) + Recents history section. expect( screen.getByRole('button', { name: 'New Chat' }), ).toBeInTheDocument(); expect( - screen.getAllByRole('button', { name: 'Chats' }).length, + screen.getAllByRole('button', { name: 'Recents' }).length, ).toBeGreaterThan(0); // Hidden: every admin/full-app nav section. @@ -959,8 +959,8 @@ describe('AppSidebar — rendering', () => { ).not.toBeInTheDocument(); }); - it('hides Chats in embed mode when the user is NOT logged in (only New Chat remains)', () => { - // Per the embed spec: in embed mode Chats is only shown to a logged-in + it('hides Recents in embed mode when the user is NOT logged in (only New Chat remains)', () => { + // Per the embed spec: in embed mode Recents is only shown to a logged-in // user (keyed on isLoggedIn()/axd_token). An anonymous embed viewer sees // just the New Chat button (anonymous bypasses the New Chat RBAC gate). mockEmbedMode = true; @@ -972,7 +972,7 @@ describe('AppSidebar — rendering', () => { screen.getByRole('button', { name: 'New Chat' }), ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: 'Chats' }), + screen.queryByRole('button', { name: 'Recents' }), ).not.toBeInTheDocument(); }); @@ -998,7 +998,7 @@ describe('AppSidebar — rendering', () => { ).toBeInTheDocument(); }); - it('hides Search chats when the user is NOT logged in (outside embed mode too)', () => { + it('hides Search when the user is NOT logged in (outside embed mode too)', () => { // The dialog lists the signed-in user's own recent messages, so unlike // `showChats` it is hidden for an anonymous user in EVERY mode — while // New Chat (which anonymous users may use) stays. @@ -1007,20 +1007,18 @@ describe('AppSidebar — rendering', () => { renderSidebar(); expect( - screen.queryByRole('button', { name: 'Search chats' }), + screen.queryByRole('button', { name: 'Search' }), ).not.toBeInTheDocument(); expect( screen.getByRole('button', { name: 'New Chat' }), ).toBeInTheDocument(); }); - it('shows Search chats for a logged-in user', () => { + it('shows Search for a logged-in user', () => { mockIsLoggedIn = true; renderSidebar(); - expect( - screen.getByRole('button', { name: 'Search chats' }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument(); }); }); @@ -1301,12 +1299,122 @@ describe('AppSidebar — Analytics section', () => { describe('AppSidebar — Chats section', () => { it('renders pinned and recent chat rows', () => { renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(screen.getByText('Pinned message one')).toBeInTheDocument(); expect(screen.getByText('Recent message one')).toBeInTheDocument(); expect(screen.getByText('Recent message two')).toBeInTheDocument(); }); + it('sorts pinned rows above recent ones without heading either group', () => { + renderSidebar(); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); + + // The two caps headings are gone; position and the pin carry it now. + expect(screen.queryByText('PINNED')).toBeNull(); + expect(screen.queryByText('RECENT')).toBeNull(); + + const pinnedList = screen.getByTestId('pinned-chats-list'); + const recentList = screen.getByTestId('recent-chats-list'); + expect( + pinnedList.compareDocumentPosition(recentList) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + /** The row container (label button + the trailing pin/menu slot). */ + const chatRowFor = (label: string) => + screen.getByText(label).closest('[data-testid="chat-row"]') as HTMLElement; + + it('marks a pinned row with a pin, and leaves recent rows unmarked', () => { + renderSidebar(); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); + + const pinnedRow = chatRowFor('Pinned message one'); + const recentRow = chatRowFor('Recent message one'); + + expect( + pinnedRow.querySelector('[data-testid="chat-row-pin"]'), + ).toBeInTheDocument(); + expect(recentRow.querySelector('[data-testid="chat-row-pin"]')).toBeNull(); + // A pin is a picture; screen readers get the word. + expect(pinnedRow).toHaveTextContent('Pinned'); + }); + + // Regression: these used the unnamed `group-hover`, and the shared Sidebar + // wrapper is a `.group` too - so a pointer anywhere in the sidebar revealed + // every row's menu at once (and would have hidden every pin). + it('scopes the hover swap to the row under the pointer', () => { + renderSidebar(); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); + + const pinnedRow = chatRowFor('Pinned message one'); + const pin = pinnedRow.querySelector( + '[data-testid="chat-row-pin"]', + ) as HTMLElement; + const menuButton = pinnedRow.querySelector( + 'button[aria-label="Chat actions"]', + ) as HTMLElement; + + // Both live in the same slot and trade places on hover. jsdom has no + // hover, so the handover is asserted as the classes that perform it. + expect(pin).toHaveClass('group-hover/chat-row:opacity-0'); + expect(menuButton).toHaveClass('opacity-0'); + expect(menuButton).toHaveClass('group-hover/chat-row:opacity-100'); + }); + + it('hides the pin while the row menu is open', async () => { + // Radix DropdownMenu fires on pointerdown, so this needs the full + // pointer sequence rather than `fireEvent.click`. + const user = userEvent.setup(); + renderSidebar(); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); + + const pinnedRow = chatRowFor('Pinned message one'); + expect( + pinnedRow.querySelector('[data-testid="chat-row-pin"]'), + ).toBeInTheDocument(); + + await user.click( + pinnedRow.querySelector('button[aria-label="Chat actions"]')!, + ); + + // The pointer may be anywhere by the time the menu is up, so hover alone + // cannot keep the pin from showing through it. + await waitFor(() => + expect( + pinnedRow.querySelector('[data-testid="chat-row-pin"]'), + ).toBeNull(), + ); + }); + + it('lines the row slot up with the section chevrons above it', () => { + renderSidebar(); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); + + // Flush with the row's own right edge, so the 24px slot centres on the + // same axis as the chevron on the Recents trigger. jsdom cannot measure + // it, so this asserts the rule that produces the alignment. + const slot = chatRowFor('Pinned message one').querySelector( + '.absolute', + ) as HTMLElement; + expect(slot).toHaveClass('right-0'); + expect(slot.className).not.toContain('right-1.5'); + }); + + it('leaves an unpinned row unmarked until it is hovered', () => { + renderSidebar(); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); + + const recentRow = chatRowFor('Recent message one'); + const menuButton = recentRow.querySelector( + 'button[aria-label="Chat actions"]', + ) as HTMLElement; + + expect(recentRow.querySelector('[data-testid="chat-row-pin"]')).toBeNull(); + expect(menuButton).toHaveClass('opacity-0'); + expect(menuButton).toHaveClass('group-hover/chat-row:opacity-100'); + }); + it('does not double-list a pinned session in Recent (dedup)', () => { // Same session appears in both pinned + recent pages: it should only // render once (under Pinned), thanks to the `pinnedSessionIds` Set. @@ -1335,7 +1443,7 @@ describe('AppSidebar — Chats section', () => { ], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(screen.getAllByText('Pinned message one')).toHaveLength(1); expect(screen.getByText('Recent only row')).toBeInTheDocument(); }); @@ -1348,7 +1456,7 @@ describe('AppSidebar — Chats section', () => { it('clicking a recent row selects the session without navigating when already on the chat page', () => { mockActiveSessionId = 'sess-recent-1'; // a DIFFERENT row will be clicked renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); fireEvent.click(screen.getByText('Recent message two').closest('button')!); // Already on this mentor's chat page → no navigation. Pushing the URL here @@ -1369,7 +1477,7 @@ describe('AppSidebar — Chats section', () => { mockPathname = '/platform/tenant-a/mentor-1/analytics'; mockActiveSessionId = 'sess-recent-1'; // a DIFFERENT row will be clicked renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); fireEvent.click(screen.getByText('Recent message two').closest('button')!); // Off the chat page → navigate to it. The session travels via state, not @@ -1384,7 +1492,7 @@ describe('AppSidebar — Chats section', () => { it('merges the selected session into any existing cached session ids', () => { mockCachedSessionId = { 'other-mentor': 'keep-me' }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); fireEvent.click(screen.getByText('Recent message one').closest('button')!); expect(saveCachedSessionIdMock).toHaveBeenCalledWith({ 'other-mentor': 'keep-me', @@ -1394,7 +1502,7 @@ describe('AppSidebar — Chats section', () => { it('clicking a pinned row also selects the session', () => { renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); fireEvent.click(screen.getByText('Pinned message one').closest('button')!); // On the chat page → no navigation, but the session is still selected. expect(pushMock).not.toHaveBeenCalled(); @@ -1406,7 +1514,7 @@ describe('AppSidebar — Chats section', () => { it('clicking the already-active chat on the chat page is a complete no-op', () => { mockActiveSessionId = 'sess-recent-1'; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); fireEvent.click(screen.getByText('Recent message one').closest('button')!); // Already-active AND already on the chat page → nothing happens: no @@ -1423,7 +1531,7 @@ describe('AppSidebar — Chats section', () => { // it. userEvent dispatches the full pointer sequence so the menu opens. const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); expect( @@ -1442,7 +1550,7 @@ describe('AppSidebar — Chats section', () => { mockTenantMetadata = { enable_chat_history_export: false }; const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); expect( @@ -1461,7 +1569,7 @@ describe('AppSidebar — Chats section', () => { mockTenantMetadata = {}; const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); expect( @@ -1474,7 +1582,7 @@ describe('AppSidebar — Chats section', () => { mockTenantMetadata = { enable_chat_history_export: false }; const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); expect( @@ -1485,7 +1593,7 @@ describe('AppSidebar — Chats section', () => { it("shows Unpin (not Pin) for a pinned row's menu", async () => { const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[0]); expect( @@ -1496,7 +1604,7 @@ describe('AppSidebar — Chats section', () => { it('clicking Pin on a recent row calls the pin mutation with the session id', async () => { const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); await user.click(await screen.findByRole('menuitem', { name: /^Pin$/ })); @@ -1508,7 +1616,7 @@ describe('AppSidebar — Chats section', () => { it('clicking Unpin on a pinned row calls the unpin mutation', async () => { const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[0]); await user.click(await screen.findByRole('menuitem', { name: /^Unpin$/ })); @@ -1520,7 +1628,7 @@ describe('AppSidebar — Chats section', () => { it('clicking Export delegates to exportMessagesToXlsx with the row messages', async () => { const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); await user.click(await screen.findByRole('menuitem', { name: /^Export$/ })); @@ -1534,7 +1642,7 @@ describe('AppSidebar — Chats section', () => { it('clicking Delete triggers the delete mutation', async () => { const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); await user.click(await screen.findByRole('menuitem', { name: /^Delete$/ })); @@ -1547,7 +1655,7 @@ describe('AppSidebar — Chats section', () => { mockPinnedPages = { results: [] }; mockRecentPages = { results: [] }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); // Empty state copy can vary; assert no chat rows render. expect( screen.queryByRole('button', { name: 'Chat actions' }), @@ -1852,7 +1960,7 @@ describe('AppSidebar — startNewChat behavior', () => { mockPathname = '/platform/tenant-a/mentor-1'; renderSidebar(); // The chats section's New Chat button triggers startNewChat. - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const newChat = screen.queryByRole('button', { name: /new chat/i }); if (newChat) { fireEvent.click(newChat); @@ -1863,7 +1971,7 @@ describe('AppSidebar — startNewChat behavior', () => { it('navigates home when not on the chat page', () => { mockPathname = '/platform/tenant-a/mentor-1/analytics'; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const newChat = screen.queryByRole('button', { name: /new chat/i }); if (newChat) { fireEvent.click(newChat); @@ -1874,7 +1982,7 @@ describe('AppSidebar — startNewChat behavior', () => { it('opens the no-mentor modal when there is no mentor in context', () => { mockParams = { tenantKey: 'tenant-a', mentorId: undefined }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const newChat = screen.queryByRole('button', { name: /new chat/i }); if (newChat) { fireEvent.click(newChat); @@ -2110,7 +2218,7 @@ describe('AppSidebar — Rail-collapsed mode', () => { screen.getAllByRole('button', { name: 'Agents' }).length, ).toBeGreaterThan(0); expect( - screen.getAllByRole('button', { name: 'Chats' }).length, + screen.getAllByRole('button', { name: 'Recents' }).length, ).toBeGreaterThan(0); expect( screen.getAllByRole('button', { name: 'Projects' }).length, @@ -2146,7 +2254,7 @@ describe('AppSidebar — Rail-collapsed mode', () => { it('clicking the rail Chats icon expands the sidebar', () => { renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(toggleSidebarMock).toHaveBeenCalled(); }); @@ -2275,7 +2383,7 @@ describe('AppSidebar — Chat mutation error paths', () => { })); const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); await user.click(await screen.findByRole('menuitem', { name: /^Pin$/ })); @@ -2297,7 +2405,7 @@ describe('AppSidebar — Chat mutation error paths', () => { })); const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[1]); await user.click(await screen.findByRole('menuitem', { name: /^Delete$/ })); @@ -2319,7 +2427,7 @@ describe('AppSidebar — Chat mutation error paths', () => { })); const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[0]); // pinned row await user.click(await screen.findByRole('menuitem', { name: /^Unpin$/ })); @@ -2354,7 +2462,7 @@ describe('AppSidebar — chat row label fallbacks', () => { ], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); // Our `getCurrentArtifactTitle` mock returns 'Artifact title' — so the // row should render that as its label. expect(screen.getByText('Artifact title')).toBeInTheDocument(); @@ -2378,7 +2486,7 @@ describe('AppSidebar — chat row label fallbacks', () => { ], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(screen.getByText('My titled chat')).toBeInTheDocument(); // The first-human-message text must NOT be used as the label. expect(screen.queryByText('Recent message one')).not.toBeInTheDocument(); @@ -2402,7 +2510,7 @@ describe('AppSidebar — chat row label fallbacks', () => { ], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(screen.getByText('Fallback message text')).toBeInTheDocument(); }); }); @@ -2456,18 +2564,18 @@ describe('AppSidebar — recent chats infinite query', () => { pageParams: [1, 2], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(screen.getByText('Page one row')).toBeInTheDocument(); expect(screen.getByText('Page two row')).toBeInTheDocument(); }); - it('opens the search dialog from the Search chats button', () => { + it('opens the search dialog from the Search button', () => { renderSidebar(); // The search input lives in the dialog, not the sidebar, until opened. expect( screen.queryByPlaceholderText('Search chats'), ).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Search chats' })); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); expect(screen.getByPlaceholderText('Search chats')).toBeInTheDocument(); }); @@ -2475,7 +2583,7 @@ describe('AppSidebar — recent chats infinite query', () => { vi.useFakeTimers(); try { renderSidebar(); - fireEvent.click(screen.getByRole('button', { name: 'Search chats' })); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); const input = screen.getByPlaceholderText('Search chats'); fireEvent.change(input, { target: { value: 'invoice' } }); // Before the debounce window elapses the arg is still empty. @@ -2509,7 +2617,7 @@ describe('AppSidebar — recent chats infinite query', () => { try { mockHasNextPage = true; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(ioCallback).not.toBeNull(); act(() => { ioCallback?.([{ isIntersecting: true }]); @@ -2535,7 +2643,7 @@ describe('AppSidebar — recent chats infinite query', () => { mockHasNextPage = true; mockIsFetchingNextPage = true; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); act(() => { ioCallback?.([{ isIntersecting: true }]); }); @@ -2559,7 +2667,7 @@ describe('AppSidebar — recent chats infinite query', () => { try { mockHasNextPage = false; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); act(() => { ioCallback?.([{ isIntersecting: true }]); }); @@ -2659,7 +2767,7 @@ describe('AppSidebar — Active-session deletion safety', () => { }; const user = userEvent.setup(); renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const menus = screen.getAllByRole('button', { name: 'Chat actions' }); await user.click(menus[0]); // the single active row await user.click(await screen.findByRole('menuitem', { name: /^Delete$/ })); @@ -2682,7 +2790,7 @@ describe('AppSidebar — Chat handler skip-path guards', () => { mockUsername = null; mockUserName = ''; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect( screen.queryByRole('button', { name: 'Chat actions' }), ).not.toBeInTheDocument(); @@ -2794,7 +2902,7 @@ describe('AppSidebar — Chat row label navigation', () => { }; mockPinnedPages = { results: [] }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const row = screen.getByText('Navigable row').closest('button'); expect(row).not.toBeNull(); fireEvent.click(row!); @@ -2829,7 +2937,7 @@ describe('AppSidebar — Chat row label navigation', () => { }; mockPinnedPages = { results: [] }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const row = screen.getByText('Project row').closest('button'); expect(row).not.toBeNull(); fireEvent.click(row!); @@ -2887,7 +2995,7 @@ describe('AppSidebar — Rail-collapsed chats flyout', () => { const user = userEvent.setup(); renderSidebar(); // Hover the chats rail icon to open the HoverCard flyout. - const chatsIcons = screen.getAllByRole('button', { name: 'Chats' }); + const chatsIcons = screen.getAllByRole('button', { name: 'Recents' }); await user.hover(chatsIcons[0]); expect(await screen.findByText('Flyout pinned')).toBeInTheDocument(); expect(screen.getByText('Flyout recent')).toBeInTheDocument(); @@ -2933,7 +3041,7 @@ describe('AppSidebar — Rail-collapsed chats flyout click', () => { mockRecentPages = { results: [] }; const user = userEvent.setup(); renderSidebar(); - await user.hover(screen.getAllByRole('button', { name: 'Chats' })[0]); + await user.hover(screen.getAllByRole('button', { name: 'Recents' })[0]); const row = await screen.findByText('Flyout pin row'); fireEvent.click(row.closest('button')!); // On the chat page the flyout row selects the session without navigating. @@ -2961,7 +3069,7 @@ describe('AppSidebar — Rail-collapsed chats flyout click', () => { }; const user = userEvent.setup(); renderSidebar(); - await user.hover(screen.getAllByRole('button', { name: 'Chats' })[0]); + await user.hover(screen.getAllByRole('button', { name: 'Recents' })[0]); const row = await screen.findByText('Flyout recent row'); fireEvent.click(row.closest('button')!); // On the chat page the flyout row selects the session without navigating. @@ -2995,7 +3103,7 @@ describe('AppSidebar — Chat row without href is inert on click', () => { ], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); const row = screen.getByText('No href row').closest('button'); expect(row).not.toBeNull(); fireEvent.click(row!); @@ -3132,7 +3240,7 @@ describe('AppSidebar — Per-mentor row filtering', () => { ], }; renderSidebar(); - fireEvent.click(screen.getAllByRole('button', { name: 'Chats' })[0]); + fireEvent.click(screen.getAllByRole('button', { name: 'Recents' })[0]); expect(screen.queryByText('Other mentor chat')).not.toBeInTheDocument(); expect(screen.getByText('Current mentor chat')).toBeInTheDocument(); }); diff --git a/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/chats/chat-row.tsx b/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/chats/chat-row.tsx index 13890310..74496dd0 100644 --- a/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/chats/chat-row.tsx +++ b/app/platform/[tenantKey]/[mentorId]/_components/app-sidebar/chats/chat-row.tsx @@ -1,5 +1,6 @@ 'use client'; +import * as React from 'react'; import { useTranslations } from 'next-intl'; import { Download, @@ -24,6 +25,8 @@ function ChatThreeDotMenu({ isPinned, isLoading, canExport = true, + open, + onOpenChange, onPinToggle, onExport, onDelete, @@ -31,13 +34,15 @@ function ChatThreeDotMenu({ isPinned: boolean; isLoading: boolean; canExport?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; onPinToggle: () => void; onExport: () => void; onDelete: () => void; }) { const t = useTranslations('appSidebarIndex'); return ( - + -
+ {/* One slot, two occupants. A pinned row wears its pin until you + reach for the row, at which point the pin steps aside for the menu + that can unpin it. An unpinned row's slot stays empty until then, + so a quiet list stays quiet. */} + {/* `right-0`, not an inset: flush with the row's own edge, the 24px + slot centres on the same axis as the chevron on the Recents trigger + above it — the icons down the right-hand side line up. */} +
+ {showPin && ( + + + + )}
- {pinned.length > 0 && ( - <> -

- {t('pinned')} -

- {pinned.map((row) => ( - - ))} - - )} -

0 && 'pt-2', - )} - > - {t('recent')} -

+ {pinned.map((row) => ( + + ))} + {/* Pinned first, then recent, and no headings over either: the + panel is already called Recents, and a pin on the row says + more than a caps label over a group does. */} {recent.length > 0 ? ( recent.map((row) => ( - - )} - {agentsMenu.items.length > 0 && ( )} - {showChats && ( - <> - + {/* Search belongs with the chats it searches, so it sits with + them below the divider rather than up with New Chat. */} + {(showSearchChats || showChats) && } - expandFromRail('chats')} - tenantKey={tenantKey} - mentorId={mentorId} - username={username} - onAfterNav={onAfterNav} - /> - + {showSearchChats && ( + + + + )} + + {showChats && ( + expandFromRail('chats')} + tenantKey={tenantKey} + mentorId={mentorId} + username={username} + onAfterNav={onAfterNav} + /> )} {projectsAllowed && ( @@ -1255,23 +1259,6 @@ export function AppSidebar() {
)} - {showSearchChats && ( -
- -
- )} - {agentsMenu.items.length > 0 && ( )} - {showChats && ( - <> - - - } + + {showSearchChats && ( + + )} + + {showChats && ( + )} {projectsAllowed && ( diff --git a/components/__tests__/live-kit-voice-chat.test.tsx b/components/__tests__/live-kit-voice-chat.test.tsx index 449ce58c..d329c894 100644 --- a/components/__tests__/live-kit-voice-chat.test.tsx +++ b/components/__tests__/live-kit-voice-chat.test.tsx @@ -36,6 +36,7 @@ const mockAudioTrackPublications = new Map([ ]); const mockLocalParticipant = { + identity: 'testuser', setMicrophoneEnabled: mockSetMicrophoneEnabled, audioTrackPublications: mockAudioTrackPublications, on: vi.fn((event: string, handler: (...args: any[]) => void) => { @@ -44,12 +45,41 @@ const mockLocalParticipant = { off: vi.fn(), }; +// A remote (mentor) participant so the diagnostic dumps that iterate +// `room.remoteParticipants` are exercised. +const makeRemoteParticipants = () => + new Map([ + [ + 'p1', + { + identity: 'mentor-agent', + sid: 'p1', + isSpeaking: false, + audioLevel: 0, + isLocal: false, + trackPublications: new Map([ + [ + 't1', + { + trackSid: 't1', + source: 'microphone', + kind: 'audio', + isMuted: false, + isSubscribed: true, + isEnabled: true, + }, + ], + ]), + }, + ], + ]); + vi.mock('livekit-client', () => ({ Room: vi.fn(() => ({ connect: mockRoomConnect, disconnect: mockRoomDisconnect, localParticipant: mockLocalParticipant, - remoteParticipants: new Map(), + remoteParticipants: makeRemoteParticipants(), name: 'test-room', state: 'disconnected', on: vi.fn((event: string, handler: (...args: any[]) => void) => { @@ -72,6 +102,7 @@ vi.mock('livekit-client', () => ({ ActiveSpeakersChanged: 'activeSpeakersChanged', MediaDevicesError: 'mediaDevicesError', SignalConnected: 'signalConnected', + TranscriptionReceived: 'transcriptionReceived', }, ConnectionState: { Connected: 'connected', @@ -173,13 +204,24 @@ describe('LiveKitChat', () => { expect.objectContaining({ isOpen: true, onClose: defaultProps.onClose, - toggleMute: expect.any(Function), - isMuted: expect.any(Boolean), + toggleMicMute: expect.any(Function), + isMicMuted: expect.any(Boolean), + toggleMentorAudio: expect.any(Function), + isMentorAudioMuted: expect.any(Boolean), connectionState: expect.any(String), isSpeaking: false, + isMentorSpeaking: false, }), ); }); + + it('should start with mentor audio unmuted', async () => { + const { getByTestId } = render(); + expect(getByTestId('room-audio-renderer')).toHaveAttribute( + 'data-muted', + 'false', + ); + }); }); describe('successful connection flow', () => { @@ -224,14 +266,14 @@ describe('LiveKitChat', () => { }); }); - it('should pass isMuted=false to modal after successful connection', async () => { + it('should pass isMicMuted=false to modal after successful connection', async () => { render(); await vi.waitFor(() => { const lastCall = mockVoiceChatModal.mock.calls[ mockVoiceChatModal.mock.calls.length - 1 ][0]; - expect(lastCall.isMuted).toBe(false); + expect(lastCall.isMicMuted).toBe(false); }); }); @@ -452,36 +494,133 @@ describe('LiveKitChat', () => { }); }); - describe('mute toggle', () => { - it('should toggle mute state when toggleMute is called', async () => { - render(); + describe('mute toggles', () => { + const latestModalProps = () => + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + const renderConnected = async () => { + const utils = render(); await vi.waitFor(() => { - const lastCall = - mockVoiceChatModal.mock.calls[ - mockVoiceChatModal.mock.calls.length - 1 - ][0]; - expect(lastCall.isMuted).toBe(false); + expect(latestModalProps().isMicMuted).toBe(false); }); + return utils; + }; + + it('should toggle mic mute state when toggleMicMute is called', async () => { + await renderConnected(); - // Get the toggleMute function and call it - const lastCall = - mockVoiceChatModal.mock.calls[ - mockVoiceChatModal.mock.calls.length - 1 - ][0]; act(() => { - (lastCall.toggleMute as () => void)(); + (latestModalProps().toggleMicMute as () => void)(); }); await vi.waitFor(() => { - const updatedCall = - mockVoiceChatModal.mock.calls[ - mockVoiceChatModal.mock.calls.length - 1 - ][0]; - expect(updatedCall.isMuted).toBe(true); + expect(latestModalProps().isMicMuted).toBe(true); }); expect(mockSetMicrophoneEnabled).toHaveBeenCalledWith(false); }); + + it('should re-enable the microphone when toggled back on', async () => { + await renderConnected(); + + act(() => { + (latestModalProps().toggleMicMute as () => void)(); + }); + await vi.waitFor(() => { + expect(latestModalProps().isMicMuted).toBe(true); + }); + + act(() => { + (latestModalProps().toggleMicMute as () => void)(); + }); + await vi.waitFor(() => { + expect(latestModalProps().isMicMuted).toBe(false); + }); + expect(mockSetMicrophoneEnabled).toHaveBeenLastCalledWith(true); + }); + + // ── Regression test for the bug this change fixes ── + // A single `isMuted` flag used to drive BOTH `setMicrophoneEnabled` and + // ``, so muting your own mic silently silenced the + // mentor too. Muting the mic must leave mentor audio playing. + it('should NOT mute mentor audio when the microphone is muted', async () => { + const { getByTestId } = await renderConnected(); + + expect(getByTestId('room-audio-renderer')).toHaveAttribute( + 'data-muted', + 'false', + ); + + act(() => { + (latestModalProps().toggleMicMute as () => void)(); + }); + + await vi.waitFor(() => { + expect(latestModalProps().isMicMuted).toBe(true); + }); + + // The whole point: mentor audio playback is untouched. + expect(getByTestId('room-audio-renderer')).toHaveAttribute( + 'data-muted', + 'false', + ); + expect(latestModalProps().isMentorAudioMuted).toBe(false); + }); + + it('should mute mentor audio playback when toggleMentorAudio is called', async () => { + const { getByTestId } = await renderConnected(); + + act(() => { + (latestModalProps().toggleMentorAudio as () => void)(); + }); + + await vi.waitFor(() => { + expect(latestModalProps().isMentorAudioMuted).toBe(true); + }); + expect(getByTestId('room-audio-renderer')).toHaveAttribute( + 'data-muted', + 'true', + ); + }); + + it('should NOT touch the microphone when mentor audio is toggled', async () => { + await renderConnected(); + + mockSetMicrophoneEnabled.mockClear(); + + act(() => { + (latestModalProps().toggleMentorAudio as () => void)(); + }); + + await vi.waitFor(() => { + expect(latestModalProps().isMentorAudioMuted).toBe(true); + }); + expect(mockSetMicrophoneEnabled).not.toHaveBeenCalled(); + expect(latestModalProps().isMicMuted).toBe(false); + }); + + it('should unmute mentor audio when toggled back on', async () => { + const { getByTestId } = await renderConnected(); + + act(() => { + (latestModalProps().toggleMentorAudio as () => void)(); + }); + await vi.waitFor(() => { + expect(latestModalProps().isMentorAudioMuted).toBe(true); + }); + + act(() => { + (latestModalProps().toggleMentorAudio as () => void)(); + }); + await vi.waitFor(() => { + expect(latestModalProps().isMentorAudioMuted).toBe(false); + }); + expect(getByTestId('room-audio-renderer')).toHaveAttribute( + 'data-muted', + 'false', + ); + }); }); describe('cleanup on unmount', () => { @@ -717,6 +856,116 @@ describe('LiveKitChat', () => { }); }); + it('should keep isSpeaking false while the mic is muted', async () => { + render(); + + await vi.waitFor(() => { + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.connectionState).toBe('connected'); + }); + + const beforeMute = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + act(() => { + (beforeMute.toggleMicMute as () => void)(); + }); + + await vi.waitFor(() => { + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.isMicMuted).toBe(true); + }); + + act(() => { + participantEventHandlers['isSpeakingChanged']?.(true); + }); + + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.isSpeaking).toBe(false); + }); + + it('should set isMentorSpeaking when a remote participant speaks', async () => { + render(); + await vi.waitFor(() => { + expect(mockRoomConnect).toHaveBeenCalled(); + }); + + act(() => { + roomEventHandlers['activeSpeakersChanged']?.([ + { identity: 'mentor-agent', sid: 's1', isLocal: false }, + ]); + }); + + await vi.waitFor(() => { + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.isMentorSpeaking).toBe(true); + }); + }); + + it('should not set isMentorSpeaking when only the local participant speaks', async () => { + render(); + await vi.waitFor(() => { + expect(mockRoomConnect).toHaveBeenCalled(); + }); + + act(() => { + roomEventHandlers['activeSpeakersChanged']?.([ + { identity: 'testuser', sid: 's0', isLocal: true }, + ]); + }); + + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.isMentorSpeaking).toBe(false); + }); + + it('should clear isMentorSpeaking when the active speaker list empties', async () => { + render(); + await vi.waitFor(() => { + expect(mockRoomConnect).toHaveBeenCalled(); + }); + + act(() => { + roomEventHandlers['activeSpeakersChanged']?.([ + { identity: 'mentor-agent', sid: 's1', isLocal: false }, + ]); + }); + await vi.waitFor(() => { + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.isMentorSpeaking).toBe(true); + }); + + act(() => { + roomEventHandlers['activeSpeakersChanged']?.([]); + }); + + await vi.waitFor(() => { + const lastCall = + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + expect(lastCall.isMentorSpeaking).toBe(false); + }); + }); + it('should set isSpeaking to false on trackMuted event', async () => { render(); @@ -1002,4 +1251,97 @@ describe('LiveKitChat', () => { }); }); }); + + describe('live transcription', () => { + const lastProps = (): any => + mockVoiceChatModal.mock.calls[ + mockVoiceChatModal.mock.calls.length - 1 + ][0]; + + const emitTranscription = ( + segments: Record[], + participant?: Record, + ) => { + act(() => { + roomEventHandlers['transcriptionReceived']?.(segments, participant); + }); + }; + + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + it('subscribes to transcription events for the call', async () => { + render(); + + await vi.waitFor(() => { + expect(roomEventHandlers['transcriptionReceived']).toBeTypeOf( + 'function', + ); + }); + }); + + it('starts with an empty transcript', () => { + render(); + + expect(lastProps().transcript).toEqual([]); + }); + + it('forwards the mentor name for transcript labelling', () => { + render(); + + expect(lastProps().mentorName).toBe('Ada'); + }); + + it('accumulates transcript entries and attributes the speaker', async () => { + render(); + await vi.waitFor(() => { + expect(roomEventHandlers['transcriptionReceived']).toBeDefined(); + }); + + emitTranscription([{ id: 's1', text: 'Hello', final: true }], { + identity: 'mentor-agent', + name: 'Ada', + }); + emitTranscription([{ id: 's2', text: 'Hi back', final: true }], { + identity: 'testuser', + }); + + expect( + lastProps().transcript.map((e: { speaker: string; text: string }) => [ + e.speaker, + e.text, + ]), + ).toEqual([ + ['agent', 'Hello'], + ['user', 'Hi back'], + ]); + }); + + it('grows a partial line in place until it finalises', async () => { + render(); + await vi.waitFor(() => { + expect(roomEventHandlers['transcriptionReceived']).toBeDefined(); + }); + + emitTranscription([{ id: 's1', text: 'Hel', final: false }], { + identity: 'mentor-agent', + }); + expect(lastProps().transcript).toHaveLength(1); + expect(lastProps().transcript[0].isFinal).toBe(false); + + emitTranscription([{ id: 's1', text: 'Hello', final: true }], { + identity: 'mentor-agent', + }); + expect(lastProps().transcript).toHaveLength(1); + expect(lastProps().transcript[0].isFinal).toBe(true); + expect(lastProps().transcript[0].text).toBe('Hello'); + }); + }); }); diff --git a/components/chat/__tests__/index.test.tsx b/components/chat/__tests__/index.test.tsx index c41c716f..4290c238 100644 --- a/components/chat/__tests__/index.test.tsx +++ b/components/chat/__tests__/index.test.tsx @@ -3293,6 +3293,7 @@ describe('Chat', () => { enableSafetyDisclaimer: false, isPending: false, isLoadingChats: false, + refetchChats: vi.fn(), }); renderWithRedux(); @@ -3312,6 +3313,46 @@ describe('Chat', () => { }); }); + it('should refetch chats when the voice call modal is closed', async () => { + const mockRefetchChats = vi.fn(); + const { useAdvancedChat } = await import('@iblai/iblai-js/web-utils'); + (useAdvancedChat as any).mockReturnValue({ + changeTab: vi.fn(), + activeTab: 'chat', + currentStreamingMessage: null, + enabledGuidedPrompts: [], + isStreaming: false, + mentorName: 'Test Mentor', + messages: [], + profileImage: '/avatar.png', + sendMessage: vi.fn(), + setMessage: vi.fn(), + stopGenerating: vi.fn(), + uniqueMentorId: 'unique-mentor-123', + sessionId: 'session-123', + startNewChat: vi.fn(), + enableSafetyDisclaimer: false, + isPending: false, + isLoadingChats: false, + refetchChats: mockRefetchChats, + }); + + renderWithRedux(); + + fireEvent.click(screen.getByTestId('phone-call-btn')); + + await waitFor(() => { + expect(screen.getByTestId('live-kit-chat')).toBeInTheDocument(); + }); + + mockRefetchChats.mockClear(); + fireEvent.click(screen.getByText('Close')); + + await waitFor(() => { + expect(mockRefetchChats).toHaveBeenCalled(); + }); + }); + it('should close screen sharing modal via close button', async () => { const { useAdvancedChat } = await import('@iblai/iblai-js/web-utils'); (useAdvancedChat as any).mockReturnValue({ diff --git a/components/chat/index.tsx b/components/chat/index.tsx index 6c78043b..568b0a05 100644 --- a/components/chat/index.tsx +++ b/components/chat/index.tsx @@ -2189,12 +2189,20 @@ export function Chat({ mentorUniqueId={uniqueMentorId} sessionId={cachedSessionId?.[mentorId] ?? sessionId} username={username ?? ''} + // Labels the agent's transcript lines with the mentor's real name + // and face, so call captions match the chat thread behind them. + mentorName={mentorName} + mentorImage={profileImage} isOpen={isPhoneCallModalOpen} onClose={() => { if (window.opener) { window.close(); } else { setIsPhoneCallModalOpen(false); + // The realtime voice conversation is persisted against the same + // session_id used by the chat thread, so pull it in on teardown — + // this mirrors the screen-sharing handler below. + refetchChats(); } }} /> diff --git a/components/live-kit-voice-chat.tsx b/components/live-kit-voice-chat.tsx index e80de0d0..ca74b6d4 100644 --- a/components/live-kit-voice-chat.tsx +++ b/components/live-kit-voice-chat.tsx @@ -9,6 +9,8 @@ import { import { useCreateCallCredentialsMutation } from '@iblai/iblai-js/data-layer'; import { RoomAudioRenderer, RoomContext } from '@livekit/components-react'; +import { useLiveKitTranscription } from '@/hooks/use-livekit-transcription'; + import { VoiceChatModal } from './modals/voice-chat-modal'; const VOICE_DEBUG_PREFIX = '[VoiceChat:LiveKit]'; @@ -32,6 +34,10 @@ type Props = { username: string; onClose: () => void; isOpen: boolean; + /** Display name of the mentor, used to label its transcript lines. */ + mentorName?: string; + /** Mentor avatar, so call captions look like the chat they belong to. */ + mentorImage?: string; }; type ConnectionState = @@ -69,6 +75,8 @@ export function LiveKitChat({ username, onClose, isOpen, + mentorName, + mentorImage, }: Props) { const [initiateCall] = useCreateCallCredentialsMutation(); @@ -76,13 +84,26 @@ export function LiveKitChat({ voiceLog('Creating new Room instance'); return new Room({}); }); - const [isMuted, setIsMuted] = React.useState(true); + // Outbound audio: whether the user's own microphone is muted. + const [isMicMuted, setIsMicMuted] = React.useState(true); + // Inbound audio: whether the mentor's voice playback is muted. These are two + // unrelated concerns and must never share a single flag — muting your mic + // used to silence the mentor as a side effect. + const [isMentorAudioMuted, setIsMentorAudioMuted] = React.useState(false); const [connectionState, setConnectionState] = React.useState( 'requesting-permission', ); const [isSpeaking, setIsSpeaking] = React.useState(false); + const [isMentorSpeaking, setIsMentorSpeaking] = React.useState(false); const permissionStreamRef = React.useRef(null); + // Transcription runs for the whole call whether or not captions are showing: + // the modal's CC toggle only decides what is drawn, and the accumulated + // entries are what the post-call history is built from. + const { entries: transcript } = useLiveKitTranscription({ + room, + }); + function stopPermissionStream() { voiceLog('Stopping permission stream', { hasTracks: !!permissionStreamRef.current, @@ -226,7 +247,7 @@ export function LiveKitChat({ kind: pub.kind, })), }); - setIsMuted(false); // Auto-unmute when successfully connected + setIsMicMuted(false); // Auto-unmute the mic when successfully connected setConnectionState('connected'); postRoomStatusToOpener('connected', 'voice-call'); } catch (error) { @@ -262,10 +283,10 @@ export function LiveKitChat({ const handleIsSpeakingChanged = (speaking: boolean) => { voiceLog('Local participant speaking changed', { speaking, - isMuted, - effectiveSpeaking: speaking && !isMuted, + isMicMuted, + effectiveSpeaking: speaking && !isMicMuted, }); - setIsSpeaking(speaking && !isMuted); + setIsSpeaking(speaking && !isMicMuted); }; const handleTrackMuted = () => { @@ -282,7 +303,7 @@ export function LiveKitChat({ room.localParticipant.off('isSpeakingChanged', handleIsSpeakingChanged); room.localParticipant.off('trackMuted', handleTrackMuted); }; - }, [connectionState, room, isMuted]); + }, [connectionState, room, isMicMuted]); // Listen to room connection state changes from LiveKit React.useEffect(() => { @@ -406,6 +427,8 @@ export function LiveKitChat({ count: speakers.length, speakers: speakers.map((s) => ({ identity: s.identity, sid: s.sid })), }); + // Anything speaking that is not us is the mentor agent. + setIsMentorSpeaking(speakers.some((s) => !s.isLocal)); }; const handleMediaDevicesError = (error: any) => { @@ -483,17 +506,31 @@ export function LiveKitChat({ }; }, []); - const handleToggleMute = () => { - const newMutedState = !isMuted; - voiceLog('Toggle mute', { - currentMuted: isMuted, + // Outbound only: stops publishing the user's microphone. Must not touch + // mentor audio playback. + const handleToggleMicMute = () => { + const newMutedState = !isMicMuted; + voiceLog('Toggle microphone mute', { + currentMuted: isMicMuted, newMuted: newMutedState, roomState: room.state, }); - setIsMuted(newMutedState); + setIsMicMuted(newMutedState); room.localParticipant.setMicrophoneEnabled(!newMutedState); }; + // Inbound only: gates playback of the mentor's voice via RoomAudioRenderer. + // Must not touch the microphone. + const handleToggleMentorAudio = () => { + const newMutedState = !isMentorAudioMuted; + voiceLog('Toggle mentor audio mute', { + currentMuted: isMentorAudioMuted, + newMuted: newMutedState, + roomState: room.state, + }); + setIsMentorAudioMuted(newMutedState); + }; + // Periodic room state dump (every 5 seconds while connected) React.useEffect(() => { if (connectionState !== 'connected') return; @@ -547,14 +584,20 @@ export function LiveKitChat({ return ( - + ); diff --git a/components/modals/__tests__/voice-chat-modal.test.tsx b/components/modals/__tests__/voice-chat-modal.test.tsx index 912cd7f0..ec1be358 100644 --- a/components/modals/__tests__/voice-chat-modal.test.tsx +++ b/components/modals/__tests__/voice-chat-modal.test.tsx @@ -1,19 +1,69 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; -import { VoiceChatModal } from '../voice-chat-modal'; +import { act } from 'react'; +import { + VoiceChatModal, + readCaptionsPreference, + writeCaptionsPreference, + groupTranscriptTurns, + formatCallDuration, +} from '../voice-chat-modal'; +import type { TranscriptEntry } from '@/hooks/use-livekit-transcription'; + +function entry( + id: string, + text: string, + speaker: TranscriptEntry['speaker'], + isFinal = true, + extra: Partial = {}, +): TranscriptEntry { + return { id, text, speaker, isFinal, timestamp: 0, ...extra }; +} + +/** + * A real agent turn as captured from a live call: several hundred characters, + * multiple paragraphs, embedded newlines. "Hello world" fixtures are what let + * the overflow bug ship — a short line never reproduces it. + */ +const REAL_AGENT_TURN = [ + "Sure! Let's break it down. A rocket's propulsion system works by burning fuel—often a combination of liquid or solid propellants.", + "When the fuel burns, it produces hot gases that expand and rush out of the rocket's nozzle at high speed. This creates thrust, which pushes the rocket upward.", + 'If we compare rockets to airplanes, airplanes rely on wings and atmospheric lift, whereas a rocket carries its own oxidiser and so keeps working in vacuum.', +].join('\n\n'); + +/** The other route to the same overflow: one token that cannot be broken. */ +const UNBREAKABLE_URL = + 'https://example.com/a/very/long/path/that/never/offers/a/single/break/opportunity/anywhere/at/all/reference.html'; + +const CAPTIONS_STORAGE_KEY = 'ibl.voiceChat.captionsEnabled'; + +/** Persist "captions on" the way a previous call would have. */ +function rememberCaptionsOn() { + window.localStorage.setItem(CAPTIONS_STORAGE_KEY, 'true'); +} + +/** Captions are on by default, so turning them off is the stored choice. */ +function rememberCaptionsOff() { + window.localStorage.setItem(CAPTIONS_STORAGE_KEY, 'false'); +} describe('VoiceChatModal', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), - toggleMute: vi.fn(), - isMuted: false, + toggleMicMute: vi.fn(), + isMicMuted: false, + toggleMentorAudio: vi.fn(), + isMentorAudioMuted: false, connectionState: 'connected' as const, isSpeaking: false, + isMentorSpeaking: false, }; beforeEach(() => { vi.clearAllMocks(); + vi.restoreAllMocks(); + window.localStorage.clear(); }); describe('accessibility', () => { @@ -67,6 +117,60 @@ describe('VoiceChatModal', () => { expect(screen.getByLabelText('Mute microphone')).toBeDisabled(); }); + + it('disables the agent audio button while connecting', () => { + render(); + + expect(screen.getByLabelText('Mute agent audio')).toBeDisabled(); + }); + + it('announces the connection state in the call status region', () => { + render(); + + // One status line for the whole call, whatever stage it is at - the + // loading message no longer lives in a paragraph of its own. + expect(screen.getByLabelText('Call status')).toHaveTextContent( + 'Connecting to voice chat...', + ); + }); + + it('announces the permission prompt in the same region', () => { + render( + , + ); + + expect(screen.getByLabelText('Call status')).toHaveTextContent( + 'Requesting microphone access...', + ); + }); + + it('holds the call clock back until the call is up', () => { + render(); + + expect(screen.queryByTestId('voice-call-duration')).toBeNull(); + }); + + it('shows the loading spinner while connecting', () => { + render(); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('does not tint the controls red while connecting', () => { + render(); + + // The loading state renders MicOff/VolumeX icons, but that is not a + // muted state and must not read as one. + expect(screen.getByLabelText('Mute microphone')).not.toHaveClass( + 'border-red-500', + ); + expect(screen.getByLabelText('Mute agent audio')).not.toHaveClass( + 'border-red-500', + ); + }); }); describe('connected state', () => { @@ -75,7 +179,7 @@ describe('VoiceChatModal', () => { , ); @@ -92,42 +196,165 @@ describe('VoiceChatModal', () => { expect(screen.getByLabelText('Mute microphone')).toBeEnabled(); }); + + it('enables the agent audio button when connected', () => { + render(); + + expect(screen.getByLabelText('Mute agent audio')).toBeEnabled(); + }); }); - describe('speaking state', () => { - it('uses faster pulse animation when speaking', () => { + describe('call presence', () => { + // The indicator is the mentor's own avatar now: their face is the call, + // their ring is their voice. The old abstract orb ran nine animations — + // ten drifting particles, five sound-wave bars and two competing pulses — + // none of which told you anything the status line does not say plainly. + it('shows the mentor with their avatar and name', () => { render( , ); - // Speaking uses 1.5s pulse (faster than non-speaking 2s) - const pulsingBg = document.querySelector('.bg-blue-100'); - expect(pulsingBg).toHaveStyle({ - animation: 'randomPulse1 1.5s ease-in-out infinite', + expect(screen.getByTestId('voice-blob')).toBeInTheDocument(); + // jsdom never loads the image, so the initials fallback stands in - the + // same one the chat shows for a mentor with no picture. + expect(screen.getAllByText('AD').length).toBeGreaterThan(0); + expect(screen.getByText('Ada')).toBeInTheDocument(); + }); + + it('falls back to a generic name when the mentor has none', () => { + render(); + + expect(screen.getAllByText('Agent').length).toBeGreaterThan(0); + }); + + it('breathes a halo while the line is open', () => { + render(); + + expect(screen.getByTestId('voice-halo')).toHaveStyle({ + animation: 'voiceHalo 3.2s ease-in-out infinite', + }); + }); + + it('drops the halo when the call is not up', () => { + render( + , + ); + + expect(screen.queryByTestId('voice-halo')).toBeNull(); + }); + + it('rings the avatar while the agent is speaking', () => { + render(); + + const ring = screen.getByTestId('mentor-speaking-ring'); + expect(ring.querySelector('.border-blue-500')).toHaveStyle({ + animation: 'voiceRingPulse 1.4s ease-in-out infinite', + }); + expect(ring.querySelector('.border-blue-400')).toHaveStyle({ + animation: 'voiceRingRipple 1.4s ease-out infinite', }); }); - it('uses slower pulse animation when not speaking', () => { + it('hides the mentor ring when the agent is silent', () => { + render(); + + expect(screen.queryByTestId('mentor-speaking-ring')).toBeNull(); + }); + + it('hides the mentor ring when agent audio is muted', () => { render( , + ); + + expect(screen.queryByTestId('mentor-speaking-ring')).toBeNull(); + }); + + it('hides the mentor ring when not connected', () => { + render( + , + ); + + expect(screen.queryByTestId('mentor-speaking-ring')).toBeNull(); + }); + + it('shows the mentor ring even while the user mic is muted', () => { + render( + , + ); + + // Regression: the indicator used to be gated on the local mic, so + // muting yourself froze it and a live call looked dead. + expect(screen.getByTestId('mentor-speaking-ring')).toBeInTheDocument(); + }); + + it('shows the caller their own voice on their own control', () => { + render(); + + // Two speakers, two places: the mentor's voice rings their avatar, the + // caller's lights up the microphone they control. + expect(screen.getByLabelText('Mute microphone')).toHaveClass( + 'ring-blue-500/40', + ); + }); + + it('leaves the mic control alone while the caller is silent', () => { + render(); + + expect(screen.getByLabelText('Mute microphone')).not.toHaveClass( + 'ring-blue-500/40', + ); + }); + + it('does not light the mic control for a muted caller who is speaking', () => { + render( + , ); - // Not speaking uses 2s pulse (slower) - const pulsingBg = document.querySelector('.bg-blue-100'); - expect(pulsingBg).toHaveStyle({ - animation: 'randomPulse1 2s ease-in-out infinite', + expect(screen.getByLabelText('Unmute microphone')).not.toHaveClass( + 'ring-blue-500/40', + ); + }); + + it('drains the indicator when agent audio is muted', () => { + render(); + + expect(screen.getByTestId('voice-blob')).toHaveStyle({ + opacity: '0.5', + filter: 'saturate(0.35)', }); }); + + it('leaves the indicator at full strength when agent audio is on', () => { + render(); + + expect(screen.getByTestId('voice-blob')).toHaveStyle({ opacity: '1' }); + }); + + it('does not drain the indicator when only the mic is muted', () => { + render(); + + expect(screen.getByTestId('voice-blob')).toHaveStyle({ opacity: '1' }); + }); }); describe('muted state', () => { @@ -136,7 +363,7 @@ describe('VoiceChatModal', () => { , ); @@ -148,7 +375,7 @@ describe('VoiceChatModal', () => { , ); @@ -160,7 +387,7 @@ describe('VoiceChatModal', () => { , ); @@ -168,6 +395,190 @@ describe('VoiceChatModal', () => { }); }); + describe('agent audio control', () => { + it('shows the mute label when agent audio is playing', () => { + render(); + + expect(screen.getByLabelText('Mute agent audio')).toBeInTheDocument(); + }); + + it('shows the unmute label when agent audio is muted', () => { + render(); + + expect(screen.getByLabelText('Unmute agent audio')).toBeInTheDocument(); + }); + + it('calls toggleMentorAudio when the agent audio button is clicked', () => { + render(); + + fireEvent.click(screen.getByLabelText('Mute agent audio')); + + expect(defaultProps.toggleMentorAudio).toHaveBeenCalledTimes(1); + expect(defaultProps.toggleMicMute).not.toHaveBeenCalled(); + }); + + it('leaves the microphone control untouched when agent audio is muted', () => { + render(); + + // Mic is still live even though the agent is silenced. + expect(screen.getByLabelText('Mute microphone')).toBeInTheDocument(); + expect(screen.getByLabelText('Mute microphone')).not.toHaveClass( + 'border-red-500', + ); + }); + }); + + describe('call status caption', () => { + const caption = () => screen.getByLabelText('Call status'); + + it('renders a single live region', () => { + render(); + + const regions = screen.getAllByRole('status'); + expect(regions).toHaveLength(1); + expect(regions[0]).toHaveAttribute('aria-label', 'Call status'); + }); + + // It used to be `sr-only`: the same facts, announced but never drawn, so + // sighted callers had to read the state off an abstract orb. One line, + // shown to everyone, is both simpler and less to maintain. + it('is shown, not just announced', () => { + render(); + + expect(caption()).not.toHaveClass('sr-only'); + expect(caption()).toBeVisible(); + }); + + it('shows "Listening…" when connected and nobody is muted or speaking', () => { + render(); + + expect(caption()).toHaveTextContent('Listening…'); + }); + + it('shows "Mic muted" when only the mic is muted', () => { + render(); + + expect(caption()).toHaveTextContent('Mic muted'); + }); + + it('shows "Agent speaking" when the agent is speaking', () => { + render(); + + expect(caption()).toHaveTextContent('Agent speaking'); + }); + + it('shows "Agent muted" when agent audio is muted', () => { + render(); + + expect(caption()).toHaveTextContent('Agent muted'); + }); + + it('prefers "Agent muted" over the agent speaking', () => { + render( + , + ); + + expect(caption()).toHaveTextContent('Agent muted'); + }); + + it('prefers "Agent muted" over the mic being muted', () => { + render( + , + ); + + expect(caption()).toHaveTextContent('Agent muted'); + }); + + it('prefers "Agent speaking" over the mic being muted', () => { + render( + , + ); + + expect(caption()).toHaveTextContent('Agent speaking'); + }); + + it('does not report the user speaking - the blob shows that', () => { + render(); + + expect(caption()).toHaveTextContent('Listening…'); + }); + + it('no longer renders the removed per-party status rows', () => { + render(); + + expect(screen.queryByLabelText('Agent audio status')).toBeNull(); + expect(screen.queryByLabelText('Microphone status')).toBeNull(); + }); + }); + + describe('control button muted treatment', () => { + // Muted is the one state that has to survive a glance, so it borrows the + // theme's destructive colour rather than inventing a red of its own. + it('tints the mic control when the mic is muted', () => { + render(); + + expect(screen.getByLabelText('Unmute microphone')).toHaveClass( + 'text-destructive', + ); + }); + + it('leaves the mic control neutral when the mic is live', () => { + render(); + + const mic = screen.getByLabelText('Mute microphone'); + expect(mic).toHaveClass('text-muted-foreground'); + expect(mic).not.toHaveClass('text-destructive'); + }); + + it('tints the agent audio control when agent audio is muted', () => { + render(); + + expect(screen.getByLabelText('Unmute agent audio')).toHaveClass( + 'text-destructive', + ); + }); + + it('leaves the agent audio control neutral when agent audio is on', () => { + render(); + + expect(screen.getByLabelText('Mute agent audio')).not.toHaveClass( + 'text-destructive', + ); + }); + + it('does not tint the mic control when only agent audio is muted', () => { + render(); + + expect(screen.getByLabelText('Mute microphone')).not.toHaveClass( + 'text-destructive', + ); + }); + + it('does not tint a disabled control while connecting', () => { + render(); + + // The loading state renders the muted icons, but nothing is muted yet. + expect(screen.getByLabelText('Mute microphone')).not.toHaveClass( + 'text-destructive', + ); + expect(screen.getByLabelText('Mute agent audio')).not.toHaveClass( + 'text-destructive', + ); + }); + }); + describe('disconnected state', () => { it('renders disconnected state without sound waves', () => { render( @@ -179,12 +590,13 @@ describe('VoiceChatModal', () => { }); describe('button interactions', () => { - it('calls toggleMute when mute button is clicked', () => { + it('calls toggleMicMute when mute button is clicked', () => { render(); fireEvent.click(screen.getByLabelText('Mute microphone')); - expect(defaultProps.toggleMute).toHaveBeenCalledTimes(1); + expect(defaultProps.toggleMicMute).toHaveBeenCalledTimes(1); + expect(defaultProps.toggleMentorAudio).not.toHaveBeenCalled(); }); it('calls onClose when close button is clicked', () => { @@ -196,6 +608,1343 @@ describe('VoiceChatModal', () => { }); }); + describe('call clock', () => { + const duration = () => screen.getByTestId('voice-call-duration'); + + /** Let the interval fire `seconds` times, with the wall clock moved on. */ + function advance(seconds: number) { + act(() => { + vi.advanceTimersByTime(seconds * 1000); + }); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts at zero and counts the call up', () => { + render(); + + expect(duration()).toHaveTextContent('0:00'); + + advance(75); + + expect(duration()).toHaveTextContent('1:15'); + }); + + it('appears only once the call is up', () => { + const { rerender } = render( + , + ); + + expect(screen.queryByTestId('voice-call-duration')).toBeNull(); + + rerender( + , + ); + + expect(duration()).toHaveTextContent('0:00'); + }); + + it('restarts rather than carrying a stale number through a reconnect', () => { + const { rerender } = render( + , + ); + + advance(42); + expect(duration()).toHaveTextContent('0:42'); + + rerender( + , + ); + rerender( + , + ); + + expect(duration()).toHaveTextContent('0:00'); + }); + + it('stops ticking when the call ends', () => { + const { rerender } = render( + , + ); + + advance(10); + rerender( + , + ); + advance(30); + + // Nothing left running to update a clock nobody is looking at. + expect(screen.queryByTestId('voice-call-duration')).toBeNull(); + }); + }); + + describe('formatCallDuration', () => { + it('pads the seconds but not the minutes, the way a phone does', () => { + expect(formatCallDuration(0)).toBe('0:00'); + expect(formatCallDuration(9)).toBe('0:09'); + expect(formatCallDuration(70)).toBe('1:10'); + expect(formatCallDuration(600)).toBe('10:00'); + }); + + it('adds an hours field once the call runs past one', () => { + expect(formatCallDuration(3600)).toBe('1:00:00'); + expect(formatCallDuration(3725)).toBe('1:02:05'); + }); + + it('never renders a negative or fractional clock', () => { + // Clock skew and a mid-second render should not produce "-0:01" or + // "0:07.5" on screen. + expect(formatCallDuration(-5)).toBe('0:00'); + expect(formatCallDuration(7.9)).toBe('0:07'); + }); + }); + + describe('status dot', () => { + const dot = () => screen.getByTestId('voice-status-dot'); + + it('is green while the line is simply open', () => { + render(); + + expect(dot()).toHaveClass('bg-emerald-500'); + }); + + it('turns blue and pulses while the agent talks', () => { + render(); + + expect(dot()).toHaveClass('bg-blue-500'); + expect(dot()).toHaveClass('animate-pulse'); + }); + + it('warns in the destructive colour whenever something is muted', () => { + const { rerender } = render( + , + ); + + expect(dot()).toHaveClass('bg-destructive'); + + rerender(); + + expect(dot()).toHaveClass('bg-destructive'); + }); + + it('gives way to a spinner while connecting', () => { + render(); + + expect(screen.queryByTestId('voice-status-dot')).toBeNull(); + expect( + screen.getByLabelText('Call status').querySelector('.animate-spin'), + ).toBeInTheDocument(); + }); + }); + + describe('captions toggle', () => { + // Label-agnostic: the control says "Hide captions" by default now, and + // "Show captions" only once the user has turned them off. + const ccButton = () => screen.getByRole('button', { name: /captions/i }); + + it('renders a captions control between agent audio and end call', () => { + render(); + + // Radix contributes its own unlabelled dialog close button; only the + // call controls carry aria-labels. + const labels = screen + .getAllByRole('button') + .map((b) => b.getAttribute('aria-label')) + .filter(Boolean); + expect(labels).toEqual([ + 'Mute microphone', + 'Mute agent audio', + 'Hide captions', + 'Close voice chat', + ]); + }); + + it('is styled as a peer of the other two circular controls', () => { + render(); + + expect(ccButton()).toHaveClass('size-11', 'rounded-full'); + expect(screen.getByLabelText('Mute microphone')).toHaveClass( + 'size-11', + 'rounded-full', + ); + }); + + it('sits with the other toggles, apart from the button that hangs up', () => { + render(); + + // The three toggles share one pill; the only control that leaves the + // call is the only one outside it. + const pill = screen.getByLabelText('Mute microphone').parentElement; + expect(pill).toContainElement(screen.getByLabelText('Mute agent audio')); + expect(pill).toContainElement(ccButton()); + expect(pill).not.toContainElement( + screen.getByLabelText('Close voice chat'), + ); + }); + + it('starts on, so a call is captioned without anyone asking', () => { + render( + , + ); + + expect(screen.getByRole('log')).toBeInTheDocument(); + expect(screen.getByTestId('voice-transcript')).toBeInTheDocument(); + expect(ccButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + it('hides the band when switched off and brings it back when switched on', () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText('Hide captions')); + + const toggledOff = screen.getByLabelText('Show captions'); + expect(toggledOff).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByRole('log')).toBeNull(); + + fireEvent.click(toggledOff); + + expect(screen.getByRole('log')).toBeInTheDocument(); + expect(ccButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + it('is disabled while connecting, like the other controls', () => { + render(); + + expect(ccButton()).toBeDisabled(); + }); + + it('does not tint the captions control while connecting', () => { + render(); + + // Even though captions are on, a disabled control must not read as an + // active one. + expect(ccButton()).toHaveClass('text-muted-foreground'); + expect(ccButton()).not.toHaveClass('text-blue-600'); + }); + + it('marks the control as active while captions are on', () => { + render(); + + expect(ccButton()).toHaveClass('text-blue-600'); + + fireEvent.click(ccButton()); + + expect(screen.getByLabelText('Show captions')).not.toHaveClass( + 'text-blue-600', + ); + }); + }); + + describe('captions preference persistence', () => { + it('writes the choice to localStorage when turned off', () => { + render(); + + fireEvent.click(screen.getByLabelText('Hide captions')); + + expect(window.localStorage.getItem(CAPTIONS_STORAGE_KEY)).toBe('false'); + }); + + it('writes the choice to localStorage when turned back on', () => { + rememberCaptionsOff(); + render(); + + fireEvent.click(screen.getByLabelText('Show captions')); + + expect(window.localStorage.getItem(CAPTIONS_STORAGE_KEY)).toBe('true'); + }); + + it('keeps captions off on mount when a previous call turned them off', () => { + rememberCaptionsOff(); + + render( + , + ); + + expect(screen.queryByRole('log')).toBeNull(); + expect(screen.getByLabelText('Show captions')).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }); + + it('restores captions on mount when a previous call left them on', () => { + rememberCaptionsOn(); + + render( + , + ); + + expect(screen.getByRole('log')).toBeInTheDocument(); + expect(screen.getByLabelText('Hide captions')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + it('only an explicit "false" turns captions off', () => { + // Anything else - a value from a future version, a half-written one - + // falls back to the default rather than to silence. + window.localStorage.setItem(CAPTIONS_STORAGE_KEY, 'off'); + + render(); + + expect(screen.getByRole('log')).toBeInTheDocument(); + }); + + it('falls back to on when reading storage throws', () => { + vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + + render(); + + expect(screen.getByLabelText('Hide captions')).toBeInTheDocument(); + expect(screen.getByRole('log')).toBeInTheDocument(); + }); + + // The modal itself cannot run without a DOM, so the server-render guard is + // exercised against the helpers directly. + describe('without a window (server render)', () => { + /** + * `window` is put back before yielding to the event loop: leaving it + * undefined across an await lets unrelated async DOM work (Radix's + * tooltip positioning) blow up. + */ + function withoutWindow(run: () => T): T { + vi.stubGlobal('window', undefined); + try { + return run(); + } finally { + vi.unstubAllGlobals(); + } + } + + it('reports the default instead of touching storage', () => { + const getItem = vi.spyOn(window.localStorage, 'getItem'); + + expect(withoutWindow(() => readCaptionsPreference())).toBe(true); + expect(getItem).not.toHaveBeenCalled(); + }); + + it('silently skips writing the preference', () => { + const setItem = vi.spyOn(window.localStorage, 'setItem'); + + expect(() => + withoutWindow(() => writeCaptionsPreference(true)), + ).not.toThrow(); + expect(setItem).not.toHaveBeenCalled(); + }); + }); + + it('keeps the toggle working when writing to storage throws', () => { + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + render( + , + ); + + fireEvent.click(screen.getByLabelText('Hide captions')); + + // The preference is lost for next time, but this call still honours the + // click - a storage failure must never break the modal. + expect(screen.queryByRole('log')).toBeNull(); + expect(screen.getByLabelText('Show captions')).toBeInTheDocument(); + }); + }); + + describe('caption band', () => { + const lines = () => screen.queryAllByTestId('voice-transcript-line'); + + const speakers = () => screen.queryAllByTestId('voice-transcript-speaker'); + + // Every turn is a speaker followed by its text, so who-said-what is read + // back by pairing the two. Composed from the pair rather than the turn's + // own `textContent`, which also picks up the avatar's initials. + const spokenExchange = () => + screen.queryAllByTestId('voice-transcript-turn').map((turn) => { + const speaker = turn.querySelector( + '[data-testid="voice-transcript-speaker"]', + ); + const line = turn.querySelector( + '[data-testid="voice-transcript-line"]', + ); + return `${speaker?.textContent ?? ''}${line?.textContent ?? ''}`; + }); + + function renderWithCaptions(props: Record = {}) { + rememberCaptionsOn(); + return render(); + } + + it('exposes a polite log region with a stable label', () => { + renderWithCaptions(); + + const region = screen.getByRole('log'); + expect(region).toHaveAttribute('aria-label', 'Call transcript'); + expect(region).toHaveAttribute('aria-live', 'polite'); + // Only new/changed lines are announced - the lines still on screen are + // never re-read. + expect(region).toHaveAttribute('aria-relevant', 'additions text'); + // The band scrolls, so it has to be reachable without a pointer - and + // reaching it has to be visible when you get there. + expect(region).toHaveAttribute('tabindex', '0'); + expect(region).toHaveClass('focus-visible:ring-2'); + expect(region).toHaveClass('focus-visible:ring-ring'); + }); + + it('does not turn the transcript into a second status region', () => { + renderWithCaptions({ transcript: [entry('s1', 'Hello', 'agent')] }); + + const statusRegions = screen.getAllByRole('status'); + expect(statusRegions).toHaveLength(1); + expect(statusRegions[0]).toHaveAttribute('aria-label', 'Call status'); + }); + + it('shows a neutral empty state before the first utterance', () => { + renderWithCaptions(); + + expect(screen.getByTestId('voice-transcript-empty')).toHaveTextContent( + 'The live transcript will appear here as the conversation starts.', + ); + expect(lines()).toHaveLength(0); + }); + + it('replaces the empty state once lines arrive', () => { + renderWithCaptions({ transcript: [entry('s1', 'Hello there', 'agent')] }); + + expect(screen.queryByTestId('voice-transcript-empty')).toBeNull(); + expect(lines()).toHaveLength(1); + }); + + it('renders lines oldest first', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'First', 'agent'), + entry('s2', 'Second', 'user'), + ], + }); + + expect(spokenExchange()).toEqual(['AgentFirst', 'YouSecond']); + }); + + it('keeps the whole conversation, not just the last exchange', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'One', 'agent'), + entry('s2', 'Two', 'user'), + entry('s3', 'Three', 'agent'), + entry('s4', 'Four', 'user'), + entry('s5', 'Five', 'agent'), + ], + }); + + // Trimming to the current exchange meant the call you had just had was + // gone the moment it moved on, and you had to take it on trust. + expect(spokenExchange()).toEqual([ + 'AgentOne', + 'YouTwo', + 'AgentThree', + 'YouFour', + 'AgentFive', + ]); + }); + + it('folds a turn that arrived in pieces into one bubble', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', 'First half of the reply', 'agent'), + entry('s3', 'second half of the reply', 'agent'), + ], + }); + + // The recogniser flushes mid-sentence; that is not a message boundary. + expect(spokenExchange()).toEqual([ + 'YouA question', + 'AgentFirst half of the reply second half of the reply', + ]); + }); + + it('puts the newest turn at the bottom', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'One', 'agent'), + entry('s2', 'Two', 'user'), + entry('s3', 'Three', 'agent'), + entry('s4', 'Four', 'user'), + ], + }); + + const rendered = lines(); + expect(rendered).toHaveLength(4); + expect(rendered[3]).toHaveAttribute('data-newest', 'true'); + rendered + .slice(0, 3) + .forEach((line) => + expect(line).toHaveAttribute('data-newest', 'false'), + ); + }); + + it('shows one bubble while a single speaker is still going', () => { + renderWithCaptions({ + transcript: [entry('s1', 'One', 'agent'), entry('s2', 'Two', 'agent')], + }); + + const rendered = lines(); + expect(rendered).toHaveLength(1); + expect(rendered[0]).toHaveTextContent('One Two'); + expect(rendered[0]).toHaveAttribute('data-newest', 'true'); + }); + + it('takes the height the card sets aside for it, and scrolls', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'One', 'agent'), + entry('s2', 'Two', 'user'), + entry('s3', 'Three', 'agent'), + ], + }); + + const band = screen.getByTestId('voice-transcript'); + expect(band).toHaveClass('flex-1'); + expect(band).toHaveClass('min-h-0'); + expect(band).toHaveClass('overflow-y-auto'); + // Sideways is still the dialog's business, not the band's. + expect(band).toHaveClass('overflow-x-hidden'); + }); + + it('gives a captioned call a card tall enough to sit with', () => { + renderWithCaptions({ transcript: [entry('s1', 'One', 'agent')] }); + + const card = screen + .getByTestId('voice-transcript') + .closest('[role="dialog"] > div'); + expect(card?.className).toContain('h-[min(46rem,88vh)]'); + }); + + it('lets the card shrink back to its contents when captions are off', () => { + rememberCaptionsOff(); + render(); + + // Nothing to fill a tall card with, so it stops framing empty space. + const card = screen + .getByTestId('voice-blob') + .closest('[role="dialog"] > div'); + expect(card?.className).not.toContain('h-[min(46rem,88vh)]'); + }); + + it('gives the captions their own surface', () => { + renderWithCaptions({ transcript: [entry('s1', 'One', 'agent')] }); + + // A tinted panel with a border, so the transcript reads as part of the + // card rather than as text floating in it. + const panel = screen.getByTestId('voice-transcript').parentElement; + expect(panel).toHaveClass('border-t'); + expect(panel).toHaveClass('bg-muted/40'); + }); + + it('leaves the presence indicator and the controls their own size', () => { + renderWithCaptions({ transcript: [entry('s1', 'One', 'agent')] }); + + // The band is the only thing that scrolls; if these could shrink, a + // long exchange would eat the mentor's avatar instead. + expect(screen.getByTestId('voice-blob')).toHaveClass('shrink-0'); + const controls = screen + .getByLabelText('Mute microphone') + .closest('div.border-t') as HTMLElement; + expect(controls).toHaveClass('shrink-0'); + }); + + it('clips at its edges rather than fading them out', () => { + renderWithCaptions({ + transcript: [ + entry('s1', REAL_AGENT_TURN, 'user'), + entry('s2', REAL_AGENT_TURN, 'agent', false), + ], + }); + + // A fade was right when the band was loose rows of text. Against a + // message bubble it dissolves the bubble's own background into the + // dialog, so the message looks like it is disintegrating instead of + // scrolling. A bubble cut off at the edge is what chat threads do. + expect(screen.getByTestId('voice-transcript').className).not.toContain( + 'mask-image', + ); + }); + + // A turn longer than the band used to be clipped: the start of a + // paragraph-long answer scrolled out of the top of its window and was + // simply gone. It is only ever two messages, so the band scrolls instead + // and follows the live line by itself. jsdom has no layout engine, so + // scroll geometry is stubbed on the element. + describe('scrollback', () => { + /** Give the band a viewport smaller than its content, as a browser would. */ + function makeBandScrollable( + band: HTMLElement, + { scrollHeight = 400, clientHeight = 160 } = {}, + ) { + Object.defineProperty(band, 'scrollHeight', { + value: scrollHeight, + configurable: true, + }); + Object.defineProperty(band, 'clientHeight', { + value: clientHeight, + configurable: true, + }); + return band; + } + + it('keeps the whole exchange in the DOM, clamping nothing', () => { + renderWithCaptions({ + mentorName: 'Ada', + transcript: [ + entry('s1', REAL_AGENT_TURN, 'user'), + entry('s2', REAL_AGENT_TURN, 'agent', false), + ], + }); + + // Both turns, in full: the answered one is no longer capped at two + // rows and the live one is no longer capped at three. + const [older, newest] = lines(); + expect(older).toHaveTextContent(REAL_AGENT_TURN.slice(0, 40)); + expect(older.textContent).toHaveLength(REAL_AGENT_TURN.length); + expect(newest.textContent).toContain(REAL_AGENT_TURN.slice(-40)); + [older, newest].forEach((line) => { + expect(line.className).not.toContain('line-clamp'); + expect(line.className).not.toContain('max-h-'); + }); + }); + + it('scrolls the live line into view as the turn grows', () => { + rememberCaptionsOn(); + const { rerender } = render( + , + ); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + band.scrollTop = 0; + + rerender( + , + ); + + expect(band.scrollTop).toBe(band.scrollHeight); + expect(band).toHaveAttribute('data-following', 'true'); + }); + + it('keeps its edges clean whether it is following or not', () => { + renderWithCaptions({ + transcript: [entry('s1', REAL_AGENT_TURN, 'agent', false)], + }); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + expect(band.className).not.toContain('mask-image'); + + band.scrollTop = 40; + fireEvent.scroll(band); + + // Scrolling back must not start dissolving the bubbles either. + expect(band.className).not.toContain('mask-image'); + }); + + it('stops following once the reader scrolls up', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', REAL_AGENT_TURN, 'agent', false), + ], + }); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + band.scrollTop = 40; + fireEvent.scroll(band); + + expect(band).toHaveAttribute('data-following', 'false'); + }); + + it('leaves a reader who scrolled up where they are', () => { + rememberCaptionsOn(); + const { rerender } = render( + , + ); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + band.scrollTop = 40; + fireEvent.scroll(band); + + rerender( + , + ); + + // Yanking the view back to the bottom mid-sentence is the reason + // scrollback is worth having at all. + expect(band.scrollTop).toBe(40); + }); + + it('follows again when the reader returns to the bottom', () => { + renderWithCaptions({ + transcript: [entry('s1', REAL_AGENT_TURN, 'agent', false)], + }); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + band.scrollTop = 40; + fireEvent.scroll(band); + expect(band).toHaveAttribute('data-following', 'false'); + + band.scrollTop = band.scrollHeight - band.clientHeight; + fireEvent.scroll(band); + + expect(band).toHaveAttribute('data-following', 'true'); + }); + + it('treats a few pixels short of the bottom as still following', () => { + renderWithCaptions({ + transcript: [entry('s1', REAL_AGENT_TURN, 'agent', false)], + }); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + // Sub-pixel layout and momentum scrolling rarely land exactly on zero. + band.scrollTop = band.scrollHeight - band.clientHeight - 4; + fireEvent.scroll(band); + + expect(band).toHaveAttribute('data-following', 'true'); + }); + + it('re-arms following when the next turn starts', () => { + rememberCaptionsOn(); + const { rerender } = render( + , + ); + + const band = makeBandScrollable(screen.getByTestId('voice-transcript')); + band.scrollTop = 40; + fireEvent.scroll(band); + expect(band).toHaveAttribute('data-following', 'false'); + + rerender( + , + ); + + // The band only ever shows the current exchange, so a scroll position + // left over from the previous one is stale. + expect(band).toHaveAttribute('data-following', 'true'); + expect(band.scrollTop).toBe(band.scrollHeight); + }); + + it('sits a short exchange at the bottom, as a chat thread does', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', 'A reply', 'agent'), + ], + }); + + // An auto margin, not flex alignment: aligning a scroll container's + // content puts overflow above the scroll origin, where it cannot be + // reached. An auto margin collapses to zero as soon as it overflows. + const content = lines()[0].closest( + '[data-testid="voice-transcript"] > div', + ); + expect(content).toHaveClass('mt-auto'); + const band = screen.getByTestId('voice-transcript'); + expect(band.className).not.toContain('justify-end'); + expect(band.className).not.toContain('justify-center'); + }); + + it('separates the turns the way the chat thread does', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', 'A reply', 'agent'), + ], + }); + + // The two halves of the exchange used to sit one row apart with no + // gap, which is what made them read as a single mangled paragraph. + screen + .getAllByTestId('voice-transcript-turn') + .forEach((turn) => expect(turn).toHaveClass('mb-4')); + }); + }); + + // The band renders the same bubbles as the chat thread the call belongs + // to, minus the action toolbar: those actions need a saved message to act + // on, and a caption is not one until the call ends. + describe('chat-message styling', () => { + it('gives the caller a right-aligned blue bubble, as in the chat', () => { + renderWithCaptions({ transcript: [entry('s1', 'My words', 'user')] }); + + const turn = screen.getByTestId('voice-transcript-turn'); + expect(turn).toHaveClass('items-end'); + const bubble = lines()[0]; + expect(bubble).toHaveClass('rounded-2xl'); + expect(bubble).toHaveClass('bg-blue-50'); + expect(bubble).toHaveClass('text-sm'); + }); + + it('names the caller for assistive tech only, as the chat does', () => { + renderWithCaptions({ transcript: [entry('s1', 'My words', 'user')] }); + + // The chat gives your own bubble no visible name; the log is still + // read aloud, where losing who-said-what is not cosmetic. + const label = screen.getByTestId('voice-transcript-speaker'); + expect(label).toHaveTextContent('You'); + expect(label).toHaveClass('sr-only'); + }); + + it('gives the mentor an avatar, a name and a grey bubble', () => { + renderWithCaptions({ + mentorName: 'Ada', + mentorImage: 'https://cdn.example.com/ada.png', + transcript: [entry('s1', 'Their words', 'agent')], + }); + + const label = screen.getByTestId('voice-transcript-speaker'); + expect(label).toHaveTextContent('Ada'); + expect(label).toHaveClass('text-gray-900'); + expect(label.className).not.toContain('sr-only'); + + const bubble = lines()[0]; + expect(bubble).toHaveClass('rounded-2xl'); + // White on the tinted caption panel, the way the chat's grey bubble + // sits on the chat's white page: a raised surface either way. + expect(bubble).toHaveClass('bg-white'); + expect(bubble).toHaveClass('text-sm/6'); + }); + + it('falls back to the mentor initials when there is no avatar', () => { + renderWithCaptions({ + mentorName: 'Ada', + transcript: [entry('s1', 'Their words', 'agent')], + }); + + // jsdom never loads the image, so Radix shows the fallback - which is + // exactly what a mentor with no picture gets in the chat too. Two of + // them: the call's own avatar and this turn's. + expect(screen.getAllByText('AD')).toHaveLength(2); + }); + + it('carries none of the chat action buttons', () => { + renderWithCaptions({ + mentorName: 'Ada', + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', 'A reply', 'agent'), + ], + }); + + // Copy/rate/share/read-aloud all act on a saved message; a caption has + // no server-side identity to act on until the call is over. + const labels = screen + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')) + .filter(Boolean); + expect(labels).toEqual([ + 'Mute microphone', + 'Mute agent audio', + 'Hide captions', + 'Close voice chat', + ]); + }); + }); + + it('gives every turn a speaker row of its own', () => { + renderWithCaptions({ + mentorName: 'Ada', + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', REAL_AGENT_TURN, 'agent', false), + ], + }); + + // Inline, a label is the first thing to scroll out of view, so a + // paragraph-long reply lost its name exactly when it needed one. + expect(speakers().map((s) => s.textContent)).toEqual(['You', 'Ada']); + expect(speakers().map((s) => s.getAttribute('data-speaker'))).toEqual([ + 'user', + 'agent', + ]); + // No label is left inside the text itself. + lines().forEach((line) => expect(line.textContent).not.toContain('Ada')); + }); + + it('gives the answered turn the same weight as the live one', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'A question', 'user'), + entry('s2', 'A reply', 'agent'), + ], + }); + + // Both are chat messages now; dimming the first half of an exchange was + // part of what made the band read as damaged rather than as a thread. + [...speakers(), ...lines()].forEach((node) => + expect(node.className).not.toContain('opacity-'), + ); + }); + + it('falls back to the LiveKit participant name when no mentor name is given', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'Their words', 'agent', true, { + participantName: 'Agent Seven', + }), + ], + }); + + expect(screen.getByTestId('voice-transcript-speaker')).toHaveTextContent( + 'Agent Seven', + ); + }); + + it('falls back to a generic agent label as a last resort', () => { + renderWithCaptions({ transcript: [entry('s1', 'Their words', 'agent')] }); + + expect(screen.getByTestId('voice-transcript-speaker')).toHaveTextContent( + 'Agent', + ); + }); + + it('never labels a user line with the mentor name', () => { + renderWithCaptions({ + mentorName: 'Ada', + transcript: [ + entry('s1', 'My words', 'user', true, { participantName: 'Ada' }), + ], + }); + + expect(screen.getByTestId('voice-transcript-speaker')).toHaveTextContent( + 'You', + ); + }); + + it('tags each line with its place in the exchange', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'Older', 'user'), + entry('s2', 'Newest', 'agent'), + ], + }); + + const [older, newest] = lines(); + expect(older).toHaveAttribute('data-age', '1'); + expect(older).toHaveAttribute('data-newest', 'false'); + expect(newest).toHaveAttribute('data-age', '0'); + expect(newest).toHaveAttribute('data-newest', 'true'); + }); + + it('marks an in-progress line with a caret', () => { + renderWithCaptions({ + transcript: [entry('s1', 'Still talk', 'agent', false)], + }); + + const caret = screen.getByTestId('voice-transcript-caret'); + expect(caret).toHaveAttribute('aria-hidden', 'true'); + expect(caret).toHaveStyle({ + animation: 'transcriptCaret 1s ease-in-out infinite', + }); + expect(lines()[0]).toHaveAttribute('data-final', 'false'); + }); + + it('settles the caret when the line finalises', () => { + rememberCaptionsOn(); + const { rerender } = render( + , + ); + + expect(screen.getByTestId('voice-transcript-caret')).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.queryByTestId('voice-transcript-caret')).toBeNull(); + expect(lines()[0]).toHaveAttribute('data-final', 'true'); + }); + + it('tags each line with its speaker for styling and selection', () => { + renderWithCaptions({ + transcript: [ + entry('s1', 'Mine', 'user'), + entry('s2', 'Theirs', 'agent'), + ], + }); + + expect(lines().map((l) => l.getAttribute('data-speaker'))).toEqual([ + 'user', + 'agent', + ]); + }); + + it('no longer renders the removed scrollback affordances', () => { + renderWithCaptions({ + transcript: [entry('s1', 'Hello', 'agent')], + }); + + expect(screen.queryByTestId('voice-transcript-scroll')).toBeNull(); + expect(screen.queryByTestId('voice-transcript-jump')).toBeNull(); + expect(screen.queryByTestId('voice-transcript-live')).toBeNull(); + }); + }); + + // Regression: the captions band used to widen the whole dialog, so text ran + // past the modal's edge - but only sometimes, because it depended on how long + // the agent's last turn happened to be. + // + // Cause: `truncate` expands to `white-space: nowrap`, and a nowrap box's + // intrinsic minimum width is its entire unwrapped line. `DialogContent` is a + // CSS grid and grid items default to `min-width: auto`, so that intrinsic + // width propagated all the way up and sized the dialog to the longest line. + // A short older line stayed under `max-w-lg` and looked fine; a 500-character + // agent paragraph did not. jsdom has no layout engine, so these assert the + // constraints rather than measured pixels. + describe('horizontal overflow constraints', () => { + const lines = () => screen.queryAllByTestId('voice-transcript-line'); + + function renderWithCaptions(props: Record = {}) { + rememberCaptionsOn(); + return render(); + } + + it('never puts a nowrap line in the band, however long the turn', () => { + renderWithCaptions({ + mentorName: 'Agent Taha', + transcript: [ + entry('s1', REAL_AGENT_TURN, 'agent'), + entry( + 's2', + 'No, not really, but I think that is good enough.', + 'user', + ), + ], + }); + + expect(lines()).toHaveLength(2); + lines().forEach((line) => { + // `truncate` is the class that carried `white-space: nowrap`. + expect(line).not.toHaveClass('truncate'); + expect(line.className).not.toContain('whitespace-nowrap'); + expect(line.className).not.toContain('text-nowrap'); + }); + // Overflow is the band's scrollbar's problem now, and a wrapped line has + // a wrappable intrinsic width whatever its length. + expect(screen.getByTestId('voice-transcript')).toHaveClass( + 'overflow-y-auto', + ); + }); + + it('keeps the live speaker row wrappable too', () => { + renderWithCaptions({ + mentorName: `Agent ${UNBREAKABLE_URL}`, + transcript: [entry('s1', REAL_AGENT_TURN, 'agent')], + }); + + // A mentor name is user-supplied, so the one-row cap has to come from + // `line-clamp`, never from `truncate`'s `white-space: nowrap` - that is + // the exact class of bug this band already had once. + const label = screen.getByTestId('voice-transcript-speaker'); + expect(label).toHaveClass('line-clamp-1'); + expect(label).toHaveClass('break-words'); + expect(label).toHaveClass('min-w-0'); + expect(label).not.toHaveClass('truncate'); + expect(label.className).not.toContain('whitespace-nowrap'); + }); + + it('lets an unbreakable token wrap instead of pushing the layout wide', () => { + renderWithCaptions({ + transcript: [ + entry('s1', `See ${UNBREAKABLE_URL}`, 'user'), + entry('s2', `Mirrored at ${UNBREAKABLE_URL}`, 'agent'), + ], + }); + + lines().forEach((line) => { + expect(line).toHaveClass('break-words'); + expect(line).toHaveClass('min-w-0'); + }); + }); + + it('caps the band inside the dialog rather than beyond it', () => { + renderWithCaptions({ + transcript: [entry('s1', REAL_AGENT_TURN, 'agent')], + }); + + const band = screen.getByTestId('voice-transcript'); + // The band fills the card and no more: it once declared `max-w-xl` + // (36rem), a cap wider than its own container could ever grant - dead at + // best, misleading at worst. + expect(band).toHaveClass('w-full'); + expect(band).not.toHaveClass('max-w-xl'); + expect(band).toHaveClass('min-w-0'); + }); + + it('breaks the min-width chain at the dialog grid item', () => { + renderWithCaptions({ + transcript: [entry('s1', REAL_AGENT_TURN, 'agent')], + }); + + // Walk up from the band to the dialog and require every box on the way + // to be allowed to shrink. One missing `min-w-0` restores the bug. + const dialog = screen.getByRole('dialog'); + let node = screen.getByTestId('voice-transcript').parentElement; + const chain: HTMLElement[] = []; + while (node && node !== dialog) { + chain.push(node); + node = node.parentElement; + } + + expect(chain.length).toBeGreaterThan(0); + chain.forEach((box) => expect(box).toHaveClass('min-w-0')); + }); + + it('collapses newlines rather than honouring them - a caption is not a document', () => { + renderWithCaptions({ + transcript: [entry('s1', REAL_AGENT_TURN, 'agent')], + }); + + // No `whitespace-pre`/`pre-wrap`: the paragraph breaks in an agent turn + // would each cost a row of a five-row band. + expect(lines()[0].className).not.toContain('whitespace-pre'); + }); + }); + + describe('groupTranscriptTurns', () => { + const shape = (turns: ReturnType) => + turns.map((turn) => `${turn.speaker}:${turn.text}`); + + it('returns nothing for an empty transcript', () => { + expect(groupTranscriptTurns([])).toEqual([]); + }); + + it('keeps alternating turns as they came', () => { + expect( + shape( + groupTranscriptTurns([ + entry('s1', 'One', 'agent'), + entry('s2', 'Two', 'user'), + entry('s3', 'Three', 'agent'), + ]), + ), + ).toEqual(['agent:One', 'user:Two', 'agent:Three']); + }); + + // The reason grouping exists: LiveKit flushes an utterance in pieces, and + // a bubble per piece splits sentences wherever the recogniser paused. + it('joins consecutive pieces from one speaker into a single turn', () => { + expect( + shape( + groupTranscriptTurns([ + entry('s1', 'A question', 'user'), + entry('s2', 'Reply part one', 'agent'), + entry('s3', 'reply part two', 'agent'), + ]), + ), + ).toEqual(['user:A question', 'agent:Reply part one reply part two']); + }); + + it('joins any number of pieces, not just two', () => { + expect( + shape( + groupTranscriptTurns([ + entry('s1', 'A', 'user'), + entry('s2', 'B', 'user'), + entry('s3', 'C', 'user'), + ]), + ), + ).toEqual(['user:A B C']); + }); + + it('keeps the id of the first piece so a growing turn stays put', () => { + const turns = groupTranscriptTurns([ + entry('s1', 'Starting', 'agent'), + entry('s2', 'and continuing', 'agent'), + ]); + + // React keys off this: change it mid-turn and the bubble is torn down + // and rebuilt on every flush. + expect(turns[0].id).toBe('s1'); + }); + + it('takes its finality from the last piece', () => { + const [turn] = groupTranscriptTurns([ + entry('s1', 'Done', 'agent', true), + entry('s2', 'still going', 'agent', false), + ]); + + expect(turn.isFinal).toBe(false); + }); + + it('keeps the first participant name it was given', () => { + const [turn] = groupTranscriptTurns([ + entry('s1', 'Hello', 'agent', true, { participantName: 'Agent Seven' }), + entry('s2', 'again', 'agent'), + ]); + + expect(turn.participantName).toBe('Agent Seven'); + }); + + it('picks up a participant name that only arrives later', () => { + const [turn] = groupTranscriptTurns([ + entry('s1', 'Hello', 'agent'), + entry('s2', 'again', 'agent', true, { + participantName: 'Agent Seven', + }), + ]); + + expect(turn.participantName).toBe('Agent Seven'); + }); + + it('does not leave a gap when a piece arrives empty', () => { + const [turn] = groupTranscriptTurns([ + entry('s1', '', 'agent'), + entry('s2', 'Hello', 'agent'), + ]); + + expect(turn.text).toBe('Hello'); + }); + + it('leaves the original entries untouched', () => { + const first = entry('s1', 'One', 'agent'); + const second = entry('s2', 'Two', 'agent'); + + groupTranscriptTurns([first, second]); + + expect(first.text).toBe('One'); + expect(second.text).toBe('Two'); + }); + }); + + describe('control tooltips', () => { + it('names the captions control in one word, like its neighbours', async () => { + render(); + + // Radix opens on focus, which jsdom simulates reliably; hover does not. + fireEvent.focus(screen.getByLabelText('Hide captions')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Captions'); + // The state-carrying wording stays on the label, where it costs nothing. + expect(tooltip).not.toHaveTextContent('Hide captions'); + expect(screen.getByLabelText('Hide captions')).toBeInTheDocument(); + }); + + it('keeps the descriptive label when captions are off', () => { + rememberCaptionsOff(); + render(); + + expect(screen.getByLabelText('Show captions')).toBeInTheDocument(); + }); + + it('keeps the tooltip itself bounded and breakable', async () => { + render(); + + fireEvent.focus(screen.getByLabelText('Hide captions')); + + // `ibl-tooltip-content` is the repo's shared treatment: max-w-xs plus + // break-words. jsdom cannot measure the collision offsets, so the + // assertion is that the row opts into the shared, capped treatment. + await screen.findByRole('tooltip'); + const content = document.querySelector('[data-slot="tooltip-content"]'); + expect(content).toHaveClass('ibl-tooltip-content'); + }); + }); + + describe('layout', () => { + it('keeps the blob and all four controls with captions off', () => { + rememberCaptionsOff(); + render( + , + ); + + expect(screen.getByTestId('voice-blob')).toBeInTheDocument(); + expect(screen.getByLabelText('Mute microphone')).toBeInTheDocument(); + expect(screen.getByLabelText('Mute agent audio')).toBeInTheDocument(); + expect(screen.getByLabelText('Show captions')).toBeInTheDocument(); + expect(screen.getByLabelText('Close voice chat')).toBeInTheDocument(); + // The status line is shown to everyone now, not just announced. + expect(screen.getByLabelText('Call status')).toBeVisible(); + // Nothing else competes with the orb. + expect(screen.queryByRole('log')).toBeNull(); + }); + + it('keeps the orb from being squeezed by the caption band', () => { + rememberCaptionsOn(); + render(); + + // The band grows into the spare space, but never at the orb's expense: + // the orb holds its size and the band scrolls instead. + expect(screen.getByTestId('voice-blob')).toHaveClass('shrink-0'); + expect(screen.getByTestId('voice-transcript')).toHaveClass( + 'overflow-y-auto', + ); + expect(screen.getByTestId('voice-transcript')).toHaveClass('flex-1'); + }); + }); + describe('reconnecting/error states', () => { it('does not show loading message for reconnecting state', () => { render( diff --git a/components/modals/voice-chat-modal.tsx b/components/modals/voice-chat-modal.tsx index 8bf1da39..2e1067aa 100644 --- a/components/modals/voice-chat-modal.tsx +++ b/components/modals/voice-chat-modal.tsx @@ -1,5 +1,8 @@ 'use client'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { UIEvent } from 'react'; + import { useTranslations } from 'next-intl'; import { Dialog, @@ -7,13 +10,26 @@ import { DialogDescription, DialogTitle, } from '@/components/ui/dialog'; -import { Mic, MicOff, X, Loader2 } from 'lucide-react'; +import type { TranscriptEntry } from '@/hooks/use-livekit-transcription'; +import { + Mic, + MicOff, + Volume2, + VolumeX, + PhoneOff, + Loader2, + Captions, + CaptionsOff, +} from 'lucide-react'; import { Tooltip, TooltipTrigger, TooltipContent, } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { cn } from '@/lib/utils'; type ConnectionState = | 'requesting-permission' @@ -23,94 +39,206 @@ type ConnectionState = | 'disconnected' | 'error'; -// CSS animations for random pulse effects -const pulseAnimations = ` - @keyframes randomPulse1 { - 0%, 100% { transform: scale(1.05); opacity: 0.8; } - 25% { transform: scale(1.15); opacity: 0.9; } - 50% { transform: scale(1.08); opacity: 0.95; } - 75% { transform: scale(1.18); opacity: 0.85; } +/** + * Three animations, all tied to a fact about the call: the mentor is speaking, + * the caller is speaking, or a word is still being transcribed. The old + * indicator ran nine — drifting particles, five sound-wave bars and two + * competing pulses — none of which meant anything on their own. + */ +const callAnimations = ` + @keyframes voiceRingPulse { + 0%, 100% { transform: scale(1); opacity: 0.5; } + 50% { transform: scale(1.05); opacity: 0.9; } } - @keyframes randomPulse2 { - 0%, 100% { transform: scale(1.03); opacity: 0.7; } - 33% { transform: scale(1.12); opacity: 0.85; } - 66% { transform: scale(1.06); opacity: 0.8; } + @keyframes voiceRingRipple { + 0% { transform: scale(0.98); opacity: 0.55; } + 70% { opacity: 0.1; } + 100% { transform: scale(1.35); opacity: 0; } } - @keyframes soundWave1 { - 0%, 100% { height: 15px; } - 20% { height: 45px; } - 40% { height: 25px; } - 60% { height: 50px; } - 80% { height: 30px; } + @keyframes voiceHalo { + 0%, 100% { transform: scale(1); opacity: 0.55; } + 50% { transform: scale(1.08); opacity: 0.8; } } - @keyframes soundWave2 { - 0%, 100% { height: 20px; } - 25% { height: 40px; } - 50% { height: 35px; } - 75% { height: 48px; } + @keyframes transcriptCaret { + 0%, 45% { opacity: 1; } + 50%, 95% { opacity: 0.15; } + 100% { opacity: 1; } } +`; - @keyframes soundWave3 { - 0%, 100% { height: 25px; } - 30% { height: 50px; } - 60% { height: 20px; } - 90% { height: 42px; } - } +/** + * Captions are on unless the user turns them off, and that choice follows them + * from call to call. A single stringly-typed flag is enough. + */ +const CAPTIONS_PREFERENCE_STORAGE_KEY = 'ibl.voiceChat.captionsEnabled'; - @keyframes soundWave4 { - 0%, 100% { height: 18px; } - 35% { height: 38px; } - 70% { height: 47px; } - } +const CAPTIONS_ON_BY_DEFAULT = true; - @keyframes soundWave5 { - 0%, 100% { height: 22px; } - 40% { height: 44px; } - 80% { height: 28px; } - } +/** How close to the bottom still counts as "reading the live line". */ +const CAPTION_BOTTOM_SLACK_PX = 8; + +/** + * One bubble per turn, not per transcription segment. + * + * LiveKit hands back an utterance in pieces: a single agent reply routinely + * arrives as several entries, and a caller who pauses mid-sentence produces + * two. Rendered raw that is a wall of one-line bubbles, each with its own + * avatar and name, splitting sentences at whatever moment the recogniser + * happened to flush. Consecutive entries from the same speaker are one turn, + * and the turn is what the transcript shows. + * + * The turn carries the id of its first entry — stable while the turn grows, + * so React keeps the same node — and the finality of its last, since a turn is + * still being spoken until its final piece is. + * + * Exported so the rule can be unit-tested without a DOM. + */ +export interface TranscriptTurn { + id: string; + speaker: TranscriptEntry['speaker']; + text: string; + isFinal: boolean; + participantName?: string; +} + +export function groupTranscriptTurns( + entries: TranscriptEntry[], +): TranscriptTurn[] { + const turns: TranscriptTurn[] = []; - @keyframes particlePulse1 { - 0%, 100% { transform: scale(1); opacity: 0.7; } - 50% { transform: scale(1.5); opacity: 1; } + for (const entry of entries) { + const current = turns[turns.length - 1]; + + if (current && current.speaker === entry.speaker) { + // A segment boundary is not a sentence boundary: join with a space and + // let the paragraph wrap as one. + current.text = `${current.text} ${entry.text}`.trim(); + current.isFinal = entry.isFinal; + current.participantName = + current.participantName ?? entry.participantName; + continue; + } + + turns.push({ + id: entry.id, + speaker: entry.speaker, + text: entry.text, + isFinal: entry.isFinal, + participantName: entry.participantName, + }); } - @keyframes particlePulse2 { - 0%, 100% { transform: scale(1.2); opacity: 0.8; } - 50% { transform: scale(0.8); opacity: 0.95; } + return turns; +} + +/** + * Only an explicit "false" turns captions off: storage is a nicety, never a + * dependency — server rendering has no `window`, Safari's private mode throws + * on access, and a value from some future version may be neither of ours. All + * three fall back to the default rather than to silence. + * + * Exported so the no-`window` path can be exercised directly — it is + * unreachable through a rendered component, which needs a DOM to exist. + */ +export function readCaptionsPreference(): boolean { + if (typeof window === 'undefined') return CAPTIONS_ON_BY_DEFAULT; + try { + return ( + window.localStorage.getItem(CAPTIONS_PREFERENCE_STORAGE_KEY) !== 'false' + ); + } catch { + return CAPTIONS_ON_BY_DEFAULT; } +} - @keyframes particlePulse3 { - 0%, 100% { transform: scale(0.9); opacity: 0.75; } - 50% { transform: scale(1.6); opacity: 0.9; } +export function writeCaptionsPreference(enabled: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem( + CAPTIONS_PREFERENCE_STORAGE_KEY, + enabled ? 'true' : 'false', + ); + } catch { + // A storage failure only costs us the memory of the choice; the toggle + // itself keeps working for the rest of this call. } -`; +} + +/** + * `m:ss`, and `h:mm:ss` once a call runs past the hour — the format every + * phone uses, so it needs no explaining and no translating. + * + * Exported to be tested directly; it is pure arithmetic and does not deserve a + * rendered component. + */ +export function formatCallDuration(totalSeconds: number): string { + const safeSeconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const seconds = safeSeconds % 60; + const paddedSeconds = String(seconds).padStart(2, '0'); + + if (hours === 0) return `${minutes}:${paddedSeconds}`; + return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`; +} + +/** Two letters is what the chat's own avatars fall back to. */ +function initialsOf(name: string): string { + return name.trim().substring(0, 2).toUpperCase(); +} interface VoiceChatModalProps { isOpen: boolean; onClose: () => void; - toggleMute: () => void; - isMuted: boolean; + /** Toggles the user's own microphone (outbound audio). */ + toggleMicMute: () => void; + isMicMuted: boolean; + /** Toggles playback of the mentor's voice (inbound audio). */ + toggleMentorAudio: () => void; + isMentorAudioMuted: boolean; connectionState: ConnectionState; + /** Whether the user is currently speaking. */ isSpeaking: boolean; + /** Whether the mentor agent is currently speaking. */ + isMentorSpeaking: boolean; + /** Accumulated call transcript, oldest first. */ + transcript?: TranscriptEntry[]; + /** Display name of the mentor, used to label its transcript lines. */ + mentorName?: string; + /** Mentor avatar, so a captioned turn looks like the chat it becomes. */ + mentorImage?: string; } export function VoiceChatModal({ isOpen, onClose, - toggleMute, - isMuted, + toggleMicMute, + isMicMuted, + toggleMentorAudio, + isMentorAudioMuted, connectionState, isSpeaking, + isMentorSpeaking, + transcript = [], + mentorName, + mentorImage, }: VoiceChatModalProps) { const t = useTranslations('modalsVoiceChatModal'); const isLoading = connectionState === 'requesting-permission' || connectionState === 'connecting'; const isConnected = connectionState === 'connected'; - const shouldAnimate = isConnected && !isMuted; + // The sound of the call belongs to the mentor, so the avatar is the call: + // its ring lights up while the agent is audible, and drains when silenced. + const isMentorVoiceActive = isMentorSpeaking && !isMentorAudioMuted; + // The caller's own voice is shown on the caller's own control, not on the + // mentor's face — two speakers, two places, no competing halos. + const isUserVoiceActive = isSpeaking && !isMicMuted; + + const mentorLabel = mentorName || t('transcriptSpeakerAgent'); const loadingMessage = isLoading ? connectionState === 'requesting-permission' @@ -118,178 +246,546 @@ export function VoiceChatModal({ : t('connectingToVoiceChat') : null; + // --- Call duration ------------------------------------------------------ + // A call with no clock feels like a page; a clock makes it a call. It only + // runs while the call is up and resets with the connection, so a reconnect + // never leaves a stale number ticking. + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + if (!isConnected) { + setElapsedSeconds(0); + return; + } + + const startedAt = Date.now(); + setElapsedSeconds(0); + const tick = window.setInterval( + () => setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)), + 1000, + ); + return () => window.clearInterval(tick); + }, [isConnected]); + + // --- Captions ---------------------------------------------------------- + // On by default: a call is captioned unless the user has turned them off. + // The stored preference is read after mount so the server-rendered markup + // and the first client render agree, and the default is what renders in the + // meantime — a caller who kept captions never sees them flash away. + const [areCaptionsVisible, setAreCaptionsVisible] = useState( + CAPTIONS_ON_BY_DEFAULT, + ); + + useEffect(() => { + setAreCaptionsVisible(readCaptionsPreference()); + }, []); + + const toggleCaptions = useCallback(() => { + const next = !areCaptionsVisible; + setAreCaptionsVisible(next); + writeCaptionsPreference(next); + }, [areCaptionsVisible]); + + // The whole call, in order: a transcript that only kept the last exchange + // made you take the conversation on trust the moment it moved on. + const visibleCaptions = groupTranscriptTurns(transcript); + + // The band follows the live line the way a terminal follows output: pinned + // to the bottom while new words arrive, released the moment the reader + // scrolls up to re-read the start of a long turn, and re-pinned when they + // come back down. + const captionScrollRef = useRef(null); + const [isFollowingLive, setIsFollowingLive] = useState(true); + const newestCaptionId = visibleCaptions[visibleCaptions.length - 1]?.id; + + // A new turn re-arms following: whoever has just started speaking is what + // the reader came for, and the transcript keeps everything above it anyway. + useEffect(() => { + setIsFollowingLive(true); + }, [newestCaptionId]); + + useEffect(() => { + const band = captionScrollRef.current; + if (!band || !isFollowingLive) return; + band.scrollTop = band.scrollHeight; + }, [transcript, isFollowingLive, areCaptionsVisible]); + + const handleCaptionScroll = useCallback((event: UIEvent) => { + const band = event.currentTarget; + // A few pixels of slack: sub-pixel layout and momentum scrolling rarely + // land exactly on zero, and being one pixel off must not stop the band + // following the words being spoken. + const distanceFromBottom = + band.scrollHeight - band.scrollTop - band.clientHeight; + setIsFollowingLive(distanceFromBottom <= CAPTION_BOTTOM_SLACK_PX); + }, []); + + // One line of state for the whole call, shown rather than hidden: the orb + // used to carry this visually and screen readers got a duplicate of it in an + // `sr-only` paragraph. Highest-precedence fact wins — connecting, then the + // agent being silenced, then who is talking, then the caller's own mic. + const callStatusLabel = isLoading + ? loadingMessage + : isMentorAudioMuted + ? t('agentMuted') + : isMentorSpeaking + ? t('agentSpeaking') + : isMicMuted + ? t('micMuted') + : t('listening'); + + const statusDotClass = isMentorAudioMuted + ? 'bg-destructive' + : isMentorVoiceActive + ? 'bg-blue-500' + : isMicMuted + ? 'bg-destructive' + : 'bg-emerald-500'; + + /** Circular control, in the shared Button, sized for a call toolbar. */ + const controlClass = (isActive: boolean, isDanger: boolean) => + cn( + 'size-11 rounded-full transition-all hover:scale-105 active:scale-95', + isDanger + ? 'bg-destructive/10 text-destructive hover:bg-destructive/15 hover:text-destructive' + : isActive + ? 'bg-background text-blue-600 shadow-sm hover:bg-background hover:text-blue-700' + : 'text-muted-foreground hover:bg-background hover:text-foreground', + isLoading && 'cursor-not-allowed opacity-50', + ); + return ( <> -