Adds startNewSession() to useChatSession hook that closes the current session without closing the panel. New Plus button in chat header appears when a conversation exists, with shift+click to skip confirm.
213 lines
5.8 KiB
TypeScript
213 lines
5.8 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { trpc } from '@/lib/trpc';
|
|
import { useLiveUpdates } from './useLiveUpdates';
|
|
|
|
export type ChatAgentStatus = 'idle' | 'running' | 'waiting' | 'crashed' | 'none';
|
|
|
|
export interface ChatMessage {
|
|
id: string;
|
|
chatSessionId: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
changeSetId: string | null;
|
|
createdAt: string | Date;
|
|
}
|
|
|
|
export interface ChatSession {
|
|
id: string;
|
|
targetType: 'phase' | 'task';
|
|
targetId: string;
|
|
initiativeId: string;
|
|
agentId: string | null;
|
|
status: 'active' | 'closed';
|
|
messages: ChatMessage[];
|
|
}
|
|
|
|
export interface UseChatSessionResult {
|
|
session: ChatSession | null;
|
|
messages: ChatMessage[];
|
|
agentStatus: ChatAgentStatus;
|
|
agentError: string | null;
|
|
sendMessage: (message: string) => void;
|
|
retryLastMessage: () => void;
|
|
closeSession: () => void;
|
|
startNewSession: () => void;
|
|
isSending: boolean;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
/**
|
|
* Hook for managing a chat session with a phase or task.
|
|
*
|
|
* Queries the active chat session, tracks agent status,
|
|
* and provides mutations for sending messages and closing.
|
|
*/
|
|
export function useChatSession(
|
|
targetType: 'phase' | 'task',
|
|
targetId: string,
|
|
initiativeId: string,
|
|
): UseChatSessionResult {
|
|
const utils = trpc.useUtils();
|
|
|
|
// Optimistic messages shown before server confirms
|
|
const [optimisticMessages, setOptimisticMessages] = useState<ChatMessage[]>([]);
|
|
|
|
// Live updates for chat + agent events
|
|
useLiveUpdates([
|
|
{ prefix: 'chat:', invalidate: ['getChatSession'] },
|
|
{ prefix: 'agent:', invalidate: ['getChatSession', 'getAgent'] },
|
|
{ prefix: 'changeset:', invalidate: ['getChatSession'] },
|
|
]);
|
|
|
|
// Query active session
|
|
const sessionQuery = trpc.getChatSession.useQuery(
|
|
{ targetType, targetId },
|
|
{ enabled: !!targetId },
|
|
);
|
|
const session = (sessionQuery.data as ChatSession | null) ?? null;
|
|
const serverMessages = session?.messages ?? [];
|
|
|
|
// Merge: show server messages, plus any optimistic ones not yet confirmed
|
|
const serverMsgIds = new Set(serverMessages.map(m => m.id));
|
|
const messages = [
|
|
...serverMessages,
|
|
...optimisticMessages.filter(m => !serverMsgIds.has(m.id)),
|
|
];
|
|
|
|
// Query agent status if session has an agent
|
|
const agentQuery = trpc.getAgent.useQuery(
|
|
{ id: session?.agentId ?? '' },
|
|
{ enabled: !!session?.agentId },
|
|
);
|
|
|
|
const agentStatus: ChatAgentStatus = useMemo(() => {
|
|
if (!session?.agentId || !agentQuery.data) return 'none';
|
|
const status = agentQuery.data.status;
|
|
switch (status) {
|
|
case 'running':
|
|
return 'running';
|
|
case 'waiting_for_input':
|
|
return 'waiting';
|
|
case 'idle':
|
|
return 'idle';
|
|
case 'crashed':
|
|
return 'crashed';
|
|
default:
|
|
return 'none';
|
|
}
|
|
}, [session?.agentId, agentQuery.data]);
|
|
|
|
// Extract error message when agent crashed
|
|
const agentError = useMemo(() => {
|
|
if (agentStatus !== 'crashed' || !agentQuery.data) return null;
|
|
const result = (agentQuery.data as { result?: string }).result;
|
|
if (!result) return 'Agent crashed unexpectedly';
|
|
try {
|
|
const parsed = JSON.parse(result);
|
|
return parsed.message ?? result;
|
|
} catch {
|
|
return result;
|
|
}
|
|
}, [agentStatus, agentQuery.data]);
|
|
|
|
// Send message mutation
|
|
const sendMutation = trpc.sendChatMessage.useMutation({
|
|
onSuccess: () => {
|
|
void utils.getChatSession.invalidate({ targetType, targetId });
|
|
// Clear optimistic messages once server confirms
|
|
setOptimisticMessages([]);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(`Chat failed: ${err.message}`);
|
|
// Remove optimistic messages on error
|
|
setOptimisticMessages([]);
|
|
},
|
|
});
|
|
|
|
// Close session mutation
|
|
const closeMutation = trpc.closeChatSession.useMutation({
|
|
onSuccess: () => {
|
|
void utils.getChatSession.invalidate({ targetType, targetId });
|
|
},
|
|
onError: (err) => {
|
|
toast.error(`Failed to close chat: ${err.message}`);
|
|
},
|
|
});
|
|
|
|
const sendMutateRef = useRef(sendMutation.mutate);
|
|
sendMutateRef.current = sendMutation.mutate;
|
|
const closeMutateRef = useRef(closeMutation.mutate);
|
|
closeMutateRef.current = closeMutation.mutate;
|
|
const sessionRef = useRef(session);
|
|
sessionRef.current = session;
|
|
|
|
const sendMessage = useCallback(
|
|
(message: string) => {
|
|
// Add optimistic user message immediately
|
|
const optimistic: ChatMessage = {
|
|
id: `opt-${Date.now()}`,
|
|
chatSessionId: '',
|
|
role: 'user',
|
|
content: message,
|
|
changeSetId: null,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
setOptimisticMessages(prev => [...prev, optimistic]);
|
|
|
|
sendMutateRef.current({
|
|
targetType,
|
|
targetId,
|
|
initiativeId,
|
|
message,
|
|
});
|
|
},
|
|
[targetType, targetId, initiativeId],
|
|
);
|
|
|
|
const messagesRef = useRef(messages);
|
|
messagesRef.current = messages;
|
|
|
|
const retryLastMessage = useCallback(() => {
|
|
const lastUserMsg = [...messagesRef.current].reverse().find(m => m.role === 'user');
|
|
if (!lastUserMsg) return;
|
|
sendMutateRef.current({
|
|
targetType,
|
|
targetId,
|
|
initiativeId,
|
|
message: lastUserMsg.content,
|
|
retry: true,
|
|
});
|
|
}, [targetType, targetId, initiativeId]);
|
|
|
|
const closeSession = useCallback(() => {
|
|
const s = sessionRef.current;
|
|
if (s) {
|
|
closeMutateRef.current({ sessionId: s.id });
|
|
}
|
|
}, []);
|
|
|
|
const startNewSession = useCallback(() => {
|
|
const s = sessionRef.current;
|
|
if (!s) return;
|
|
closeMutateRef.current({ sessionId: s.id });
|
|
setOptimisticMessages([]);
|
|
}, []);
|
|
|
|
const isLoading = sessionQuery.isLoading;
|
|
const isSending = sendMutation.isPending;
|
|
|
|
return {
|
|
session,
|
|
messages,
|
|
agentStatus,
|
|
agentError,
|
|
sendMessage,
|
|
retryLastMessage,
|
|
closeSession,
|
|
startNewSession,
|
|
isSending,
|
|
isLoading,
|
|
};
|
|
}
|