Compare commits

...

2 Commits

Author SHA1 Message Date
23a33e893a 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>
2026-05-29 14:38:39 +00:00
8bf86ecb92 web(coder): keep composer refresh on the top line + icon-only Mode picker on mobile
The AgentComposerBar refresh button wrapped to a second line on mobile: the status dot had ml-auto (pinned to the far-right edge) and the refresh button followed it in DOM order, overflowing past the edge. Group the dot + refresh into one right-aligned (ml-auto) unit so the refresh stays on the top line. Also add an iconOnly option to CompactPicker and render the Mode (permission) picker icon-only on mobile (shield + chevron, no label; aria-label/title + tap-to-open list still convey the selection) to free row width. Desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:46:40 +00:00
10 changed files with 261 additions and 80 deletions

View File

@@ -2,6 +2,14 @@
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. 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.
## v2.5.7-claude-models-and-picker-fix — 2026-05-29 ## v2.5.7-claude-models-and-picker-fix — 2026-05-29
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`. Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.

View File

@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
pane_id: z.string().min(1).max(200), pane_id: z.string().min(1).max(200),
skill_name: z.string().min(1), skill_name: z.string().min(1),
user_message: z.string().max(64_000).nullable().optional(), 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 { interface InferenceApi {
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
} }
const sessionId = req.params.sessionId; const sessionId = req.params.sessionId;
const { pane_id, skill_name } = parsed.data; const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
const sessionRows = await sql<{ id: string }[]>` const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId} SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
reply.code(404); reply.code(404);
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; 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, { const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId, sessionId,
chatId, chatId,

View File

@@ -332,18 +332,32 @@ export const api = {
request<CoderMessageWire[]>( request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, `/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<{ request<{
user_message_id: string; user_message_id: string;
assistant_message_id: string; assistant_message_id?: string;
synth_assistant_id: string; synth_assistant_id?: string;
tool_message_id: string; tool_message_id?: string;
task_id?: string;
dispatched?: boolean;
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, { }>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
pane_id: paneId, pane_id: paneId,
skill_name: skillName, skill_name: skillName,
user_message: userMessage, 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 // Queue a new-file create from the RightRail browser → BooCoder

View File

@@ -92,9 +92,11 @@ interface PickerProps {
options: Array<{ id: string; label: string }>; options: Array<{ id: string; label: string }>;
onPick: (id: string) => void; onPick: (id: string) => void;
icon?: React.ReactNode; icon?: React.ReactNode;
/** Mobile: render icon + chevron only (no value label) to save row width. */
iconOnly?: boolean;
} }
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) { function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label); const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
@@ -129,7 +131,7 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40" className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
> >
{icon} {icon}
<span className="truncate max-w-[120px]">{currentLabel}</span> {!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
<ChevronDown className="size-3 opacity-70 shrink-0" /> <ChevronDown className="size-3 opacity-70 shrink-0" />
</button> </button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}> <BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
@@ -290,6 +292,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
options={modeOptions} options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })} onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />} icon={<Shield className="size-3 shrink-0" />}
iconOnly
/> />
<CompactPicker <CompactPicker
label="Model" label="Model"
@@ -308,9 +311,12 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
icon={<Brain className="size-3 shrink-0" />} icon={<Brain className="size-3 shrink-0" />}
/> />
)} )}
{/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{connected !== undefined && ( {connected !== undefined && (
<span <span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')} className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'} title={connected ? 'Connected' : 'Disconnected'}
/> />
)} )}
@@ -318,12 +324,13 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
type="button" type="button"
onClick={() => void handleRefresh()} onClick={() => void handleRefresh()}
disabled={refreshing} disabled={refreshing}
className={cn('inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40', connected === undefined && 'ml-auto')} className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label="Refresh provider list" aria-label="Refresh provider list"
title="Refresh providers" title="Refresh providers"
> >
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} /> <RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button> </button>
</div> </div>
</div>
); );
} }

View File

@@ -24,7 +24,7 @@ import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker'; import { AgentPicker } from '@/components/AgentPicker';
import { AgentCommandsHint } from '@/components/AgentCommandsHint'; import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { ContextBar } from '@/components/ContextBar'; 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 { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Message } from '@/api/types'; import type { Message } from '@/api/types';
@@ -56,6 +56,13 @@ interface Props {
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text). // disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>; 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 // v1.10.4: send-to-chat reverse path. When chatId is provided, this input
// registers in chatInputsRegistry so the terminal floating menu can list // registers in chatInputsRegistry so the terminal floating menu can list
// it, and subscribes to sendToChat events scoped to this chatId. Receiving // it, and subscribes to sendToChat events scoped to this chatId. Receiving
@@ -71,7 +78,7 @@ interface Props {
modelContextLimit?: number | null; 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 { isMobile } = useViewport();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); 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); for (const s of skills) m.set(s.name, true);
return m; return m;
}, [skills]); }, [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 [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -561,8 +577,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
))} ))}
</div> </div>
)} )}
{skills.length > 0 && ( {slashItems.length > 0 && (
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} /> <AgentCommandsHint commands={slashItems} />
)} )}
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1 {/* 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 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 && ( {slashState && (
<SlashCommandPicker <SlashCommandPicker
query={slashState.query} query={slashState.query}
items={skills} items={slashItems}
groups={slashGroups}
inputRef={textareaRef} inputRef={textareaRef}
onSelect={handleSlashSelect} onSelect={handleSlashSelect}
onClose={() => setSlashState(null)} onClose={() => setSlashState(null)}
emptyLabel="No skills available" emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
interface Props { interface Props {
@@ -9,6 +8,10 @@ interface Props {
agentId?: string | null; agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>; onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => 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 }>; createChat: () => Promise<{ id: string }>;
} }
@@ -18,6 +21,7 @@ export function SessionLandingPage({
agentId, agentId,
onAgentChange, onAgentChange,
onSend, onSend,
onSkillInvoke,
createChat, createChat,
}: Props) { }: Props) {
const [chatId, setChatId] = useState<string | null>(null); const [chatId, setChatId] = useState<string | null>(null);
@@ -45,14 +49,13 @@ export function SessionLandingPage({
} }
}, [ensureChat, onSend]); }, [ensureChat, onSend]);
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { // Route to the parent, which creates the chat, assigns it to the pane (so the
try { // pane transitions to ChatPane and subscribes to the stream), then invokes the
const cid = await ensureChat(); // skill — mirroring the text-send transition. Doing the skill invoke locally
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null); // (without the pane assignment) left the landing pane stuck/blank.
} catch (err) { const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`); onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
} }, [onSkillInvoke]);
}, [ensureChat]);
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">

View File

@@ -8,9 +8,18 @@ export interface SlashCommandItem {
description?: string; description?: string;
} }
export interface SlashCommandGroup {
label: string;
items: SlashCommandItem[];
}
interface Props { interface Props {
query: string; query: string;
items: SlashCommandItem[]; 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>; inputRef: RefObject<HTMLElement | null>;
onSelect: (name: string) => void; onSelect: (name: string) => void;
onClose: () => void; onClose: () => void;
@@ -28,6 +37,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
export function SlashCommandPicker({ export function SlashCommandPicker({
query, query,
items, items,
groups,
inputRef, inputRef,
onSelect, onSelect,
onClose, onClose,
@@ -35,7 +45,21 @@ export function SlashCommandPicker({
}: Props) { }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0); const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null); 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>( const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null, () => inputRef.current?.getBoundingClientRect() ?? null,
@@ -130,25 +154,9 @@ export function SlashCommandPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]); }, [rect, vvTick]);
const popover = filtered.length === 0 ? ( const renderItem = (item: SlashCommandItem, i: number) => (
<div <div
ref={popoverRef} key={`${i}-${item.name}`}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No command starts with "/${query}"` : emptyLabel}
</div>
</div>
) : (
<div
ref={popoverRef}
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" role="option"
aria-selected={i === highlightIndex} aria-selected={i === highlightIndex}
data-highlighted={i === highlightIndex} data-highlighted={i === highlightIndex}
@@ -173,7 +181,35 @@ export function SlashCommandPicker({
</div> </div>
)} )}
</div> </div>
))} );
let runningIndex = -1;
const popover = filtered.length === 0 ? (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No command starts with "/${query}"` : emptyLabel}
</div>
</div>
) : (
<div
ref={popoverRef}
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}
>
{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>
))
: filtered.map((item, i) => renderItem(item, i))}
</div> </div>
); );

View File

@@ -82,6 +82,7 @@ export function Workspace({
deleteChat, deleteChat,
renameChat, renameChat,
handleLandingSend, handleLandingSend,
handleLandingSkill,
} = chatsHook; } = chatsHook;
const { isMobile } = useViewport(); const { isMobile } = useViewport();
@@ -387,6 +388,7 @@ export function Workspace({
onAgentChange={onAgentChange} onAgentChange={onAgentChange}
createChat={() => api.chats.create(sessionId)} createChat={() => api.chats.create(sessionId)}
onSend={(content) => void handleLandingSend(idx, content)} onSend={(content) => void handleLandingSend(idx, content)}
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
/> />
)} )}
</div> </div>

View File

@@ -510,6 +510,31 @@ export function CoderPane({
[displayedCommands], [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, { const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange, onConnectedChange,
onPermissionRequested: (prompt) => { onPermissionRequested: (prompt) => {
@@ -736,19 +761,35 @@ export function CoderPane({
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => { const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return; if (!chatId) return;
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) { // 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); setSending(true);
setPermissionPrompt(null); setPermissionPrompt(null);
setLiveTaskCommands([]); setLiveTaskCommands([]);
try { try {
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null); 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) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed'); toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally { } finally {
setSending(false); setSending(false);
} }
} }, [chatId, sessionId, paneId, agentConfig, skillsByName]);
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
return ( return (
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
@@ -810,6 +851,7 @@ export function CoderPane({
projectId={projectPath ?? ''} projectId={projectPath ?? ''}
onSend={handleChatInputSend} onSend={handleChatInputSend}
onSlashCommand={handleChatInputSlash} onSlashCommand={handleChatInputSlash}
slashGroups={slashGroups}
chatId={chatId ?? undefined} chatId={chatId ?? undefined}
chatLabel="BooCode" chatLabel="BooCode"
messages={messages as unknown as import('@/api/types').Message[]} messages={messages as unknown as import('@/api/types').Message[]}

View File

@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
deleteChat: (chatId: string) => Promise<void>; deleteChat: (chatId: string) => Promise<void>;
renameChat: (chatId: string, name: string) => Promise<void>; renameChat: (chatId: string, name: string) => Promise<void>;
handleLandingSend: (paneIdx: number, content: string) => Promise<void>; handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise<void>;
} }
export function useSessionChats( export function useSessionChats(
@@ -166,6 +167,25 @@ export function useSessionChats(
} }
}, [sessionId]); }, [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 { return {
chats, chats,
setChats, setChats,
@@ -175,5 +195,6 @@ export function useSessionChats(
deleteChat, deleteChat,
renameChat, renameChat,
handleLandingSend, handleLandingSend,
handleLandingSkill,
}; };
} }