Compare commits

..

4 Commits

Author SHA1 Message Date
dc3859975d coder(providers): capture + persist opencode's live ACP commands (no dispatch needed)
The cold ACP probe captured available_commands but read probedCommands synchronously right after newSession, racing opencode's async available_commands_update notification -> captured nothing, only the static manifest showed. The probe now waits (poll <=3s + 300ms settle) for the notification. Captured commands persist to a new available_agents.commands column and are served (merged with the manifest) on the tier-2-skip path, so the agent's discovered commands survive once models are warm and show without a dispatch. Boot warms via the force:true startup snapshot. Caveat: relies on opencode emitting available_commands_update on session creation, not only post-prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:56:18 +00:00
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
fe52250d78 coder(providers): fix empty picker (loading-state) + config model overrides + current Claude models
Fix: getProviderSnapshot returned synchronous installed:false 'loading' entries on a cache miss (v2.5.5/Phase 2), which AgentComposerBar filters out — with the Phase 5 client poll not yet built, a single fetch stranded on 'loading' and the picker showed no providers. It now awaits the build and returns terminal entries; the sync loading-return is deferred until Phase 5. Builds stay fast via the tier-2 cold-probe skip.

Feature: wire the v2.3 config schema's models/additionalModels — buildResolvedRegistry carries them onto ResolvedProviderDef (models replace, additionalModels merge) and provider-snapshot applies them to every ready model list, so /data/coder-providers.json can edit any provider's models with no code change. Claude staticModels bumped from the stale 2-entry list to opus/sonnet/haiku latest-aliases + pinned claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5-20251001 (passed verbatim to claude --model). +2 tests (109 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:37:01 +00:00
17 changed files with 405 additions and 123 deletions

View File

@@ -2,6 +2,22 @@
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.10-opencode-live-commands — 2026-05-29
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
## 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
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`.
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29 ## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`. Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.

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

@@ -66,6 +66,10 @@ CREATE OR REPLACE VIEW human_inbox AS
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty'; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
-- an agent's live command set survives the tier-2 probe skip and shows without a
-- dispatch.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
-- v2.2.0: Paseo-style session config on tasks. -- v2.2.0: Paseo-style session config on tasks.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;

View File

@@ -61,6 +61,22 @@ describe('buildResolvedRegistry', () => {
warn.mockRestore(); warn.mockRestore();
}); });
it('carries config models + additionalModels onto built-in and custom defs', () => {
const reg = buildResolvedRegistry(PROVIDERS, {
providers: {
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
'amp-acp': {
extends: 'acp',
label: 'Amp',
command: ['amp-acp'],
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
},
},
});
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
});
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => { it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} }); const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
expect(reg.size).toBe(PROVIDERS.length); expect(reg.size).toBe(PROVIDERS.length);

View File

@@ -293,6 +293,37 @@ describe('getProviderSnapshot', () => {
expect(boocode?.installed).toBe(true); expect(boocode?.installed).toBe(true);
}); });
it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
loadConfigFixture({
claude: {
models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
},
});
const sql = mockSql([
{
name: 'claude',
install_path: '/usr/bin/claude',
supports_acp: false,
models: [{ id: 'old-static', label: 'Old' }],
label: 'Claude Code',
transport: 'pty',
last_probed_at: new Date().toISOString(),
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const claude = entries.find((e) => e.name === 'claude');
const ids = claude!.models.map((m) => m.id);
expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
expect(ids).toContain('sonnet'); // additionalModels merged on top
expect(ids).not.toContain('old-static'); // replaced, not appended
// thinking options still attach to the config-provided models
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
});
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => { it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
loadConfigFixture({}); loadConfigFixture({});
mockProbe.mockResolvedValue({ mockProbe.mockResolvedValue({

View File

@@ -130,6 +130,17 @@ export async function probeAcpProvider(
}); });
const session = await connection.newSession({ cwd, mcpServers: [] }); const session = await connection.newSession({ cwd, mcpServers: [] });
// available_commands_update is an async session notification opencode sends
// shortly AFTER newSession resolves — reading probedCommands synchronously
// here races it and captures nothing. Wait briefly for the first batch, then
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
const deadline = Date.now() + 3_000;
while (probedCommands.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 150));
}
if (probedCommands.length > 0) {
await new Promise((r) => setTimeout(r, 300));
}
const result = parseSessionResponse(session, agent); const result = parseSessionResponse(session, agent);
result.commands = probedCommands; result.commands = probedCommands;
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {}); await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});

View File

@@ -22,6 +22,10 @@ export interface ResolvedProviderDef extends ProviderDef {
env: Record<string, string> | undefined; env: Record<string, string> | undefined;
configLabel?: string; configLabel?: string;
configDescription?: string; configDescription?: string;
/** Config `models` — REPLACES the discovered/static model list when present. */
configModels?: Array<{ id: string; label: string }>;
/** Config `additionalModels` — MERGED on top of the resolved model list. */
configAdditionalModels?: Array<{ id: string; label: string }>;
} }
/** /**
@@ -61,6 +65,8 @@ export function buildResolvedRegistry(
env: ov?.env, env: ov?.env,
configLabel: ov?.label, configLabel: ov?.label,
configDescription: ov?.description, configDescription: ov?.description,
configModels: ov?.models,
configAdditionalModels: ov?.additionalModels,
}); });
} }
@@ -87,6 +93,8 @@ export function buildResolvedRegistry(
env: ov.env, env: ov.env,
configLabel: ov.label, configLabel: ov.label,
configDescription: ov.description, configDescription: ov.description,
configModels: ov.models,
configAdditionalModels: ov.additionalModels,
}); });
} }

View File

@@ -41,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
label: 'Claude Code', label: 'Claude Code',
transport: 'pty', transport: 'pty',
modelSource: 'static', modelSource: 'static',
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
// exact version. Extend/replace per-install via data/coder-providers.json
// (models / additionalModels) without a code change.
staticModels: [ staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' }, { id: 'opus', label: 'Opus (latest)' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' }, { id: 'claude-opus-4-8', label: 'Opus 4.8' },
{ id: 'sonnet', label: 'Sonnet (latest)' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'haiku', label: 'Haiku (latest)' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
], ],
}, },
{ {

View File

@@ -11,7 +11,7 @@ import {
PROVIDER_MANIFEST, PROVIDER_MANIFEST,
} from './provider-manifest.js'; } from './provider-manifest.js';
import { probeAcpProvider } from './acp-probe.js'; import { probeAcpProvider } from './acp-probe.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js'; import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
@@ -22,6 +22,7 @@ interface AgentRow {
install_path: string | null; install_path: string | null;
supports_acp: boolean; supports_acp: boolean;
models: ProviderModel[] | null; models: ProviderModel[] | null;
commands: AgentCommand[] | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at: string | Date | null; last_probed_at: string | Date | null;
@@ -82,9 +83,22 @@ async function buildProviderEntry(
const fallbackModes = getManifestModes(name); const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name); const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name); const manifestCommands = getManifestCommands(name);
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
// the agent's discovered commands show even when the tier-2 probe is skipped.
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label; const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {}; const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
// MERGES on top. Applied to every ready/installed model list below.
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
out = mergeModels(out, resolved.configAdditionalModels);
}
return out;
};
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP. // ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
let transport = resolved.transport; let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) { if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
@@ -104,7 +118,7 @@ async function buildProviderEntry(
if (isNative) { if (isNative) {
return { return {
name, label: resolved.label, transport, status: 'ready', name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: llamaModels, modes: [], enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands, defaultModeId: null, commands: manifestCommands,
}; };
} }
@@ -137,7 +151,7 @@ async function buildProviderEntry(
if (name === 'claude') { if (name === 'claude') {
return { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(models), modes: fallbackModes, defaultModeId, models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: manifestCommands, commands: manifestCommands,
}; };
} }
@@ -165,7 +179,7 @@ async function buildProviderEntry(
} }
return { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models: skipModels, modes: fallbackModes, defaultModeId, commands: manifestCommands, models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
}; };
} }
@@ -188,7 +202,7 @@ async function buildProviderEntry(
name, label, transport, name, label, transport,
status: probe.ok ? 'ready' : 'error', status: probe.ok ? 'ready' : 'error',
enabled: true, installed: true, enabled: true, installed: true,
models: probeModels, models: withConfigModels(probeModels),
modes: probe.modes.length > 0 ? probe.modes : fallbackModes, modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId, defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(manifestCommands, probe.commands), commands: mergeCommands(manifestCommands, probe.commands),
@@ -203,27 +217,10 @@ async function buildProviderEntry(
} }
return { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models, modes: fallbackModes, defaultModeId, commands: manifestCommands, models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
}; };
} }
/** Synchronous placeholder entries for a cache-miss while the build runs (§4.4). */
function loadingEntries(): ProviderSnapshotEntry[] {
return [...getResolvedRegistry().values()].map((r) => ({
name: r.id,
label: r.configLabel ?? r.label,
...(r.configDescription ? { description: r.configDescription } : {}),
transport: r.transport,
status: r.enabled ? ('loading' as const) : ('unavailable' as const),
enabled: r.enabled,
installed: false,
models: [],
modes: getManifestModes(r.id),
defaultModeId: getManifestDefaultModeId(r.id),
commands: getManifestCommands(r.id),
}));
}
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>(); const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>(); const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
const CACHE_TTL_MS = 5 * 60_000; const CACHE_TTL_MS = 5 * 60_000;
@@ -249,7 +246,7 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => { const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config); const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>` const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
`; `;
const agentMap = new Map(agents.map((a) => [a.name, a])); const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS; const ttlMs = config.PROVIDER_PROBE_TTL_MS;
@@ -269,14 +266,13 @@ export async function getProviderSnapshot(
}); });
snapshotInflight.set(cacheKey, promise); snapshotInflight.set(cacheKey, promise);
// force → await the full build (cold probes included). Non-force cache miss → // Await the build (force or cache-miss) and return terminal entries. The sync
// return loading entries synchronously; the build settles in the background // `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
// and the next call returns it via cache / inflight (§4.4; client polls). // poll that resolves it: without that poll, a single fetch lands on
if (force) return promise; // installed:false `loading` entries, which AgentComposerBar filters out
promise.catch(() => { // (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
/* settled errors surface on the next call that awaits inflight/rebuilds */ // once available_agents.models is warm.
}); return promise;
return loadingEntries();
} }
export function clearProviderSnapshotCache(): void { export function clearProviderSnapshotCache(): void {
@@ -292,16 +288,34 @@ export async function persistProbedModels(
): Promise<void> { ): Promise<void> {
let count = 0; let count = 0;
for (const entry of entries) { for (const entry of entries) {
if (entry.name === 'boocode' || entry.models.length === 0) continue; if (entry.name === 'boocode') continue;
let persisted = false;
if (entry.models.length > 0) {
const flatModels = entry.models.map(({ id, label }) => ({ id, label })); const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
await sql` await sql`
UPDATE available_agents UPDATE available_agents
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp() SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name} WHERE name = ${entry.name}
`; `;
count++; persisted = true;
}
// Persist captured ACP commands so they survive the tier-2 probe skip and
// show without a dispatch. Only when non-empty — never clobber a prior set.
if (entry.commands.length > 0) {
const flatCommands = entry.commands.map((c) => ({
name: c.name,
...(c.description ? { description: c.description } : {}),
}));
await sql`
UPDATE available_agents
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
persisted = true;
}
if (persisted) count++;
} }
if (count > 0) { if (count > 0) {
log.info({ count }, 'provider-snapshot: persisted models to available_agents'); log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
} }
} }

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,
}; };
} }