fix(panels): resolve sidebar refresh bugs — getUser→getSession, files double-call, hardcoded IP

- transcriptionStore: replace all supabase.auth.getUser() with getSession() so session
  restoration on page refresh does not race against GoTrue network validation
- CCFilesPanel: remove selectedCabinet from loadCabinets useCallback deps; use
  initialSelectionDone ref to prevent double-call on first mount
- CCTranscriptionPanel: replace hardcoded LAN IP with VITE_API_URL env var

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-25 13:41:25 +00:00
co-authored by Claude Sonnet 4.6
parent fb1795fd2b
commit 5284d30f84
3 changed files with 502 additions and 163 deletions
+173 -31
View File
@@ -23,6 +23,12 @@ export interface TranscriptionSession {
segment_count: number;
}
export interface ServerSegment {
text: string;
start: number;
end: number;
}
export interface TimetablePeriod {
period_id: string | null;
event_type: string | null;
@@ -35,6 +41,8 @@ export interface LLMConfig {
provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google';
model: string;
apiKey: string;
baseUrl?: string; // for Ollama: e.g. https://ollama.kevlarai.com
whisperModel?: string; // faster-whisper model size sent to WhisperLive
}
export type ExportFormat = 'srt' | 'txt' | 'json';
@@ -72,6 +80,8 @@ function loadLLMConfig(): LLMConfig {
provider: 'openai',
model: '',
apiKey: '',
baseUrl: '',
whisperModel: 'large-v3',
};
}
@@ -90,8 +100,9 @@ interface TranscriptionState {
activeSession: TranscriptionSession | null;
// Live feed
completedSegments: TranscriptionSegment[];
currentSegment: TranscriptionSegment | null;
completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived)
serverWindow: ServerSegment[]; // the current server-provided segment window (last N)
currentSegment: TranscriptionSegment | null; // the live (last) segment if still being refined
// Canvas event buffer (flushed to API every 5s)
pendingCanvasEvents: any[];
@@ -122,6 +133,7 @@ interface TranscriptionState {
// Actions
startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
stopSession: () => Promise<void>;
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
resetSession: () => void;
tickElapsed: () => void;
@@ -156,6 +168,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isConnecting: false,
activeSession: null,
completedSegments: [],
serverWindow: [],
currentSegment: null,
pendingCanvasEvents: [],
timetableContext: null,
@@ -187,14 +200,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
// Create session in Supabase
try {
const user = await supabase.auth.getUser();
if (!user.data.user) {
const { data: sessionData_auth } = await supabase.auth.getSession();
if (!sessionData_auth.session?.user) {
console.error('No authenticated user');
return;
}
const sessionData = {
user_id: user.data.user.id,
user_id: sessionData_auth.session.user.id,
title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null,
@@ -221,7 +234,32 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
},
stopSession: async () => {
const { activeSession, completedSegments } = get();
const { activeSession, currentSegment, completedSegments } = get();
// The live segment (currentSegment) was never added to completedSegments — flush it now.
let newCompleted = [...completedSegments];
if (currentSegment && currentSegment.text.trim()) {
const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5);
if (!alreadyIn) {
const idx = newCompleted.length;
newCompleted.push({ ...currentSegment, isFinal: true });
if (activeSession) {
supabase.from('transcription_segments').insert({
session_id: activeSession.id,
sequence_index: idx,
text: currentSegment.text,
start_seconds: currentSegment.start,
end_seconds: currentSegment.end,
is_final: true,
}).then(({ error }) => { if (error) console.error('Failed to save live segment on stop:', error); });
}
}
}
const finalWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
if (activeSession) {
try {
@@ -229,8 +267,8 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
.from('transcription_sessions')
.update({
ended_at: new Date().toISOString(),
word_count: get().wordCount,
segment_count: completedSegments.length,
word_count: finalWordCount,
segment_count: newCompleted.length,
})
.eq('id', activeSession.id);
} catch (error) {
@@ -242,35 +280,121 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false,
isConnecting: false,
activeSession: null,
completedSegments: newCompleted,
serverWindow: [],
currentSegment: null,
wordCount: finalWordCount,
});
},
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => {
const { completedSegments, activeSession } = get();
if (segments.length === 0) return;
// The server marks every finalized segment with completed=true and the live
// one with completed=false. Rather than relying on window-scroll detection
// (which can miss segments when the server creates several at once), we
// directly merge every completed segment from this message into the store.
// This guarantees no gaps: any segment the server says is complete is captured
// immediately, regardless of how many were created since the last message.
const serverCompleted = isLastLive ? segments.slice(0, -1) : segments;
let newCompleted = [...completedSegments];
const toSave: Array<{ seg: ServerSegment; idx: number }> = [];
for (const seg of serverCompleted) {
if (!seg.text.trim()) continue;
const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5);
if (existingIdx >= 0) {
// Server refined an existing segment — update text and end time in place.
newCompleted[existingIdx] = {
...newCompleted[existingIdx],
text: seg.text,
end: seg.end,
};
} else {
const newIdx = newCompleted.length;
newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end });
toSave.push({ seg, idx: newIdx });
}
}
// Keep sorted by start time so display order is always correct.
newCompleted.sort((a, b) => a.start - b.start);
// Persist and keyword-check only truly new segments.
if (toSave.length > 0) {
const elapsed = get().elapsedSeconds;
for (const { seg, idx } of toSave) {
if (activeSession) {
supabase.from('transcription_segments').insert({
session_id: activeSession.id,
sequence_index: idx,
text: seg.text,
start_seconds: seg.start,
end_seconds: seg.end,
is_final: true,
}).then(({ error }) => { if (error) console.error('Failed to save segment:', error); });
}
get().checkSegmentForKeywords(seg.text, elapsed);
}
}
const lastSeg = segments[segments.length - 1];
const newCurrentSegment: TranscriptionSegment | null = isLastLive
? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end }
: null;
const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({
serverWindow: segments,
completedSegments: newCompleted,
currentSegment: newCurrentSegment,
wordCount: newWordCount,
});
},
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
const { completedSegments, currentSegment, activeSession, wordCount } = get();
const { completedSegments, currentSegment, activeSession } = get();
if (isFinal) {
// Final segment — append the finalized text directly (not currentSegment, which
// may lag behind or duplicate when WhisperLive re-sends the full segments array).
const newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
// Deduplicate by start time: if a segment with this start already exists, update it
// rather than appending. This prevents doubles when the stability timer fires and
// the segment later appears in the server's finalized list with a slightly extended end.
const existingIdx = completedSegments.findIndex(
(s) => Math.abs(s.start - metadata.start) < 0.5
);
let newCompleted: TranscriptionSegment[];
let isNew: boolean;
if (existingIdx >= 0) {
newCompleted = completedSegments.map((s, i) =>
i === existingIdx ? { text, isFinal: true, ...metadata } : s
);
isNew = false;
} else {
newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
isNew = true;
}
const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({
completedSegments: newCompleted,
currentSegment: null,
wordCount: newWordCount,
});
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
// Save to Supabase if session is active
if (activeSession) {
if (isNew && activeSession) {
try {
const sequenceIndex = newCompleted.length - 1;
await supabase.from('transcription_segments').insert({
session_id: activeSession.id,
sequence_index: sequenceIndex,
text: text,
sequence_index: newCompleted.length - 1,
text,
start_seconds: metadata.start,
end_seconds: metadata.end,
is_final: true,
@@ -280,7 +404,26 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
}
}
} else {
// In-progress segment
// In-progress segment. If the start time jumped to a new position, the previous
// live segment is done — auto-commit it before switching.
if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) {
const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }];
const autoWordCount = autoCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: autoCompleted, wordCount: autoWordCount });
if (activeSession) {
supabase.from('transcription_segments').insert({
session_id: activeSession.id,
sequence_index: autoCompleted.length - 1,
text: currentSegment.text,
start_seconds: currentSegment.start,
end_seconds: currentSegment.end,
is_final: true,
}).then(({ error }) => { if (error) console.error('Failed to save auto-committed segment:', error); });
}
}
set({ currentSegment: { text, isFinal: false, ...metadata } });
}
},
@@ -290,6 +433,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false,
isConnecting: false,
completedSegments: [],
serverWindow: [],
currentSegment: null,
wordCount: 0,
elapsedSeconds: 0,
@@ -321,7 +465,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({
session_id: activeSession?.id || null,
user_id: (await supabase.auth.getUser()).data.user?.id || '',
user_id: (await supabase.auth.getSession()).data.session?.user?.id || '',
timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType,
@@ -340,13 +484,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
loadSessions: async (): Promise<TranscriptionSession[]> => {
try {
const user = await supabase.auth.getUser();
if (!user.data.user) return [];
const { data: sessionData_auth } = await supabase.auth.getSession();
if (!sessionData_auth.session?.user) return [];
const { data, error } = await supabase
.from('transcription_sessions')
.select('*')
.eq('user_id', user.data.user.id)
.eq('user_id', sessionData_auth.session.user.id)
.order('started_at', { ascending: false })
.limit(50);
@@ -457,9 +601,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
addKeywordWatch: async (keyword: string) => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) return;
const user = await supabase.auth.getUser();
if (!user.data.user) return;
if (!session?.access_token || !session.user) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
method: 'POST',
@@ -468,7 +610,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({
user_id: user.data.user.id,
user_id: session.user.id,
keyword: keyword.trim(),
match_type: 'contains',
action: 'alert',