Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/core/audio/audio-recorder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/app/features/discovery/data/discovery-chat.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuggestionResponse>([]));
http.match(() => true).forEach((r) => r.flush(page<never>([])));
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 {
Expand Down
51 changes: 28 additions & 23 deletions src/app/features/discovery/data/discovery-chat.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
10 changes: 2 additions & 8 deletions src/app/features/discovery/data/discovery.models.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
Loading