diff --git a/src/app/core/audio/audio-recorder.service.ts b/src/app/core/audio/audio-recorder.service.ts index fbc1cb0..603a70b 100644 --- a/src/app/core/audio/audio-recorder.service.ts +++ b/src/app/core/audio/audio-recorder.service.ts @@ -132,6 +132,17 @@ export class AudioRecorderService { } if (this.webSocket) { + // Flush the sub-chunk tail (< CHUNK_SIZE samples) before closing, so the last + // ~100ms of speech — often the end of the final sentence — reaches the STT. + if (this.webSocket.readyState === WebSocket.OPEN && this.pcmBufferAccumulator.length > 0) { + const tail = this.pcmBufferAccumulator; + const buffer = new ArrayBuffer(tail.length * 2); + const view = new DataView(buffer); + for (let i = 0; i < tail.length; i++) { + view.setInt16(i * 2, tail[i], true); // little-endian + } + this.webSocket.send(buffer); + } if ( this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING diff --git a/src/app/features/discovery/data/discovery-chat.store.spec.ts b/src/app/features/discovery/data/discovery-chat.store.spec.ts index ee476e2..df0a180 100644 --- a/src/app/features/discovery/data/discovery-chat.store.spec.ts +++ b/src/app/features/discovery/data/discovery-chat.store.spec.ts @@ -360,6 +360,27 @@ describe('DiscoveryChatStore', () => { expect(store.blocks()).toHaveLength(0); }); + it('warns and ignores an unknown realtime message type instead of failing silently', () => { + flushInit([session()]); + http + .expectOne((r) => r.url === '/api/projects/proj-1/suggestions') + .flush(page([])); + http.match(() => true).forEach((r) => r.flush(page([]))); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + store.applyRealtime({ + sessionId: 's-1', + type: 'BRAND_NEW_EVENT', + occurredAt: new Date().toISOString(), + } as never); + + expect(warnSpy).toHaveBeenCalledWith( + '[discovery] Unhandled realtime message type:', + 'BRAND_NEW_EVENT', + ); + warnSpy.mockRestore(); + }); + describe('decide', () => { /** Boots one completed session whose queue holds a single pending suggestion. */ function setupWithPending(): SuggestionResponse { diff --git a/src/app/features/discovery/data/discovery-chat.store.ts b/src/app/features/discovery/data/discovery-chat.store.ts index ae3d117..112791c 100644 --- a/src/app/features/discovery/data/discovery-chat.store.ts +++ b/src/app/features/discovery/data/discovery-chat.store.ts @@ -840,30 +840,35 @@ export class DiscoveryChatStore { } const status = STATUS_BY_EVENT[message.type]; - if (status) { - const error = - message.type === 'FAILED' ? (message as SessionProcessingFailedMessage).reason : null; - this.updateBlock(sessionId, (b) => ({ - ...b, - session: { - ...b.session, - status, - processingError: error ?? b.session.processingError, - }, - })); - this.recording.syncStatus(sessionId, status); - // Keep the project-level live flag in sync even when the lifecycle - // reaches us only through the per-session topic. - if (status === 'RECORDING' || status === 'PAUSED') { - this._liveSessionId.set(sessionId); - } else if (this._liveSessionId() === sessionId) { - this._liveSessionId.set(null); - } - // A block created while the session was live never fetched its persisted - // timeline. Once the session settles, reload it as a historical block so - // segments and resolved decisions survive exactly like after a reload. - if (HISTORICAL_STATUSES.includes(status)) this.upgradeToHistorical(sessionId); + if (!status) { + // A type this build doesn't know (backend shipped a new event first). Log it so a + // schema drift is visible in the console instead of a silently dead feature. + console.warn('[discovery] Unhandled realtime message type:', message.type); + return; + } + + const error = + message.type === 'FAILED' ? (message as SessionProcessingFailedMessage).reason : null; + this.updateBlock(sessionId, (b) => ({ + ...b, + session: { + ...b.session, + status, + processingError: error ?? b.session.processingError, + }, + })); + this.recording.syncStatus(sessionId, status); + // Keep the project-level live flag in sync even when the lifecycle + // reaches us only through the per-session topic. + if (status === 'RECORDING' || status === 'PAUSED') { + this._liveSessionId.set(sessionId); + } else if (this._liveSessionId() === sessionId) { + this._liveSessionId.set(null); } + // A block created while the session was live never fetched its persisted + // timeline. Once the session settles, reload it as a historical block so + // segments and resolved decisions survive exactly like after a reload. + if (HISTORICAL_STATUSES.includes(status)) this.upgradeToHistorical(sessionId); } /** diff --git a/src/app/features/discovery/data/discovery.models.ts b/src/app/features/discovery/data/discovery.models.ts index 3ba0b71..d1f0658 100644 --- a/src/app/features/discovery/data/discovery.models.ts +++ b/src/app/features/discovery/data/discovery.models.ts @@ -1,13 +1,7 @@ /** Mirrors the discovery REST + realtime contracts (backend discovery context). */ export type SessionStatus = - | 'DRAFT' - | 'RECORDING' - | 'PAUSED' - | 'STOPPED' - | 'PROCESSING' - | 'COMPLETED' - | 'FAILED'; + 'DRAFT' | 'RECORDING' | 'PAUSED' | 'STOPPED' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; export interface DiscoverySessionResponse { id: string; @@ -431,11 +425,11 @@ export function editableToAcceptRequest( // ---- Realtime (STOMP topic /topic/sessions/{id}) ---- export type SessionEventType = + | 'SESSION_CREATED' | 'RECORDING_STARTED' | 'RECORDING_PAUSED' | 'RECORDING_RESUMED' | 'RECORDING_STOPPED' - | 'SESSION_RESET' | 'TRANSCRIPT_SEGMENT' | 'TRANSCRIPT_UPLOADED' | 'PROCESSING' diff --git a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts index 57da0e0..a12ab6e 100644 --- a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts +++ b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts @@ -32,6 +32,7 @@ import { lucideX, } from '@ng-icons/lucide'; import { AuthStore } from '../../../../core/auth/auth.store'; +import { PermissionsStore } from '../../../../core/authz/permissions.store'; import { WorkspaceStore } from '../../../workspace/data/workspace.store'; import { ToastService } from '../../../../shared/toast/toast.service'; import { messageForError } from '../../../../core/errors/error-message'; @@ -592,6 +593,7 @@ export class DiscoveryChat implements OnInit { protected readonly recording = inject(SessionRecordingService); protected readonly recorder = inject(AudioRecorderService); private readonly auth = inject(AuthStore); + private readonly permissions = inject(PermissionsStore); private readonly workspace = inject(WorkspaceStore); private readonly toast = inject(ToastService); private readonly transloco = inject(TranslocoService); @@ -646,15 +648,13 @@ export class DiscoveryChat implements OnInit { ); /** Owner/admin gate reused from the workspace pages (fine-grained perms not client-side yet). */ - protected readonly canManage = computed(() => { - const user = this.auth.user(); - if (!user) return false; - const orgId = this.auth.organizationId(); - const org = this.workspace.organizations().find((o) => o.id === orgId); - return org?.ownerId === user.id; - }); - protected readonly canRecord = this.canManage; - protected readonly canDecide = this.canManage; + // Gate the live controls by the caller's PROJECT permissions (owner/admin bypass is built + // into PermissionsStore.has()), not org ownership — an admin or a member whose project role + // grants SESSION_RUN/SESSION_DECIDE must be able to run and decide, while a read-only viewer + // sees the session without the record/accept controls (and never hits a spurious 403 toast). + // The SESSION_READ route guard has already awaited the project permission load before we render. + protected readonly canRecord = computed(() => this.permissions.has('SESSION_RUN')); + protected readonly canDecide = computed(() => this.permissions.has('SESSION_DECIDE')); /** * Meeting language for the next session, editable until recording starts.