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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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,
|
|
};
|
|
}
|