From 23a33e893a3ea61e130661e9c0e4eac9b3342d63 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 14:38:39 +0000 Subject: [PATCH] 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) --- CHANGELOG.md | 4 + apps/coder/src/routes/skills.ts | 37 +++++++- apps/web/src/api/client.ts | 22 ++++- apps/web/src/components/ChatInput.tsx | 29 ++++-- .../web/src/components/SessionLandingPage.tsx | 21 +++-- .../web/src/components/SlashCommandPicker.tsx | 92 +++++++++++++------ apps/web/src/components/Workspace.tsx | 2 + apps/web/src/components/panes/CoderPane.tsx | 66 ++++++++++--- apps/web/src/hooks/useSessionChats.ts | 21 +++++ 9 files changed, 232 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c4f59..024e175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.5.9-agent-slash-commands — 2026-05-29 + +Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean. + ## v2.5.8-mobile-composer-row — 2026-05-29 Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change. diff --git a/apps/coder/src/routes/skills.ts b/apps/coder/src/routes/skills.ts index c195d41..aa3519d 100644 --- a/apps/coder/src/routes/skills.ts +++ b/apps/coder/src/routes/skills.ts @@ -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, diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 713209f..1e3fbfb 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -332,18 +332,32 @@ export const api = { request( `/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 diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index 74ab6e3..8581430 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -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; + // 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(null); const textareaRef = useRef(null); @@ -561,8 +577,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session ))} )} - {skills.length > 0 && ( - ({ name: s.name, description: s.description }))} /> + {slashItems.length > 0 && ( + )} {/* 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 && ( setSlashState(null)} - emptyLabel="No skills available" + emptyLabel={slashGroups ? 'No commands available' : 'No skills available'} /> )} diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index 4601271..dddd4de 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -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; 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(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 (
diff --git a/apps/web/src/components/SlashCommandPicker.tsx b/apps/web/src/components/SlashCommandPicker.tsx index 8aa085f..fde77b1 100644 --- a/apps/web/src/components/SlashCommandPicker.tsx +++ b/apps/web/src/components/SlashCommandPicker.tsx @@ -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; 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(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( () => 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) => ( +
setHighlightIndex(i)} + onClick={() => onSelect(item.name)} + > +
/{item.name}
+ {item.description && ( +
+ {item.description} +
+ )} +
+ ); + + let runningIndex = -1; const popover = filtered.length === 0 ? (
- {filtered.map((item, i) => ( -
setHighlightIndex(i)} - onClick={() => onSelect(item.name)} - > -
/{item.name}
- {item.description && ( -
- {item.description} + {filteredGroups + ? filteredGroups.map((g) => ( +
+
+ {g.label} +
+ {g.items.map((item) => renderItem(item, (runningIndex += 1)))}
- )} -
- ))} + )) + : filtered.map((item, i) => renderItem(item, i))}
); diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index f1bf64f..ebb9c9a 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -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)} /> )}
diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index 437bd63..4a9415b 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -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 (
@@ -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[]} diff --git a/apps/web/src/hooks/useSessionChats.ts b/apps/web/src/hooks/useSessionChats.ts index a1b565b..ffc5fa9 100644 --- a/apps/web/src/hooks/useSessionChats.ts +++ b/apps/web/src/hooks/useSessionChats.ts @@ -24,6 +24,7 @@ export interface UseSessionChatsResult { deleteChat: (chatId: string) => Promise; renameChat: (chatId: string, name: string) => Promise; handleLandingSend: (paneIdx: number, content: string) => Promise; + handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise; } 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, }; }