Skip to content
Merged
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
3 changes: 3 additions & 0 deletions components/chat/chat-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface ChatAreaProps {
onSpeechProgress?: (ratio: number | null) => void;
onThinking?: (state: { stage: string; agentId?: string } | null) => void;
onCueUser?: (fromAgentId?: string, prompt?: string) => void;
onLiveSessionError?: () => void;
onStopSession?: () => void;
onSegmentSealed?: (
messageId: string,
Expand Down Expand Up @@ -76,6 +77,7 @@ export const ChatArea = forwardRef<ChatAreaRef, ChatAreaProps>(
onSpeechProgress,
onThinking,
onCueUser,
onLiveSessionError,
onStopSession,
onSegmentSealed,
shouldHoldAfterReveal,
Expand Down Expand Up @@ -111,6 +113,7 @@ export const ChatArea = forwardRef<ChatAreaRef, ChatAreaProps>(
onThinking,
onCueUser,
onActiveBubble,
onLiveSessionError,
onStopSession,
onSegmentSealed,
shouldHoldAfterReveal,
Expand Down
157 changes: 68 additions & 89 deletions components/chat/use-chat-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface UseChatSessionsOptions {
onThinking?: (state: { stage: string; agentId?: string } | null) => void;
onCueUser?: (fromAgentId?: string, prompt?: string) => void;
onActiveBubble?: (messageId: string | null) => void;
onLiveSessionError?: () => void;
/** Called when a QA/Discussion session completes naturally (director end). */
onStopSession?: () => void;
onSegmentSealed?: (
Expand All @@ -52,6 +53,7 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
const onThinkingRef = useRef(options.onThinking);
const onCueUserRef = useRef(options.onCueUser);
const onActiveBubbleRef = useRef(options.onActiveBubble);
const onLiveSessionErrorRef = useRef(options.onLiveSessionError);
const onStopSessionRef = useRef(options.onStopSession);
const onSegmentSealedRef = useRef(options.onSegmentSealed);
const shouldHoldAfterRevealRef = useRef(options.shouldHoldAfterReveal);
Expand All @@ -61,6 +63,7 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
onThinkingRef.current = options.onThinking;
onCueUserRef.current = options.onCueUser;
onActiveBubbleRef.current = options.onActiveBubble;
onLiveSessionErrorRef.current = options.onLiveSessionError;
onStopSessionRef.current = options.onStopSession;
onSegmentSealedRef.current = options.onSegmentSealed;
shouldHoldAfterRevealRef.current = options.shouldHoldAfterReveal;
Expand All @@ -70,6 +73,7 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
options.onThinking,
options.onCueUser,
options.onActiveBubble,
options.onLiveSessionError,
options.onStopSession,
options.onSegmentSealed,
options.shouldHoldAfterReveal,
Expand Down Expand Up @@ -137,6 +141,50 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
// When true, newly created discussion/QA buffers are immediately paused.
const livePausedRef = useRef(false);

const clearLiveSessionAfterError = useCallback((sessionId: string, message: string) => {
const now = Date.now();
const errorMessageId = `error-${now}`;

const buf = buffersRef.current.get(sessionId);
if (buf) {
buf.shutdown();
buffersRef.current.delete(sessionId);
}

setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
updatedAt: now,
messages: [
...s.messages,
{
id: errorMessageId,
role: 'assistant' as const,
parts: [{ type: 'text', text: message }],
metadata: {
senderName: 'System',
originalRole: 'agent' as const,
createdAt: now,
},
},
],
}
: s,
),
);

onActiveBubbleRef.current?.(null);
if (onLiveSessionErrorRef.current) {
onLiveSessionErrorRef.current();
} else {
onSpeechProgressRef.current?.(null);
onThinkingRef.current?.(null);
onLiveSpeechRef.current?.(null, null);
}
}, []);

// Tracks the single message ID per lecture session
const lectureMessageIds = useRef<Map<string, string>>(new Map());

Expand Down Expand Up @@ -825,34 +873,9 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
return;
}
log.error('[ChatArea] Resume error:', error);

const errorMessageId = `error-${Date.now()}`;
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
messages: [
...s.messages,
{
id: errorMessageId,
role: 'assistant' as const,
parts: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
metadata: {
senderName: 'System',
originalRole: 'agent' as const,
createdAt: Date.now(),
},
},
],
}
: s,
),
clearLiveSessionAfterError(
sessionId,
`Error: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
if (abortControllerRef.current === controller) {
Expand All @@ -862,7 +885,7 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
}
}
},
[runAgentLoop],
[clearLiveSessionAfterError, runAgentLoop],
);

/**
Expand Down Expand Up @@ -1064,35 +1087,9 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
}

log.error('[ChatArea] Error:', error);

// Create error message since there's no pre-created assistant message
const errorMessageId = `error-${Date.now()}`;
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
messages: [
...s.messages,
{
id: errorMessageId,
role: 'assistant' as const,
parts: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
metadata: {
senderName: 'System',
originalRole: 'agent' as const,
createdAt: Date.now(),
},
},
],
}
: s,
),
clearLiveSessionAfterError(
sessionId!,
`Error: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
// Only clean up if this is still the active controller (avoid race with interrupt)
Expand All @@ -1103,7 +1100,15 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
}
}
},
[activeSessionId, isStreaming, createSession, endSession, runAgentLoop, t],
[
activeSessionId,
clearLiveSessionAfterError,
isStreaming,
createSession,
endSession,
runAgentLoop,
t,
],
);

/**
Expand Down Expand Up @@ -1225,35 +1230,9 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
}

log.error('[ChatArea] Discussion error:', error);

// Create error message since there's no pre-created assistant message
const errorMessageId = `error-${Date.now()}`;
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? {
...s,
messages: [
...s.messages,
{
id: errorMessageId,
role: 'assistant' as const,
parts: [
{
type: 'text',
text: `Error starting discussion: ${error instanceof Error ? error.message : String(error)}`,
},
],
metadata: {
senderName: 'System',
originalRole: 'agent' as const,
createdAt: Date.now(),
},
},
],
}
: s,
),
clearLiveSessionAfterError(
sessionId,
`Error starting discussion: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
// Only clean up if this is still the active controller (avoid race with interrupt)
Expand All @@ -1265,7 +1244,7 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from i18n context
[endSession, runAgentLoop],
[clearLiveSessionAfterError, endSession, runAgentLoop],
);

/**
Expand Down
11 changes: 11 additions & 0 deletions components/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ export function Stage({
setSpeakingAgentId(null);
setThinkingState({ stage: 'director' });
setChatIsStreaming(true);
// Transition engine back to live — onInputActivate paused it when soft-pausing,
// so we must explicitly resume to keep engine mode in sync with the chat loop.
engineRef.current?.resume();
// Fire new chat round — SSE events will drive thinking → agent_start → speech
await chatAreaRef.current?.resumeActiveSession();
}, []);
Expand Down Expand Up @@ -227,6 +230,13 @@ export function Stage({
setDiscussionTrigger(null);
}, [resetLiveState]);

/** Request failure should exit live discussion UI without hard-closing the session. */
const handleLiveSessionError = useCallback(() => {
engineRef.current?.handleDiscussionError();
resetLiveState();
setActiveBubbleId(null);
}, [resetLiveState]);

/**
* Unified session cleanup — called by both roundtable stop button and chat area end button.
* Handles: engine transition, flash, roundtable state clearing.
Expand Down Expand Up @@ -923,6 +933,7 @@ export function Stage({
onCueUser={(_fromAgentId, _prompt) => {
setIsCueUser(true);
}}
onLiveSessionError={handleLiveSessionError}
onStopSession={doSessionCleanup}
onSegmentSealed={discussionTTS.handleSegmentSealed}
shouldHoldAfterReveal={discussionTTS.shouldHold}
Expand Down
36 changes: 31 additions & 5 deletions lib/playback/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,30 @@ export class PlaybackEngine {
this.callbacks.onDiscussionEnd?.();

// Restore lecture state
if (this.savedSceneIndex !== null && this.savedActionIndex !== null) {
this.sceneIndex = this.savedSceneIndex;
this.actionIndex = this.savedActionIndex;
this.savedSceneIndex = null;
this.savedActionIndex = null;
this.restoreSavedLectureState();

this.setMode('idle');
}

/**
* Exit live discussion mode after a request failure without treating it as a
* normal discussion end. The chat session stays retryable; this only restores
* the playback engine to a coherent non-live state.
*/
handleDiscussionError(): void {
const hasSavedLectureState = this.savedSceneIndex !== null && this.savedActionIndex !== null;
const isLiveTopic =
this.mode === 'live' || (this.mode === 'paused' && this.currentTopicState === 'pending');

if (!isLiveTopic && !hasSavedLectureState) {
return;
}

this.actionEngine.clearEffects();
useCanvasStore.getState().setWhiteboardOpen(false);
this.currentTopicState = 'closed';
this.currentTrigger = null;
this.restoreSavedLectureState();
this.setMode('idle');
}

Expand Down Expand Up @@ -374,6 +391,15 @@ export class PlaybackEngine {
this.callbacks.onModeChange?.(mode);
}

private restoreSavedLectureState(): void {
if (this.savedSceneIndex !== null && this.savedActionIndex !== null) {
this.sceneIndex = this.savedSceneIndex;
this.actionIndex = this.savedActionIndex;
}
this.savedSceneIndex = null;
this.savedActionIndex = null;
}

/**
* Get the current action, or null if playback is complete.
* Advances sceneIndex automatically when a scene's actions are exhausted.
Expand Down
Loading