web+coder: segmented per-agent slash menu (agent commands + skills) + cross-agent skill execution
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>
This commit is contained in:
@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
|
||||
pane_id: z.string().min(1).max(200),
|
||||
skill_name: z.string().min(1),
|
||||
user_message: z.string().max(64_000).nullable().optional(),
|
||||
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
|
||||
// its body is injected into a dispatched task instead of native inference.
|
||||
provider: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
mode_id: z.string().max(200).optional(),
|
||||
thinking_option_id: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { pane_id, skill_name } = parsed.data;
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
|
||||
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||
}
|
||||
|
||||
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
|
||||
// stays server-side (like the native path's tool message) and is injected
|
||||
// into a dispatched task; the agent receives the skill instructions + the
|
||||
// user's text. Mirrors the messages-route external-provider dispatch.
|
||||
if (provider && provider !== 'boocode') {
|
||||
const [userMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
|
||||
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
|
||||
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
|
||||
|
||||
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||
}
|
||||
|
||||
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||
sessionId,
|
||||
chatId,
|
||||
|
||||
@@ -332,18 +332,32 @@ export const api = {
|
||||
request<CoderMessageWire[]>(
|
||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||
),
|
||||
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
|
||||
skillInvoke: (
|
||||
sessionId: string,
|
||||
paneId: string,
|
||||
skillName: string,
|
||||
userMessage: string | null,
|
||||
// v2.5.9: when the active provider is external, the skill runs under that
|
||||
// agent (body injected into a dispatched task) → response carries task_id.
|
||||
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
|
||||
) =>
|
||||
request<{
|
||||
user_message_id: string;
|
||||
assistant_message_id: string;
|
||||
synth_assistant_id: string;
|
||||
tool_message_id: string;
|
||||
assistant_message_id?: string;
|
||||
synth_assistant_id?: string;
|
||||
tool_message_id?: string;
|
||||
task_id?: string;
|
||||
dispatched?: boolean;
|
||||
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
pane_id: paneId,
|
||||
skill_name: skillName,
|
||||
user_message: userMessage,
|
||||
...(config?.provider ? { provider: config.provider } : {}),
|
||||
...(config?.model ? { model: config.model } : {}),
|
||||
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
|
||||
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
|
||||
}),
|
||||
}),
|
||||
// Queue a new-file create from the RightRail browser → BooCoder
|
||||
|
||||
@@ -24,7 +24,7 @@ import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { ContextBar } from '@/components/ContextBar';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { api } from '@/api/client';
|
||||
import type { Message } from '@/api/types';
|
||||
@@ -56,6 +56,13 @@ interface Props {
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
// disables slash-command dispatch (input is sent as literal text).
|
||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
|
||||
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
|
||||
// groups are shown instead of the BooChat skills. Invocation routing still
|
||||
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
|
||||
// through and are sent to the agent as literal text. Omitted → BooChat skills
|
||||
// (flat, unchanged — parity).
|
||||
slashGroups?: SlashCommandGroup[];
|
||||
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||
@@ -71,7 +78,7 @@ interface Props {
|
||||
modelContextLimit?: number | null;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -100,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
for (const s of skills) m.set(s.name, true);
|
||||
return m;
|
||||
}, [skills]);
|
||||
// Flat display source for the hint (and the picker's no-groups fallback):
|
||||
// caller-provided groups flattened, else the BooChat skills.
|
||||
const slashItems = useMemo(
|
||||
() =>
|
||||
slashGroups
|
||||
? slashGroups.flatMap((g) => g.items)
|
||||
: skills.map((s) => ({ name: s.name, description: s.description })),
|
||||
[slashGroups, skills],
|
||||
);
|
||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -561,8 +577,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{skills.length > 0 && (
|
||||
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} />
|
||||
{slashItems.length > 0 && (
|
||||
<AgentCommandsHint commands={slashItems} />
|
||||
)}
|
||||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
||||
inlines ContextBar in the same row so the bar lives next to the
|
||||
@@ -661,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
{slashState && (
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
items={skills}
|
||||
items={slashItems}
|
||||
groups={slashGroups}
|
||||
inputRef={textareaRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
emptyLabel="No skills available"
|
||||
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +8,10 @@ interface Props {
|
||||
agentId?: string | null;
|
||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||
onSend: (content: string) => void;
|
||||
// Slash-command (skill) send from the landing page. The parent creates the
|
||||
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
|
||||
// the skill — same transition the text send uses. See useSessionChats.
|
||||
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||
createChat: () => Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
@@ -18,6 +21,7 @@ export function SessionLandingPage({
|
||||
agentId,
|
||||
onAgentChange,
|
||||
onSend,
|
||||
onSkillInvoke,
|
||||
createChat,
|
||||
}: Props) {
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
@@ -45,14 +49,13 @@ export function SessionLandingPage({
|
||||
}
|
||||
}, [ensureChat, onSend]);
|
||||
|
||||
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
||||
try {
|
||||
const cid = await ensureChat();
|
||||
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||
}
|
||||
}, [ensureChat]);
|
||||
// Route to the parent, which creates the chat, assigns it to the pane (so the
|
||||
// pane transitions to ChatPane and subscribes to the stream), then invokes the
|
||||
// skill — mirroring the text-send transition. Doing the skill invoke locally
|
||||
// (without the pane assignment) left the landing pane stuck/blank.
|
||||
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
|
||||
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||
}, [onSkillInvoke]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -8,9 +8,18 @@ export interface SlashCommandItem {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SlashCommandGroup {
|
||||
label: string;
|
||||
items: SlashCommandItem[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
items: SlashCommandItem[];
|
||||
// Optional segmented rendering. When provided, items are shown under labeled
|
||||
// group headers (in order). `items` is ignored. BooChat passes only `items`
|
||||
// (flat) so its menu is unchanged — grouping is opt-in.
|
||||
groups?: SlashCommandGroup[];
|
||||
inputRef: RefObject<HTMLElement | null>;
|
||||
onSelect: (name: string) => void;
|
||||
onClose: () => void;
|
||||
@@ -28,6 +37,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
|
||||
export function SlashCommandPicker({
|
||||
query,
|
||||
items,
|
||||
groups,
|
||||
inputRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
@@ -35,7 +45,21 @@ export function SlashCommandPicker({
|
||||
}: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
|
||||
// When grouped, filter each group and drop empties; otherwise the flat list.
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
? groups
|
||||
.map((g) => ({ label: g.label, items: filterByPrefix(g.items, query) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
: null,
|
||||
[groups, query],
|
||||
);
|
||||
// Flat list drives keyboard nav + Enter selection across all groups.
|
||||
const filtered = useMemo(
|
||||
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
|
||||
[filteredGroups, items, query],
|
||||
);
|
||||
|
||||
const [rect, setRect] = useState<DOMRect | null>(
|
||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||
@@ -130,6 +154,36 @@ export function SlashCommandPicker({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rect, vvTick]);
|
||||
|
||||
const renderItem = (item: SlashCommandItem, i: number) => (
|
||||
<div
|
||||
key={`${i}-${item.name}`}
|
||||
role="option"
|
||||
aria-selected={i === highlightIndex}
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onClick={() => onSelect(item.name)}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||
{item.description && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
let runningIndex = -1;
|
||||
const popover = filtered.length === 0 ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
@@ -146,34 +200,16 @@ export function SlashCommandPicker({
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((item, i) => (
|
||||
<div
|
||||
key={item.name}
|
||||
role="option"
|
||||
aria-selected={i === highlightIndex}
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onClick={() => onSelect(item.name)}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||
{item.description && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
{filteredGroups
|
||||
? filteredGroups.map((g) => (
|
||||
<div key={g.label}>
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
: filtered.map((item, i) => renderItem(item, i))}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export function Workspace({
|
||||
deleteChat,
|
||||
renameChat,
|
||||
handleLandingSend,
|
||||
handleLandingSkill,
|
||||
} = chatsHook;
|
||||
|
||||
const { isMobile } = useViewport();
|
||||
@@ -387,6 +388,7 @@ export function Workspace({
|
||||
onAgentChange={onAgentChange}
|
||||
createChat={() => api.chats.create(sessionId)}
|
||||
onSend={(content) => void handleLandingSend(idx, content)}
|
||||
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -510,6 +510,31 @@ export function CoderPane({
|
||||
[displayedCommands],
|
||||
);
|
||||
|
||||
// v2.5.9: segmented slash menu — the active agent's commands first, then
|
||||
// BooCoder skills. boocode has no separate "commands" group (it IS native),
|
||||
// so it shows only Skills. Empty groups are dropped.
|
||||
const agentCommands = useMemo(
|
||||
() =>
|
||||
agentConfig.provider === 'boocode'
|
||||
? []
|
||||
: mergeCommandsByName(providerCommands, liveTaskCommands),
|
||||
[agentConfig.provider, providerCommands, liveTaskCommands],
|
||||
);
|
||||
const skillItems = useMemo(
|
||||
() => skills.map((s) => ({ name: s.name, description: s.description })),
|
||||
[skills],
|
||||
);
|
||||
const slashGroups = useMemo(() => {
|
||||
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = [];
|
||||
if (agentCommands.length > 0) {
|
||||
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands });
|
||||
}
|
||||
if (skillItems.length > 0) {
|
||||
groups.push({ label: 'Skills', items: skillItems });
|
||||
}
|
||||
return groups;
|
||||
}, [agentCommands, skillItems, agentConfig.provider]);
|
||||
|
||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||
onConnectedChange,
|
||||
onPermissionRequested: (prompt) => {
|
||||
@@ -736,19 +761,35 @@ export function CoderPane({
|
||||
|
||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||
if (!chatId) return;
|
||||
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
try {
|
||||
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
||||
// through to a literal send in ChatInput. Skills run under the active
|
||||
// provider: boocode → native inference; external → body injected into a task.
|
||||
if (!skillsByName.has(skillName)) return;
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
try {
|
||||
const data = await api.coder.skillInvoke(
|
||||
sessionId,
|
||||
paneId,
|
||||
skillName,
|
||||
userMessage.length > 0 ? userMessage : null,
|
||||
agentConfig.provider !== 'boocode'
|
||||
? {
|
||||
provider: agentConfig.provider,
|
||||
model: agentConfig.model || undefined,
|
||||
mode_id: agentConfig.modeId ?? undefined,
|
||||
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (data.task_id) setActiveTaskId(data.task_id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
|
||||
}, [chatId, sessionId, paneId, agentConfig, skillsByName]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
@@ -810,6 +851,7 @@ export function CoderPane({
|
||||
projectId={projectPath ?? ''}
|
||||
onSend={handleChatInputSend}
|
||||
onSlashCommand={handleChatInputSlash}
|
||||
slashGroups={slashGroups}
|
||||
chatId={chatId ?? undefined}
|
||||
chatLabel="BooCode"
|
||||
messages={messages as unknown as import('@/api/types').Message[]}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
|
||||
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(
|
||||
@@ -166,6 +167,25 @@ export function useSessionChats(
|
||||
}
|
||||
}, [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,
|
||||
@@ -175,5 +195,6 @@ export function useSessionChats(
|
||||
deleteChat,
|
||||
renameChat,
|
||||
handleLandingSend,
|
||||
handleLandingSkill,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user