File size: 4,945 Bytes
27da720
 
 
 
564aab6
 
27da720
3b6a2ca
27da720
 
 
 
 
962191f
27da720
 
 
 
 
 
 
 
 
 
f56fa2e
962191f
 
27da720
ff8c636
27da720
 
 
 
 
 
 
f56fa2e
 
 
27da720
f56fa2e
 
 
 
 
27da720
f56fa2e
 
d7f2a7c
f56fa2e
 
d7f2a7c
f56fa2e
27da720
f56fa2e
 
 
 
27da720
3b6a2ca
887da19
 
3b6a2ca
 
887da19
a13e8cc
 
 
 
27da720
 
a13e8cc
27da720
3b6a2ca
27da720
 
 
2e60856
27da720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f56fa2e
27da720
 
 
 
 
 
 
 
 
a13e8cc
2a2e170
27da720
 
0611031
27da720
962191f
 
 
 
 
 
 
ff8c636
 
962191f
 
 
 
 
 
 
 
 
27da720
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
 * Per-session chat component.
 *
 * Each session renders its own SessionChat. The hook (useAgentChat) always
 * runs — processing events — but only the active session renders visible
 * UI (MessageList + ChatInput).
 */
import { useCallback, useEffect } from 'react';
import { useAgentChat } from '@/hooks/useAgentChat';
import { useAgentStore } from '@/store/agentStore';
import { useSessionStore } from '@/store/sessionStore';
import MessageList from '@/components/Chat/MessageList';
import ChatInput from '@/components/Chat/ChatInput';
import ExpiredBanner from '@/components/Chat/ExpiredBanner';
import { apiFetch } from '@/utils/api';
import { logger } from '@/utils/logger';

interface SessionChatProps {
  sessionId: string;
  isActive: boolean;
  onSessionDead: (sessionId: string) => void;
}

export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) {
  const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
  const { updateSessionTitle, sessions } = useSessionStore();
  const isExpired = sessions.find((s) => s.id === sessionId)?.expired === true;

  const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools, declineBlockedJobs, continueBlockedJobsWithNamespace } = useAgentChat({
    sessionId,
    isActive,
    onReady: () => logger.log(`Session ${sessionId} ready`),
    onError: (error) => logger.error(`Session ${sessionId} error:`, error),
    onSessionDead,
  });

  // When this session becomes active, restore its per-session state to the
  // global flat fields. The per-session state map is kept up-to-date by
  // side-channel callbacks even while the session is in the background.
  useEffect(() => {
    if (isActive) {
      useAgentStore.getState().switchActiveSession(sessionId);
      useAgentStore.getState().setConnected(true);
    }
  }, [isActive, sessionId]);

  // Re-sync state when the browser tab regains focus (Chrome throttles
  // timers in background tabs which can stall the AI SDK's update flushing).
  // Fires for ALL sessions so background sessions also recover after sleep.
  useEffect(() => {
    const onVisible = () => {
      if (document.visibilityState === 'visible' && isActive) {
        useAgentStore.getState().switchActiveSession(sessionId);
      }
    };
    document.addEventListener('visibilitychange', onVisible);
    return () => document.removeEventListener('visibilitychange', onVisible);
  }, [isActive, sessionId]);

  // Wrap stop to show cancelled shimmer
  const handleStop = useCallback(() => {
    stop();
    updateSession(sessionId, { activityStatus: { type: 'cancelled' } });
  }, [stop, updateSession, sessionId]);

  // SDK status is the ground truth — if it's streaming/submitted, agent is busy
  const sdkBusy = status === 'streaming' || status === 'submitted';
  const busy = isProcessing || sdkBusy;

  const handleSendMessage = useCallback(
    async (text: string) => {
      if (!text.trim() || busy) return;

      updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } });
      sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });

      // Auto-title the session from the first user message
      const isFirstMessage = messages.filter((m) => m.role === 'user').length === 0;
      if (isFirstMessage) {
        apiFetch('/api/title', {
          method: 'POST',
          body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
        })
          .then((res) => res.json())
          .then((data) => {
            if (data.title) updateSessionTitle(sessionId, data.title);
          })
          .catch(() => {
            const raw = text.trim();
            updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw);
          });
      }
    },
    [sessionId, sendMessage, messages, updateSessionTitle, busy, updateSession],
  );

  // Don't render UI for background sessions — hooks still run
  if (!isActive) return null;

  return (
    <>
      <MessageList
        messages={messages}
        isProcessing={busy}
        sessionId={sessionId}
        approveTools={approveTools}
        onUndoLastTurn={undoLastTurn}
        onEditAndRegenerate={editAndRegenerate}
      />
      {isExpired ? (
        <ExpiredBanner sessionId={sessionId} />
      ) : (
        <ChatInput
          sessionId={sessionId}
          onSend={handleSendMessage}
          onStop={handleStop}
          onDeclineBlockedJobs={declineBlockedJobs}
          onContinueBlockedJobsWithNamespace={continueBlockedJobsWithNamespace}
          isProcessing={busy}
          disabled={!isConnected || activityStatus.type === 'waiting-approval'}
          placeholder={
            activityStatus.type === 'waiting-approval'
              ? 'Approve or reject pending tools first...'
              : undefined
          }
        />
      )}
    </>
  );
}