Status indicator (StatusDot): drops the flat amber pulse for a richer set of states — orbiting amber for streaming, spinning sky ring for tool_running, static violet for waiting_for_input, plus the existing idle/error. Backend chat_status frame widens from 'working|idle|error' to discriminate streaming vs tool execution vs paused for user input. Workspace pane sync: pane layout moves from per-device localStorage to server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace broadcasts session_workspace_updated on the user channel for cross-device live sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop. Legacy localStorage seeds the server on first hydrate, then is deleted. Deprecated session_panes table dropped. Resilience: startup sweep marks any stale 'streaming' message older than 5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart. useWorkspacePanes gains validatePanes() to prune dead chatId references from saved pane state when the chat list lands.
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api/client';
|
|
import type { Chat } from '@/api/types';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
|
|
export interface UseSessionChatsOpts {
|
|
removeChatFromPanes: (chatId: string) => void;
|
|
openChatInPane: (paneIdx: number, chatId: string) => void;
|
|
// Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId);
|
|
// built by Workspace and passed in so this hook doesn't need to know
|
|
// about pane indexing.
|
|
openChatInActivePane: (chatId: string) => void;
|
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
|
validatePanes: (validChatIds: Set<string>) => void;
|
|
}
|
|
|
|
export interface UseSessionChatsResult {
|
|
chats: Chat[];
|
|
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
|
|
createChat: (paneIdx: number) => Promise<void>;
|
|
archiveChat: (chatId: string) => Promise<void>;
|
|
unarchiveChat: (chatId: string) => Promise<void>;
|
|
deleteChat: (chatId: string) => Promise<void>;
|
|
renameChat: (chatId: string, name: string) => Promise<void>;
|
|
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
|
}
|
|
|
|
export function useSessionChats(
|
|
sessionId: string,
|
|
opts: UseSessionChatsOpts,
|
|
): UseSessionChatsResult {
|
|
const [chats, setChats] = useState<Chat[]>([]);
|
|
const chatsRef = useRef<Chat[]>([]);
|
|
chatsRef.current = chats;
|
|
|
|
// Stable refs to opts callbacks so the subscription effect — which only
|
|
// re-runs on sessionId change — always sees the latest closures without
|
|
// unsubscribe/resubscribe churn.
|
|
const removeChatFromPanesRef = useRef(opts.removeChatFromPanes);
|
|
removeChatFromPanesRef.current = opts.removeChatFromPanes;
|
|
const openChatInPaneRef = useRef(opts.openChatInPane);
|
|
openChatInPaneRef.current = opts.openChatInPane;
|
|
const openChatInActivePaneRef = useRef(opts.openChatInActivePane);
|
|
openChatInActivePaneRef.current = opts.openChatInActivePane;
|
|
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
|
|
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
|
|
const validatePanesRef = useRef(opts.validatePanes);
|
|
validatePanesRef.current = opts.validatePanes;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.chats.listForSession(sessionId).then((list) => {
|
|
if (cancelled) return;
|
|
setChats(list);
|
|
validatePanesRef.current(new Set(list.map((c) => c.id)));
|
|
const openChat = list.find((c) => c.status === 'open');
|
|
if (openChat) {
|
|
initializeFirstChatIfEmptyRef.current(openChat.id);
|
|
}
|
|
}).catch(() => {});
|
|
return () => { cancelled = true; };
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
return sessionEvents.subscribe((event) => {
|
|
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
|
setChats((prev) => {
|
|
if (prev.some((c) => c.id === event.chat.id)) return prev;
|
|
return [event.chat, ...prev];
|
|
});
|
|
}
|
|
if (event.type === 'chat_updated') {
|
|
setChats((prev) => prev.map((c) =>
|
|
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
|
|
));
|
|
}
|
|
if (event.type === 'chat_archived') {
|
|
setChats((prev) => prev.map((c) =>
|
|
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
|
|
));
|
|
removeChatFromPanesRef.current(event.chat_id);
|
|
}
|
|
if (event.type === 'chat_unarchived') {
|
|
setChats((prev) => {
|
|
if (prev.some((c) => c.id === event.chat.id)) {
|
|
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
|
|
}
|
|
return [event.chat, ...prev];
|
|
});
|
|
}
|
|
if (event.type === 'chat_deleted') {
|
|
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
|
removeChatFromPanesRef.current(event.chat_id);
|
|
}
|
|
if (event.type === 'open_chat_in_active_pane') {
|
|
openChatInActivePaneRef.current(event.chat_id);
|
|
}
|
|
});
|
|
}, [sessionId]);
|
|
|
|
const createChat = useCallback(async (paneIdx: number) => {
|
|
try {
|
|
const chat = await api.chats.create(sessionId);
|
|
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
|
setChats((prev) => {
|
|
if (prev.some((c) => c.id === chat.id)) return prev;
|
|
return [chat, ...prev];
|
|
});
|
|
openChatInPaneRef.current(paneIdx, chat.id);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const archiveChat = useCallback(async (chatId: string) => {
|
|
try {
|
|
await api.chats.archive(chatId);
|
|
// Server publishes chat_archived; bus forwarder updates state.
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
|
|
}
|
|
}, []);
|
|
|
|
const unarchiveChat = useCallback(async (chatId: string) => {
|
|
try {
|
|
await api.chats.unarchive(chatId);
|
|
// Server publishes chat_unarchived.
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
|
|
}
|
|
}, []);
|
|
|
|
const deleteChat = useCallback(async (chatId: string) => {
|
|
try {
|
|
await api.chats.remove(chatId);
|
|
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
|
removeChatFromPanesRef.current(chatId);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
|
|
}
|
|
}, []);
|
|
|
|
const renameChat = useCallback(async (chatId: string, name: string) => {
|
|
try {
|
|
await api.chats.update(chatId, { name });
|
|
setChats((prev) => prev.map((c) =>
|
|
c.id === chatId ? { ...c, name } : c
|
|
));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
|
|
}
|
|
}, []);
|
|
|
|
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
|
try {
|
|
const chat = await api.chats.create(sessionId);
|
|
setChats((prev) => {
|
|
if (prev.some((c) => c.id === chat.id)) return prev;
|
|
return [chat, ...prev];
|
|
});
|
|
openChatInPaneRef.current(paneIdx, chat.id);
|
|
await api.messages.send(chat.id, content);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
|
}
|
|
}, [sessionId]);
|
|
|
|
return {
|
|
chats,
|
|
setChats,
|
|
createChat,
|
|
archiveChat,
|
|
unarchiveChat,
|
|
deleteChat,
|
|
renameChat,
|
|
handleLandingSend,
|
|
};
|
|
}
|