fix(panels): eliminate getSession() from transcription store and cabinets panel

Same GoTrueClient lock contention fix as CCFilesPanel:
- transcriptionStore: add _accessToken/_userId state + setAuthInfo() action;
  replace all 6 getSession() calls (startSession, flushCanvasEvents, loadSessions,
  loadKeywordWatches, addKeywordWatch, deleteKeywordWatch, checkSegmentForKeywords)
  with stored values — zero getSession() calls remain in the store
- CCTranscriptionPanel: destructure accessToken from useAuth; sync both values into
  store via setAuthInfo() on every auth change; gate loadSessions on authUser.id
- CCCabinetsPanel: same pattern as CCFilesPanel — useAuth for token, useCallback on
  apiFetch/loadCabinets, gate effect on authUser.id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kcar 2026-05-25 15:13:37 +01:00
parent 6bd85671d2
commit 3a65cf436b
3 changed files with 51 additions and 31 deletions

View File

@ -130,6 +130,11 @@ interface TranscriptionState {
keywordWatches: KeywordWatch[]; keywordWatches: KeywordWatch[];
keywordMatches: KeywordMatch[]; keywordMatches: KeywordMatch[];
// Auth (set by panel via setAuthInfo after SIGNED_IN)
_accessToken: string | null;
_userId: string | null;
setAuthInfo: (token: string | null, userId: string | null) => void;
// Actions // Actions
startSession: (timetableTag?: TimetablePeriod) => Promise<void>; startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
stopSession: () => Promise<void>; stopSession: () => Promise<void>;
@ -167,6 +172,8 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
activeSession: null, activeSession: null,
_accessToken: null,
_userId: null,
completedSegments: [], completedSegments: [],
serverWindow: [], serverWindow: [],
currentSegment: null, currentSegment: null,
@ -195,19 +202,23 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
set({ timetableContext: context }); set({ timetableContext: context });
}, },
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
startSession: async (timetableTag?: TimetablePeriod) => { startSession: async (timetableTag?: TimetablePeriod) => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
// Create session in Supabase // Create session in Supabase
try { try {
const { data: sessionData_auth } = await supabase.auth.getSession(); const { _accessToken: token, _userId: userId } = get();
if (!sessionData_auth.session?.user) { if (!token || !userId) {
console.error('No authenticated user'); console.error('No authenticated user');
return; return;
} }
const sessionData = { const sessionData = {
user_id: sessionData_auth.session.user.id, user_id: userId,
title: timetableTag?.event_label || 'Untitled Session', title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas', canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null, timetable_period_id: timetableTag?.period_id || null,
@ -465,7 +476,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
for (const event of eventsToFlush) { for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({ await supabase.from('canvas_events').insert({
session_id: activeSession?.id || null, session_id: activeSession?.id || null,
user_id: (await supabase.auth.getSession()).data.session?.user?.id || '', user_id: get()._userId || '',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null, session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType, event_type: event.eventType,
@ -484,13 +495,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
loadSessions: async (): Promise<TranscriptionSession[]> => { loadSessions: async (): Promise<TranscriptionSession[]> => {
try { try {
const { data: sessionData_auth } = await supabase.auth.getSession(); const { _userId: userId } = get();
if (!sessionData_auth.session?.user) return []; if (!userId) return [];
const { data, error } = await supabase const { data, error } = await supabase
.from('transcription_sessions') .from('transcription_sessions')
.select('*') .select('*')
.eq('user_id', sessionData_auth.session.user.id) .eq('user_id', userId)
.order('started_at', { ascending: false }) .order('started_at', { ascending: false })
.limit(50); .limit(50);
@ -584,11 +595,11 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
loadKeywordWatches: async () => { loadKeywordWatches: async () => {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { _accessToken: token } = get();
if (!session?.access_token) return; if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
headers: { 'Authorization': `Bearer ${session.access_token}` }, headers: { 'Authorization': `Bearer ${token}` },
}); });
if (!response.ok) return; if (!response.ok) return;
const watches = await response.json(); const watches = await response.json();
@ -600,17 +611,17 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
addKeywordWatch: async (keyword: string) => { addKeywordWatch: async (keyword: string) => {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { _accessToken: token, _userId: userId } = get();
if (!session?.access_token || !session.user) return; if (!token || !userId) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
user_id: session.user.id, user_id: userId,
keyword: keyword.trim(), keyword: keyword.trim(),
match_type: 'contains', match_type: 'contains',
action: 'alert', action: 'alert',
@ -626,12 +637,12 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
deleteKeywordWatch: async (watchId: string) => { deleteKeywordWatch: async (watchId: string) => {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { _accessToken: token } = get();
if (!session?.access_token) return; if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': `Bearer ${session.access_token}` }, headers: { 'Authorization': `Bearer ${token}` },
}); });
set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) }));
} catch (error) { } catch (error) {
@ -667,13 +678,13 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
if (activeSession) { if (activeSession) {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { _accessToken: _kwToken } = get();
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(session?.access_token ? { 'Authorization': `Bearer ${session.access_token}` } : {}), ...(_kwToken ? { 'Authorization': `Bearer ${_kwToken}` } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: activeSession.id, session_id: activeSession.id,

View File

@ -1,17 +1,18 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ThemeProvider, createTheme, useMediaQuery, Box, Grid, Card, CardContent, CardActions, Typography, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, styled } from '@mui/material'; import { ThemeProvider, createTheme, useMediaQuery, Box, Grid, Card, CardContent, CardActions, Typography, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, styled } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import { useTLDraw } from '../../../../../contexts/TLDrawContext'; import { useTLDraw } from '../../../../../contexts/TLDrawContext';
import { supabase } from '../../../../../supabaseClient'; import { useAuth } from '../../../../../contexts/AuthContext';
type Cabinet = { id: string; name: string }; type Cabinet = { id: string; name: string };
const Toolbar = styled('div')(() => ({ display: 'flex', gap: '8px', marginBottom: '8px' })); const Toolbar = styled('div')(() => ({ display: 'flex', gap: '8px', marginBottom: '8px' }));
export const CCCabinetsPanel: React.FC = () => { export const CCCabinetsPanel: React.FC = () => {
const { tldrawPreferences, authToken } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' }, authToken?: string }; const { tldrawPreferences } = useTLDraw() as { tldrawPreferences?: { colorScheme?: 'light' | 'dark' | 'system' } };
const { user: authUser, accessToken } = useAuth();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [cabinets, setCabinets] = useState<Cabinet[]>([]); const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -28,27 +29,29 @@ export const CCCabinetsPanel: React.FC = () => {
const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined; type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined;
const apiFetch = async (url: string, init?: RequestInitLite) => { const apiFetch = useCallback(async (url: string, init?: RequestInitLite) => {
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const { data: { session } } = await supabase.auth.getSession();
const bearer = session?.access_token || authToken || '';
const res = await fetch(fullUrl, { const res = await fetch(fullUrl, {
...init, ...init,
headers: { headers: {
'Authorization': `Bearer ${bearer}`, 'Authorization': `Bearer ${accessToken || ''}`,
...(init?.headers || {}) ...(init?.headers || {})
} }
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
}; }, [accessToken, API_BASE]);
const loadCabinets = async () => { const loadCabinets = useCallback(async () => {
const data = await apiFetch('/database/cabinets'); const data = await apiFetch('/database/cabinets');
setCabinets([...(data.owned || []), ...(data.shared || [])]); setCabinets([...(data.owned || []), ...(data.shared || [])]);
}; }, [apiFetch]);
useEffect(() => { loadCabinets(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, []); useEffect(() => {
if (authUser?.id) {
loadCabinets();
}
}, [loadCabinets, authUser?.id]);
const handleCreate = async () => { const handleCreate = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;

View File

@ -67,6 +67,7 @@ export const CCTranscriptionPanel: React.FC = () => {
flushCanvasEvents, flushCanvasEvents,
loadSessions, loadSessions,
setTimetableContext, setTimetableContext,
setAuthInfo,
llmConfig, llmConfig,
summaryText, summaryText,
isGeneratingSummary, isGeneratingSummary,
@ -93,7 +94,7 @@ export const CCTranscriptionPanel: React.FC = () => {
// Modal state // Modal state
const [showSettingsModal, setShowSettingsModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false);
const { user: authUser } = useAuth(); const { user: authUser, accessToken } = useAuth();
const [showSummaryModal, setShowSummaryModal] = useState(false); const [showSummaryModal, setShowSummaryModal] = useState(false);
const [summaryType, setSummaryType] = useState('full_lesson'); const [summaryType, setSummaryType] = useState('full_lesson');
@ -101,6 +102,11 @@ export const CCTranscriptionPanel: React.FC = () => {
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const [isAddingKeyword, setIsAddingKeyword] = useState(false); const [isAddingKeyword, setIsAddingKeyword] = useState(false);
// Sync access token into Zustand store so all store actions can use it without getSession()
useEffect(() => {
setAuthInfo(accessToken, authUser?.id ?? null);
}, [accessToken, authUser?.id, setAuthInfo]);
// Load sessions when auth is confirmed (avoids GoTrueClient lock on mount) // Load sessions when auth is confirmed (avoids GoTrueClient lock on mount)
useEffect(() => { useEffect(() => {
if (authUser?.id) { if (authUser?.id) {