Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
7.4 KiB
TypeScript
201 lines
7.4 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>;
|
|
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => 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]);
|
|
|
|
// Slash-command equivalent of handleLandingSend: the initial (landing) chat
|
|
// must create the chat AND assign it to the pane (openChatInPane) before
|
|
// invoking the skill, so the pane transitions to ChatPane and subscribes to
|
|
// the chat's stream. Skipping the assignment left the pane stuck on the
|
|
// landing page while the skill ran invisibly (and could blank the pane).
|
|
const handleLandingSkill = useCallback(
|
|
async (paneIdx: number, skillName: string, userMessage: string | null) => {
|
|
try {
|
|
const chat = await api.chats.create(sessionId);
|
|
setChats((prev) => (prev.some((c) => c.id === chat.id) ? prev : [chat, ...prev]));
|
|
openChatInPaneRef.current(paneIdx, chat.id);
|
|
await api.chats.skillInvoke(chat.id, skillName, userMessage);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
|
}
|
|
},
|
|
[sessionId],
|
|
);
|
|
|
|
return {
|
|
chats,
|
|
setChats,
|
|
createChat,
|
|
archiveChat,
|
|
unarchiveChat,
|
|
deleteChat,
|
|
renameChat,
|
|
handleLandingSend,
|
|
handleLandingSkill,
|
|
};
|
|
}
|