diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..644212d32b3 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1792,17 +1792,25 @@ impl AcpClient { } "available_commands_update" => { // Advertised slash commands (ACP slash-commands extension). - // Logged for observability; UI surfacing is a follow-up. - let names: Vec<&str> = update["availableCommands"] - .as_array() - .map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect()) - .unwrap_or_default(); + // Forward the complete latest list through the encrypted observer + // stream so Desktop can offer commands for the originating agent. + let Some(commands) = update["availableCommands"].as_array() else { + return false; + }; + let names: Vec<&str> = commands + .iter() + .filter_map(|command| command["name"].as_str()) + .collect(); tracing::info!( target: "acp::update", "available_commands_update: {} commands [{}]", names.len(), names.join(", ") ); + self.observe( + "available_commands_captured", + serde_json::json!({ "commands": commands }), + ); false } "session_info_update" => { @@ -3749,6 +3757,53 @@ mod tests { }) } + #[tokio::test] + async fn available_commands_update_emits_complete_semantic_snapshot() { + let mut client = spawn_inert_client().await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 3); + + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "test-session", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "review", "description": "Review changes" }, + { "name": "deploy", "description": "Ship it" } + ] + } + } + }); + let _ = client.handle_session_update(&msg); + + let events = observer.snapshot(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, "available_commands_captured"); + assert_eq!(events[0].agent_index, Some(3)); + assert_eq!( + events[0].payload, + serde_json::json!({ + "commands": [ + { "name": "review", "description": "Review changes" }, + { "name": "deploy", "description": "Ship it" } + ] + }) + ); + let mut empty = msg.clone(); + empty["params"]["update"]["availableCommands"] = serde_json::json!([]); + let _ = client.handle_session_update(&empty); + let events = observer.snapshot(); + assert_eq!(events.len(), 2); + assert_eq!(events[1].payload, serde_json::json!({ "commands": [] })); + + empty["params"]["update"]["availableCommands"] = serde_json::Value::Null; + let _ = client.handle_session_update(&empty); + assert_eq!(observer.snapshot().len(), 2); + } + #[tokio::test] async fn active_run_id_sets_on_string() { let mut client = spawn_inert_client().await; diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index aad2580dad0..fa13f68fba7 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -77,6 +77,7 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/slash-command-autocomplete.spec.ts", "**/mention-spacing.spec.ts", "**/mention-clipboard.spec.ts", "**/cloud-provenance.spec.ts", diff --git a/desktop/src/features/agents/agentCommandCatalog.test.mjs b/desktop/src/features/agents/agentCommandCatalog.test.mjs new file mode 100644 index 00000000000..affee78dd76 --- /dev/null +++ b/desktop/src/features/agents/agentCommandCatalog.test.mjs @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getAgentCommandCatalog, + initAgentCommandCatalog, + resetAgentCommandCatalog, + parseAvailableCommandsPayload, + recordAvailableCommandsUpdate, + resetAgentCommandCatalogForTests, +} from "./agentCommandCatalog.ts"; + +const OWNER = "aa".repeat(32); +const OTHER_OWNER = "bb".repeat(32); +const AGENT = "cc".repeat(32); + +function installLocalStorage() { + const values = new Map(); + globalThis.window = { + localStorage: { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }, + }; +} + +describe("agent command catalog", () => { + beforeEach(() => { + installLocalStorage(); + resetAgentCommandCatalogForTests(); + initAgentCommandCatalog("test-community"); + }); + + it("sanitizes, bounds, and deduplicates advertised commands", () => { + const commands = parseAvailableCommandsPayload({ + commands: [ + { name: "/review", description: " Review changes " }, + { name: "REVIEW", description: "duplicate" }, + { name: "bad name" }, + { name: "deploy", description: 42 }, + ], + }); + + assert.deepEqual(commands, [ + { name: "review", description: "Review changes" }, + { name: "deploy", description: null }, + ]); + }); + + it("keeps the latest complete command list per owner and agent", () => { + assert.equal( + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 8, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review", description: "Review" }] }, + }), + true, + ); + assert.equal( + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 7, + timestamp: "2026-07-23T07:00:00Z", + payload: { commands: [{ name: "stale" }] }, + }), + false, + ); + + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, [ + { name: "review", description: "Review" }, + ]); + assert.equal(getAgentCommandCatalog(OTHER_OWNER).has(AGENT), false); + }); + + it("rejects hidden controls in command names and removes them from descriptions", () => { + assert.deepEqual( + parseAvailableCommandsPayload({ + commands: [ + { name: "rev\u0000iew" }, + { name: "rev\u202eiew" }, + { name: "rev\u200biew" }, + { name: "review", description: "a\u001bb\u202ec" }, + ], + }), + [{ name: "review", description: "a b c" }], + ); + }); + + it("enforces count, name, and description bounds", () => { + assert.equal( + parseAvailableCommandsPayload({ + commands: Array.from({ length: 300 }, (_, i) => ({ name: `cmd-${i}` })), + }).length, + 256, + ); + assert.deepEqual( + parseAvailableCommandsPayload({ commands: [{ name: "a".repeat(129) }] }), + [], + ); + assert.equal( + parseAvailableCommandsPayload({ + commands: [{ name: "review", description: "a".repeat(600) }], + })[0].description.length, + 512, + ); + }); + + it("ignores malformed snapshots and uses sequence to break timestamp ties", () => { + const timestamp = "2026-07-23T08:00:00Z"; + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 2, + timestamp, + payload: { commands: [{ name: "review" }] }, + }); + for (const event of [ + { seq: 3, timestamp, payload: {} }, + { seq: 3, timestamp: "invalid", payload: { commands: [] } }, + { seq: 1, timestamp, payload: { commands: [] } }, + ]) + assert.equal(recordAvailableCommandsUpdate(OWNER, AGENT, event), false); + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT).commands, [ + { name: "review", description: null }, + ]); + }); + + it("re-sanitizes persisted commands and tolerates unavailable storage", () => { + window.localStorage.setItem( + `buzz-agent-command-catalog.v1:test-community:${OWNER}`, + JSON.stringify({ + version: 1, + agents: { + [AGENT]: { + commands: [{ name: "bad\u202e" }, { name: "review" }], + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + }, + }, + }), + ); + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT).commands, [ + { name: "review", description: null }, + ]); + resetAgentCommandCatalogForTests(); + initAgentCommandCatalog("test-community"); + window.localStorage.getItem = () => { + throw new Error("storage disabled"); + }; + assert.equal(getAgentCommandCatalog(OWNER).size, 0); + }); + + it("treats an empty update as authoritative removal of prior commands", () => { + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review" }] }, + }); + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 2, + timestamp: "2026-07-23T08:01:00Z", + payload: { commands: [] }, + }); + + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, []); + }); + + it("hydrates a persisted owner-scoped catalog after restart", () => { + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 3, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review" }] }, + }); + resetAgentCommandCatalogForTests(); + initAgentCommandCatalog("test-community"); + + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, [ + { name: "review", description: null }, + ]); + }); + + it("isolates the same owner and agent across community switches and restores on return", () => { + const event = { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review" }] }, + }; + recordAvailableCommandsUpdate(OWNER, AGENT, event); + resetAgentCommandCatalog(); + assert.equal(getAgentCommandCatalog(OWNER).size, 0); + assert.equal(recordAvailableCommandsUpdate(OWNER, AGENT, event), false); + initAgentCommandCatalog("other-community"); + assert.equal(getAgentCommandCatalog(OWNER).size, 0); + recordAvailableCommandsUpdate(OWNER, AGENT, { + ...event, + payload: { commands: [{ name: "deploy" }] }, + }); + initAgentCommandCatalog("test-community"); + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT).commands, [ + { name: "review", description: null }, + ]); + }); +}); diff --git a/desktop/src/features/agents/agentCommandCatalog.ts b/desktop/src/features/agents/agentCommandCatalog.ts new file mode 100644 index 00000000000..095b709af1f --- /dev/null +++ b/desktop/src/features/agents/agentCommandCatalog.ts @@ -0,0 +1,239 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const STORAGE_PREFIX = "buzz-agent-command-catalog.v1"; +const MAX_COMMANDS_PER_AGENT = 256; +const MAX_COMMAND_NAME_LENGTH = 128; +const MAX_COMMAND_DESCRIPTION_LENGTH = 512; + +/** A sanitized command advertised by an ACP connector. */ +export type AgentCommand = { + name: string; + description: string | null; +}; + +/** The latest complete command snapshot and its observer ordering key. */ +export type AgentCommandCatalogEntry = { + commands: readonly AgentCommand[]; + seq: number; + timestamp: string; +}; + +/** Commands indexed by normalized agent pubkey within an owner and community. */ +export type AgentCommandCatalog = ReadonlyMap; + +type PersistedCatalog = { + version: 1; + agents: Record; +}; + +type AvailableCommandsEvent = { + payload: unknown; + seq: number; + timestamp: string; +}; + +const EMPTY_CATALOG: AgentCommandCatalog = new Map(); +let communityScope: string | null = null; +const catalogByOwner = new Map(); +const listeners = new Set<() => void>(); + +function storageKey(ownerPubkey: string): string { + return `${STORAGE_PREFIX}:${encodeURIComponent(communityScope ?? "")}:${normalizePubkey(ownerPubkey)}`; +} + +/** Scope both memory and persisted command catalogs to the active community. */ +export function initAgentCommandCatalog(communityId: string | null): void { + if (communityScope === communityId) return; + communityScope = communityId; + catalogByOwner.clear(); + for (const listener of listeners) listener(); +} + +/** Retire the current community's memory cache before another community mounts. */ +export function resetAgentCommandCatalog(): void { + initAgentCommandCatalog(null); +} + +function sanitizeCommand(value: unknown): AgentCommand | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const record = value as Record; + if (typeof record.name !== "string") return null; + + const name = record.name.trim().replace(/^\/+/, ""); + if ( + name.length === 0 || + name.length > MAX_COMMAND_NAME_LENGTH || + /[\s/\p{Cc}\p{Cf}]/u.test(name) + ) { + return null; + } + + const description = + typeof record.description === "string" + ? record.description + .replace(/[\p{Cc}\p{Bidi_Control}]/gu, " ") + .trim() + .slice(0, MAX_COMMAND_DESCRIPTION_LENGTH) || null + : null; + return { name, description }; +} + +/** Bound and sanitize an untrusted snapshot; null denotes a malformed payload. */ +export function parseAvailableCommandsPayload( + payload: unknown, +): readonly AgentCommand[] | null { + if ( + typeof payload !== "object" || + payload === null || + Array.isArray(payload) + ) { + return null; + } + const commands = (payload as Record).commands; + if (!Array.isArray(commands)) return null; + + const parsed: AgentCommand[] = []; + const seen = new Set(); + for (const value of commands.slice(0, MAX_COMMANDS_PER_AGENT)) { + const command = sanitizeCommand(value); + if (!command) continue; + const key = command.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + parsed.push(command); + } + return parsed; +} + +function parseStoredCatalog(raw: string | null): AgentCommandCatalog { + if (!raw) return EMPTY_CATALOG; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + !parsed.agents || + typeof parsed.agents !== "object" + ) { + return EMPTY_CATALOG; + } + const next = new Map(); + for (const [pubkey, entry] of Object.entries(parsed.agents)) { + if (!entry || typeof entry !== "object") continue; + const candidate = entry as Partial; + const commands = parseAvailableCommandsPayload({ + commands: candidate.commands, + }); + if ( + commands === null || + typeof candidate.seq !== "number" || + !Number.isSafeInteger(candidate.seq) || + typeof candidate.timestamp !== "string" || + !Number.isFinite(Date.parse(candidate.timestamp)) + ) { + continue; + } + next.set(normalizePubkey(pubkey), { + commands, + seq: candidate.seq, + timestamp: candidate.timestamp, + }); + } + return next; + } catch { + return EMPTY_CATALOG; + } +} + +function hydrate(ownerPubkey: string): AgentCommandCatalog { + const owner = normalizePubkey(ownerPubkey); + if (!catalogByOwner.has(owner)) { + let raw: string | null = null; + try { + if (typeof window !== "undefined") + raw = window.localStorage.getItem(storageKey(owner)); + } catch { + // Storage can be unavailable; live updates still maintain the memory cache. + } + catalogByOwner.set(owner, parseStoredCatalog(raw)); + } + return catalogByOwner.get(owner) ?? EMPTY_CATALOG; +} + +function persist(ownerPubkey: string, catalog: AgentCommandCatalog): void { + if (typeof window === "undefined") return; + const agents = Object.fromEntries(catalog.entries()); + setLocalStorageItemWithRecovery( + storageKey(ownerPubkey), + JSON.stringify({ version: 1, agents } satisfies PersistedCatalog), + ); +} + +function isNewer( + incoming: Pick, + current: AgentCommandCatalogEntry | undefined, +): boolean { + if (!current) return true; + const incomingTime = Date.parse(incoming.timestamp); + const currentTime = Date.parse(current.timestamp); + if (Number.isFinite(incomingTime) && Number.isFinite(currentTime)) { + if (incomingTime !== currentTime) return incomingTime > currentTime; + } + return incoming.seq > current.seq; +} + +/** Record a newer complete snapshot, including an authoritative empty list. */ +export function recordAvailableCommandsUpdate( + ownerPubkey: string, + agentPubkey: string, + event: AvailableCommandsEvent, +): boolean { + if (communityScope === null) return false; + const commands = parseAvailableCommandsPayload(event.payload); + if ( + commands === null || + !Number.isSafeInteger(event.seq) || + !Number.isFinite(Date.parse(event.timestamp)) + ) + return false; + + const owner = normalizePubkey(ownerPubkey); + const agent = normalizePubkey(agentPubkey); + const current = hydrate(owner); + if (!isNewer(event, current.get(agent))) return false; + + const next = new Map(current); + next.set(agent, { + commands, + seq: event.seq, + timestamp: event.timestamp, + }); + catalogByOwner.set(owner, next); + persist(owner, next); + for (const listener of listeners) listener(); + return true; +} + +/** Read the active community's last known commands for an owner. */ +export function getAgentCommandCatalog( + ownerPubkey: string | null, +): AgentCommandCatalog { + return ownerPubkey && communityScope !== null + ? hydrate(ownerPubkey) + : EMPTY_CATALOG; +} + +/** Subscribe to command snapshot or community changes. */ +export function subscribeAgentCommandCatalog(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Clear module state and subscriptions between tests. */ +export function resetAgentCommandCatalogForTests(): void { + communityScope = null; + catalogByOwner.clear(); + listeners.clear(); +} diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index 343ce241335..387956c6988 100644 --- a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -11,6 +11,11 @@ import assert from "node:assert/strict"; import { beforeEach, describe, it } from "node:test"; +import { + getAgentCommandCatalog, + initAgentCommandCatalog, + resetAgentCommandCatalogForTests, +} from "@/features/agents/agentCommandCatalog.ts"; import { ingestArchivedObserverEvents, injectObserverEventsForE2E, @@ -74,6 +79,8 @@ function makeDecryptFail() { describe("ingestArchivedObserverEvents", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); + initAgentCommandCatalog("test-community"); }); it("test_unknown_agent_drops_event_before_decrypt", async () => { @@ -94,6 +101,78 @@ describe("ingestArchivedObserverEvents", () => { assert.equal(snap.events.length, 0); }); + it("hydrates command catalogs from trusted archived semantic frames", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const ownerPubkey = "c".repeat(64); + const commandEvent = makeObserverEvent({ + kind: "available_commands_captured", + payload: { + commands: [{ name: "review", description: "Review changes" }], + }, + }); + + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt(commandEvent), + async () => ownerPubkey, + ); + + assert.deepEqual( + getAgentCommandCatalog(ownerPubkey).get(AGENT_PUBKEY)?.commands, + [{ name: "review", description: "Review changes" }], + ); + }); + + it("hydrates the latest catalog from batched archive frames", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const owner = "c".repeat(64); + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt( + makeObserverEvent({ + kind: "batch", + payload: { + events: [ + makeObserverEvent({ + kind: "available_commands_captured", + payload: { commands: [{ name: "review" }] }, + }), + makeObserverEvent({ + seq: 2, + kind: "available_commands_captured", + payload: { commands: [] }, + }), + ], + }, + }), + ), + async () => owner, + ); + assert.deepEqual( + getAgentCommandCatalog(owner).get(AGENT_PUBKEY)?.commands, + [], + ); + }); + + it("does not restore command catalogs after reset during owner lookup", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const owner = "c".repeat(64); + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt( + makeObserverEvent({ + kind: "available_commands_captured", + payload: { commands: [{ name: "review" }] }, + }), + ), + async () => { + resetAgentObserverStore(); + return owner; + }, + ); + assert.equal(getAgentCommandCatalog(owner).size, 0); + }); + it("test_mismatched_sender_drops_event_before_decrypt", async () => { _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); let decryptCalled = false; @@ -687,6 +766,7 @@ describe("eager initial hydration loop control flow (production runHydrationLoop describe("archive window holds more than MAX_OBSERVER_EVENTS (3000) frames", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_archive_window_retains_all_events_beyond_3000_cap", async () => { @@ -804,6 +884,7 @@ import { mergeObserverEventWindows } from "@/features/agents/ui/agentSessionPane describe("archive page subscription notification", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_full_archive_page_notifies_subscribers", async () => { @@ -882,6 +963,7 @@ describe("archive page subscription notification", () => { describe("raw-event-level merge: stateful aggregates across live/archive boundary", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_tool_start_in_archive_plus_update_in_live_yields_complete_row", () => { diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index b945496380f..229cb59aed6 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -7,6 +7,7 @@ import { putAgentSessionConfig } from "@/shared/api/tauri"; import { putManagedAgentRuntimeLifecycle } from "@/shared/api/tauriManagedAgents"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; +import { recordAvailableCommandsUpdate } from "./agentCommandCatalog"; import { parseAgentManagementRequest, type AgentManagementRequest, @@ -65,6 +66,7 @@ export type AgentObserverStoreUpdate = { type AgentObserverStoreListener = (update?: AgentObserverStoreUpdate) => void; const listeners = new Set(); +let observerOwnerPubkey: string | null = null; const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); @@ -488,6 +490,9 @@ function processLiveObserverEvents( const accepted = appendAgentEvents(agentPubkey, events); for (const parsed of accepted ?? []) { + if (parsed.kind === "available_commands_captured" && observerOwnerPubkey) { + recordAvailableCommandsUpdate(observerOwnerPubkey, agentPubkey, parsed); + } // Track the latest-live-session-id per (agent, channel) on the live path. // Only set when the parsed event carries both a sessionId and channelId, // so we never attribute a session to the wrong channel. @@ -602,6 +607,8 @@ export function ensureRelayObserverSubscription() { setConnectionState("connecting", null); startPromise = (async () => { const identity = await getIdentity(); + if (activeGeneration !== generation) return; + observerOwnerPubkey = normalizePubkey(identity.pubkey); const unsubscribe = await subscribeToAgentObserverFrames( identity.pubkey, (event) => { @@ -847,7 +854,11 @@ export function useManagedAgentObserverBridge( export async function ingestArchivedObserverEvents( rawEvents: RelayEvent[], _decryptFn: (event: RelayEvent) => Promise = decryptObserverEvent, + _ownerPubkeyFn: () => Promise = async () => + (await getIdentity()).pubkey, ): Promise { + const activeGeneration = generation; + let archiveOwnerPubkey: string | null = null; let archiveChanged = false; for (const event of rawEvents) { const agentPubkey = observerTag(event, "agent"); @@ -863,7 +874,13 @@ export async function ingestArchivedObserverEvents( } try { const parsed = (await _decryptFn(event)) as ObserverEvent; + if (activeGeneration !== generation) return; for (const inner of unwrapObserverBatch(parsed)) { + if (inner.kind === "available_commands_captured") { + archiveOwnerPubkey ??= normalizePubkey(await _ownerPubkeyFn()); + if (activeGeneration !== generation) return; + recordAvailableCommandsUpdate(archiveOwnerPubkey, agentPubkey, inner); + } // Route archived events to the channel-scoped archive window (no cap) // rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap). // Events without a channelId fall through to the live store so they @@ -927,6 +944,7 @@ export function syncAgentObserverEvents( export function resetAgentObserverStore() { generation += 1; + observerOwnerPubkey = null; const unsubscribe = unsubscribeRelay; unsubscribeRelay = null; startPromise = null; diff --git a/desktop/src/features/agents/useAgentCommandCatalog.ts b/desktop/src/features/agents/useAgentCommandCatalog.ts new file mode 100644 index 00000000000..5d7111020f5 --- /dev/null +++ b/desktop/src/features/agents/useAgentCommandCatalog.ts @@ -0,0 +1,15 @@ +import * as React from "react"; + +import { + getAgentCommandCatalog, + subscribeAgentCommandCatalog, +} from "./agentCommandCatalog"; + +/** Subscribe to the current owner's command catalog without copying snapshots. */ +export function useAgentCommandCatalog(ownerPubkey: string | null) { + return React.useSyncExternalStore( + subscribeAgentCommandCatalog, + () => getAgentCommandCatalog(ownerPubkey), + () => getAgentCommandCatalog(null), + ); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 4a1ddf9d0b8..2c58215598b 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -36,6 +36,10 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { + initAgentCommandCatalog, + resetAgentCommandCatalog, +} from "@/features/agents/agentCommandCatalog"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -66,6 +70,7 @@ async function resetCommunityState({ resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); + resetAgentCommandCatalog(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); if (isTauri() && isMacPlatform()) { @@ -353,6 +358,7 @@ export function useCommunityInit( if (identityPubkey !== null) { initDraftStore(identityPubkey, activeCommunity.relayUrl); } + initAgentCommandCatalog(activeCommunity.id); // Restore any turn state saved for this community (a prior A→B round- // trip). This runs after applyCommunity succeeds and before the app // renders so components see the restored timers on first render. diff --git a/desktop/src/features/messages/lib/slashCommandAutocomplete.test.mjs b/desktop/src/features/messages/lib/slashCommandAutocomplete.test.mjs new file mode 100644 index 00000000000..ebec3332ed4 --- /dev/null +++ b/desktop/src/features/messages/lib/slashCommandAutocomplete.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + buildSlashCommandGroups, + buildSlashCommandInsertText, + detectSlashCommandQuery, + resolveLeadingAgentMentionPubkeys, +} from "./slashCommandAutocomplete.ts"; + +const ALPHA = "aa".repeat(32); +const BETA = "bb".repeat(32); +const catalog = new Map([ + [ + ALPHA, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [ + { name: "review", description: "Review the current changes" }, + { name: "deploy", description: "Ship to production" }, + ], + }, + ], + [ + BETA, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [{ name: "review", description: "Independent review" }], + }, + ], +]); +const providers = [ + { pubkey: ALPHA, displayName: "Alpha" }, + { pubkey: BETA, displayName: "Beta" }, +]; + +describe("slash command autocomplete", () => { + it("detects a slash at message start and after leading agent mentions", () => { + assert.deepEqual(detectSlashCommandQuery("/rev", 4), { + leadingText: "", + query: "rev", + replaceFromOffset: 0, + }); + assert.deepEqual(detectSlashCommandQuery("@Alpha /dep", 11), { + leadingText: "@Alpha ", + query: "dep", + replaceFromOffset: 7, + }); + }); + + it("resolves selected or manually typed leading member-agent mentions", () => { + assert.deepEqual( + resolveLeadingAgentMentionPubkeys("@Alpha ", [ + { displayName: "Alpha", pubkey: ALPHA }, + ]), + [ALPHA], + ); + assert.deepEqual( + resolveLeadingAgentMentionPubkeys("@Alpha @Beta ", [ + { displayName: "Alpha", pubkey: ALPHA }, + { displayName: "Beta", pubkey: BETA }, + ]), + [ALPHA, BETA], + ); + assert.deepEqual( + resolveLeadingAgentMentionPubkeys("@Alpha hello ", [ + { displayName: "Alpha", pubkey: ALPHA }, + ]), + [], + ); + }); + + it("does not trigger for inline paths, arguments, or multi-line text", () => { + assert.equal(detectSlashCommandQuery("please /review", 14), null); + assert.equal(detectSlashCommandQuery("/review now", 11), null); + assert.equal(detectSlashCommandQuery("hello\n/review", 13), null); + }); + + it("routes commands chosen at message start through the provider mention", () => { + const [group] = buildSlashCommandGroups({ + catalog, + providers: [providers[0]], + query: "rev", + selectedAgentPubkeys: null, + }); + const [suggestion] = group.commands; + + assert.equal( + buildSlashCommandInsertText(suggestion, false), + "@Alpha /review ", + ); + assert.equal(buildSlashCommandInsertText(suggestion, true), "/review "); + }); + + it("groups duplicate command names by provider and narrows to mentions", () => { + const all = buildSlashCommandGroups({ + catalog, + providers, + query: "rev", + selectedAgentPubkeys: null, + }); + assert.deepEqual( + all.map((group) => [group.agentDisplayName, group.commands[0].name]), + [ + ["Alpha", "review"], + ["Beta", "review"], + ], + ); + + const mentioned = buildSlashCommandGroups({ + catalog, + providers, + query: "", + selectedAgentPubkeys: [BETA], + }); + assert.deepEqual( + mentioned.map((group) => group.agentPubkey), + [BETA], + ); + }); + + it("ranks name prefixes before infix and description matches", () => { + const rankedCatalog = new Map([ + [ + ALPHA, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [ + { name: "preview", description: null }, + { name: "review", description: null }, + { name: "inspect", description: "review changes" }, + ], + }, + ], + ]); + const [group] = buildSlashCommandGroups({ + catalog: rankedCatalog, + providers: [providers[0]], + query: "rev", + selectedAgentPubkeys: null, + }); + assert.deepEqual( + group.commands.map((command) => command.name), + ["review", "preview", "inspect"], + ); + }); +}); diff --git a/desktop/src/features/messages/lib/slashCommandAutocomplete.ts b/desktop/src/features/messages/lib/slashCommandAutocomplete.ts new file mode 100644 index 00000000000..3fc82eb951a --- /dev/null +++ b/desktop/src/features/messages/lib/slashCommandAutocomplete.ts @@ -0,0 +1,148 @@ +import type { AgentCommandCatalog } from "@/features/agents/agentCommandCatalog"; +import { mentionOccurrences } from "@/shared/lib/mentionOccurrences"; + +/** A channel agent eligible to supply slash-command suggestions. */ +export type SlashCommandProvider = { + pubkey: string; + displayName: string; +}; + +/** A command bound to the specific agent that advertised it. */ +export type SlashCommandSuggestion = { + agentDisplayName: string; + agentPubkey: string; + description: string | null; + name: string; +}; + +/** Suggestions grouped by their originating agent. */ +export type SlashCommandGroup = { + agentDisplayName: string; + agentPubkey: string; + commands: readonly SlashCommandSuggestion[]; +}; + +/** A command query at message start or after leading mentions. */ +export type SlashCommandQuery = { + leadingText: string; + query: string; + replaceFromOffset: number; +}; + +/** Detect a first-line command prefix without matching paths or arguments. */ +export function detectSlashCommandQuery( + value: string, + cursorPosition: number, +): SlashCommandQuery | null { + const beforeCursor = value.slice(0, cursorPosition); + if (beforeCursor.includes("\n")) return null; + + const slashIndex = beforeCursor.lastIndexOf("/"); + if (slashIndex < 0) return null; + const leadingText = beforeCursor.slice(0, slashIndex); + const query = beforeCursor.slice(slashIndex + 1); + if (/\s|\//u.test(query)) return null; + if ( + leadingText.length > 0 && + (!leadingText.startsWith("@") || !/\s$/u.test(leadingText)) + ) { + return null; + } + + return { leadingText, query, replaceFromOffset: slashIndex }; +} + +/** Require a prefix made entirely of complete mention labels and separators. */ +export function resolveLeadingAgentMentionPubkeys( + leadingText: string, + candidates: readonly SlashCommandProvider[], +): string[] { + const pubkeys = new Set(); + let offset = 0; + for (const match of mentionOccurrences(leadingText, candidates)) { + if (match.start !== offset) return []; + for (const candidate of match.candidates) + pubkeys.add(candidate.pubkey.toLowerCase()); + const whitespace = leadingText.slice(match.end).match(/^\s+/u)?.[0]; + if (!whitespace) return []; + offset = match.end + whitespace.length; + } + return offset === leadingText.length ? [...pubkeys] : []; +} + +/** Add the provider mention only when the message has no leading mentions. */ +export function buildSlashCommandInsertText( + suggestion: SlashCommandSuggestion, + hasLeadingAgentMention: boolean, +): string { + const command = `/${suggestion.name} `; + return hasLeadingAgentMention + ? command + : `@${suggestion.agentDisplayName} ${command}`; +} + +function commandRank( + name: string, + description: string | null, + query: string, +): number | null { + if (!query) return 0; + const lowerName = name.toLowerCase(); + if (lowerName.startsWith(query)) return 0; + if (lowerName.includes(query)) return 1; + if (description?.toLowerCase().includes(query)) return 2; + return null; +} + +/** Filter by recipient and rank name matches before description matches. */ +export function buildSlashCommandGroups({ + catalog, + providers, + query, + selectedAgentPubkeys, +}: { + catalog: AgentCommandCatalog; + providers: readonly SlashCommandProvider[]; + query: string; + selectedAgentPubkeys: readonly string[] | null; +}): SlashCommandGroup[] { + const lowerQuery = query.toLowerCase(); + const selected = selectedAgentPubkeys + ? new Set(selectedAgentPubkeys.map((pubkey) => pubkey.toLowerCase())) + : null; + + return providers + .filter( + (provider) => !selected || selected.has(provider.pubkey.toLowerCase()), + ) + .map((provider) => { + const commands = ( + catalog.get(provider.pubkey.toLowerCase())?.commands ?? [] + ) + .map((command) => ({ + command, + rank: commandRank(command.name, command.description, lowerQuery), + })) + .filter( + (entry): entry is typeof entry & { rank: number } => + entry.rank !== null, + ) + .sort( + (left, right) => + left.rank - right.rank || + left.command.name.localeCompare(right.command.name), + ) + .map(({ command }) => ({ + agentDisplayName: provider.displayName, + agentPubkey: provider.pubkey, + description: command.description, + name: command.name, + })); + return { + agentDisplayName: provider.displayName, + agentPubkey: provider.pubkey, + commands, + }; + }) + .filter((group) => group.commands.length > 0); +} diff --git a/desktop/src/features/messages/lib/useSlashCommandAutocomplete.test.mjs b/desktop/src/features/messages/lib/useSlashCommandAutocomplete.test.mjs new file mode 100644 index 00000000000..08c9be2c85a --- /dev/null +++ b/desktop/src/features/messages/lib/useSlashCommandAutocomplete.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import * as React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import { + initAgentCommandCatalog, + recordAvailableCommandsUpdate, + resetAgentCommandCatalogForTests, +} from "@/features/agents/agentCommandCatalog.ts"; +import { + extractMentionPubkeys, + selectedMentionLabel, +} from "./extractMentionPubkeys.ts"; +import { useSlashCommandAutocomplete } from "./useSlashCommandAutocomplete.ts"; + +const OWNER = "a".repeat(64); +const ALPHA = "b".repeat(64); +const BETA = "c".repeat(64); +const dom = new JSDOM("", { + url: "http://localhost", +}); +const clients = []; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + for (const client of clients.splice(0)) client.clear(); + resetAgentCommandCatalogForTests(); + window.localStorage.clear(); +}); +after(() => dom.window.close()); + +async function setup({ sameName = false } = {}) { + initAgentCommandCatalog("test-community"); + const { renderHook } = await import("@testing-library/react"); + const members = [ + { pubkey: ALPHA, displayName: "Alpha", isAgent: true, isMember: true }, + { + pubkey: BETA, + displayName: sameName ? "Alpha" : "Beta", + isAgent: true, + isMember: true, + }, + ]; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + clients.push(client); + client.setQueryData(["channels", "channel", "members"], members); + client.setQueryData(["channels", "other-channel", "members"], members); + for (const member of members) { + recordAvailableCommandsUpdate(OWNER, member.pubkey, { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review" }, { name: "deploy" }] }, + }); + } + const bindings = new Map(); + const mentions = { + registerMentionPubkey(name, pubkey) { + const label = selectedMentionLabel(name, pubkey, bindings); + bindings.set(label, pubkey); + return label; + }, + getMentionIdentities: () => [ + ...[...bindings].map(([label, pubkey]) => ({ + label, + pubkey, + isAgent: true, + })), + ...members + .filter((member) => !bindings.has(member.displayName)) + .map((member) => ({ + label: member.displayName, + pubkey: member.pubkey, + isAgent: true, + })), + ], + extractMentionPubkeys: (text) => + extractMentionPubkeys({ + text, + selectedMentions: bindings, + memberCandidates: members, + }), + }; + const hook = renderHook( + ({ channelId }) => + useSlashCommandAutocomplete({ + channelId, + ownerPubkey: OWNER, + mentions, + }), + { + initialProps: { channelId: "channel" }, + wrapper: ({ children }) => + React.createElement(QueryClientProvider, { client }, children), + }, + ); + return { ...hook, bindings, mentions }; +} + +function keyEvent(key, modifiers = {}) { + return { + key, + preventDefault() { + this.defaultPrevented = true; + }, + ...modifiers, + }; +} + +test("keyboard selection inserts and binds the exact registered same-name agent label", async () => { + const { act } = await import("@testing-library/react"); + const { result, mentions } = await setup({ sameName: true }); + mentions.registerMentionPubkey("Alpha", ALPHA); + act(() => result.current.updateQuery("/rev", 4)); + act(() => result.current.handleKeyDown(keyEvent("ArrowDown"))); + let edit; + act(() => { + const selection = result.current.handleKeyDown(keyEvent("Enter")); + edit = result.current.insertCommand(selection.suggestion, 4); + }); + assert.equal(edit.insertText, `@Alpha (${BETA}) /review `); + assert.deepEqual(mentions.extractMentionPubkeys(edit.insertText), [BETA]); + assert.equal(result.current.isOpen, false); + const text = `@Alpha (${BETA}) /rev`; + act(() => result.current.updateQuery(text, text.length)); + assert.deepEqual( + result.current.groups.map((group) => group.agentPubkey), + [BETA], + ); +}); + +test("ambiguous manually typed names do not offer a command to an arbitrary agent", async () => { + const { act } = await import("@testing-library/react"); + const { result } = await setup({ sameName: true }); + act(() => result.current.updateQuery("@Alpha /rev", 11)); + assert.equal(result.current.isOpen, false); +}); + +test("escape keeps the literal query dismissed until it changes and tab selects", async () => { + const { act } = await import("@testing-library/react"); + const { result } = await setup(); + act(() => result.current.updateQuery("/", 1)); + act(() => result.current.handleKeyDown(keyEvent("Escape"))); + act(() => result.current.updateQuery("/", 1)); + assert.equal(result.current.isOpen, false); + act(() => result.current.updateQuery("/rev", 4)); + assert.equal(result.current.isOpen, true); + assert.equal( + result.current.handleKeyDown(keyEvent("Enter", { shiftKey: true })).handled, + false, + ); + assert.equal( + result.current.handleKeyDown(keyEvent("Tab")).suggestion.name, + "review", + ); +}); + +test("live catalog removal closes the picker without clearing a leading mention", async () => { + const { act } = await import("@testing-library/react"); + const { result } = await setup(); + act(() => result.current.updateQuery("@Alpha /rev", 11)); + assert.equal(result.current.isOpen, true); + act(() => + recordAvailableCommandsUpdate(OWNER, ALPHA, { + seq: 2, + timestamp: "2026-07-23T08:01:00Z", + payload: { commands: [] }, + }), + ); + assert.equal(result.current.isOpen, false); +}); + +test("code context and composing keys leave the literal command alone", async () => { + const { act } = await import("@testing-library/react"); + const { result } = await setup(); + act(() => result.current.updateQuery("/rev", 4, true)); + assert.equal(result.current.isOpen, false); + act(() => result.current.updateQuery("/rev", 4)); + assert.equal( + result.current.handleKeyDown( + keyEvent("Enter", { nativeEvent: { isComposing: true } }), + ).handled, + false, + ); + assert.equal( + result.current.handleKeyDown(keyEvent("Tab", { shiftKey: true })).handled, + false, + ); +}); + +test("reusing the composer in another channel clears its command query", async () => { + const { act } = await import("@testing-library/react"); + const { result, rerender } = await setup(); + act(() => result.current.updateQuery("/rev", 4)); + assert.equal(result.current.isOpen, true); + rerender({ channelId: "other-channel" }); + assert.equal(result.current.isOpen, false); +}); diff --git a/desktop/src/features/messages/lib/useSlashCommandAutocomplete.ts b/desktop/src/features/messages/lib/useSlashCommandAutocomplete.ts new file mode 100644 index 00000000000..eba2b4995ea --- /dev/null +++ b/desktop/src/features/messages/lib/useSlashCommandAutocomplete.ts @@ -0,0 +1,226 @@ +import * as React from "react"; + +import { useAgentCommandCatalog } from "@/features/agents/useAgentCommandCatalog"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; +import type { AutocompleteEdit } from "./useRichTextEditor"; +import type { UseMentionsResult } from "./useMentions"; +import { + buildSlashCommandInsertText, + buildSlashCommandGroups, + detectSlashCommandQuery, + resolveLeadingAgentMentionPubkeys, + type SlashCommandQuery, + type SlashCommandSuggestion, +} from "./slashCommandAutocomplete"; + +type ActiveQuery = { + detected: SlashCommandQuery; + selectedAgentPubkeys: readonly string[] | null; + signature: string; +}; + +/** Complete advertised agent commands without changing command execution semantics. */ +export function useSlashCommandAutocomplete({ + channelId, + ownerPubkey, + mentions, +}: { + channelId: string | null; + ownerPubkey: string | null; + mentions: Pick< + UseMentionsResult, + "getMentionIdentities" | "extractMentionPubkeys" | "registerMentionPubkey" + >; +}) { + const membersQuery = useChannelMembersQuery(channelId, Boolean(channelId)); + const catalog = useAgentCommandCatalog(ownerPubkey); + const [activeQuery, setActiveQuery] = React.useState( + null, + ); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const dismissedSignatureRef = React.useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: a composer can be reused for a different channel or identity without unmounting + React.useEffect(() => { + setActiveQuery(null); + setSelectedIndex(0); + dismissedSignatureRef.current = null; + }, [channelId, ownerPubkey]); + + const providers = React.useMemo( + () => + (membersQuery.data ?? []) + .filter((member) => member.isAgent || member.role === "bot") + .map((member) => ({ + pubkey: normalizePubkey(member.pubkey), + displayName: + member.displayName?.trim() || truncateNpub(member.pubkey), + })), + [membersQuery.data], + ); + + const groups = React.useMemo( + () => + activeQuery + ? buildSlashCommandGroups({ + catalog, + providers, + query: activeQuery.detected.query, + selectedAgentPubkeys: activeQuery.selectedAgentPubkeys, + }) + : [], + [activeQuery, catalog, providers], + ); + const suggestions = React.useMemo( + () => groups.flatMap((group) => group.commands), + [groups], + ); + const isOpen = activeQuery !== null && suggestions.length > 0; + + React.useEffect(() => { + setSelectedIndex((current) => + suggestions.length === 0 ? 0 : Math.min(current, suggestions.length - 1), + ); + }, [suggestions.length]); + + const updateQuery = React.useCallback( + (value: string, cursorPosition: number, isCodeContext = false) => { + const detected = isCodeContext + ? null + : detectSlashCommandQuery(value, cursorPosition); + if (!detected) { + dismissedSignatureRef.current = null; + setActiveQuery(null); + setSelectedIndex(0); + return; + } + + let selectedAgentPubkeys: readonly string[] | null = null; + if (detected.leadingText) { + const candidates = mentions.getMentionIdentities().map((identity) => ({ + displayName: identity.label, + pubkey: identity.pubkey, + })); + const leadingPubkeys = resolveLeadingAgentMentionPubkeys( + detected.leadingText, + candidates, + ); + try { + selectedAgentPubkeys = leadingPubkeys.length + ? mentions.extractMentionPubkeys(detected.leadingText) + : []; + } catch { + // Ambiguous mentions remain literal; the send flow explains how to resolve them. + selectedAgentPubkeys = []; + } + if ( + selectedAgentPubkeys.length === 0 || + selectedAgentPubkeys.some( + (pubkey) => + !providers.some( + (provider) => provider.pubkey === normalizePubkey(pubkey), + ), + ) + ) { + setActiveQuery(null); + setSelectedIndex(0); + return; + } + } + + const signature = `${detected.replaceFromOffset}:${detected.leadingText}:${detected.query}`; + if (dismissedSignatureRef.current === signature) { + setActiveQuery(null); + return; + } + dismissedSignatureRef.current = null; + setActiveQuery({ detected, selectedAgentPubkeys, signature }); + setSelectedIndex(0); + }, + [providers, mentions.getMentionIdentities, mentions.extractMentionPubkeys], + ); + + const insertCommand = React.useCallback( + ( + suggestion: SlashCommandSuggestion, + selectionEnd: number, + ): AutocompleteEdit | null => { + if (!activeQuery) return null; + let agentDisplayName = suggestion.agentDisplayName; + if (activeQuery.selectedAgentPubkeys === null) { + const label = mentions.registerMentionPubkey( + agentDisplayName, + suggestion.agentPubkey, + { isAgent: true }, + ); + if (!label) return null; + agentDisplayName = label; + } + const edit = { + replaceFromOffset: activeQuery.detected.replaceFromOffset, + replaceToOffset: selectionEnd, + insertText: buildSlashCommandInsertText( + { ...suggestion, agentDisplayName }, + activeQuery.selectedAgentPubkeys !== null, + ), + }; + setActiveQuery(null); + setSelectedIndex(0); + return edit; + }, + [activeQuery, mentions.registerMentionPubkey], + ); + + const handleKeyDown = React.useCallback( + ( + event: React.KeyboardEvent, + ): { handled: boolean; suggestion?: SlashCommandSuggestion } => { + if (!isOpen || !activeQuery || event.nativeEvent?.isComposing) + return { handled: false }; + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((current) => + current < suggestions.length - 1 ? current + 1 : 0, + ); + return { handled: true }; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((current) => + current > 0 ? current - 1 : suggestions.length - 1, + ); + return { handled: true }; + } + if ( + (event.key === "Tab" || event.key === "Enter") && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) { + event.preventDefault(); + return { handled: true, suggestion: suggestions[selectedIndex] }; + } + if (event.key === "Escape") { + event.preventDefault(); + dismissedSignatureRef.current = activeQuery.signature; + setActiveQuery(null); + setSelectedIndex(0); + return { handled: true }; + } + return { handled: false }; + }, + [activeQuery, isOpen, selectedIndex, suggestions], + ); + + return { + groups, + handleKeyDown, + insertCommand, + isOpen, + selectedIndex, + suggestions, + updateQuery, + }; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6286ebfb56b..f74432d4298 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -28,6 +28,8 @@ import { import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership"; import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { useSlashCommandAutocomplete } from "@/features/messages/lib/useSlashCommandAutocomplete"; +import type { SlashCommandSuggestion } from "@/features/messages/lib/slashCommandAutocomplete"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { setKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -148,6 +150,11 @@ function MessageComposerImpl({ recentMentionPubkeys, }); const channelLinks = useChannelLinks(); + const slashCommands = useSlashCommandAutocomplete({ + channelId, + ownerPubkey, + mentions, + }); const customEmoji = useCustomEmoji(); const emojiAutocomplete = useEmojiAutocomplete(customEmoji); const notifyTyping = useTypingBroadcast( @@ -253,7 +260,8 @@ function MessageComposerImpl({ isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || - emojiAutocomplete.isEmojiAutocompleteOpen; + emojiAutocomplete.isEmojiAutocompleteOpen || + slashCommands.isOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); const formRef = React.useRef(null); @@ -308,6 +316,11 @@ function MessageComposerImpl({ mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); + slashCommands.updateQuery( + text, + cursor, + isMentionCodeContext(richText.editor), + ); if (text.trim().length > 0) { notifyTyping(); } @@ -506,6 +519,19 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); + const applySlashCommandInsert = React.useCallback( + (suggestion: SlashCommandSuggestion) => { + const { cursor } = richText.getPlainTextAndCursor(); + const edit = slashCommands.insertCommand(suggestion, cursor); + if (!edit) return; + applyAutocompleteEdit(edit); + }, + [ + applyAutocompleteEdit, + richText.getPlainTextAndCursor, + slashCommands.insertCommand, + ], + ); // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -726,6 +752,12 @@ function MessageComposerImpl({ const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (handleAlwaysAddressShortcut(event)) return; + const commandResult = slashCommands.handleKeyDown(event); + if (commandResult.handled) { + if (commandResult.suggestion) + applySlashCommandInsert(commandResult.suggestion); + return; + } // Let autocomplete handle keys first const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); if (emojiResult.handled) { @@ -784,6 +816,8 @@ function MessageComposerImpl({ }, [ handleAlwaysAddressShortcut, + slashCommands.handleKeyDown, + applySlashCommandInsert, emojiAutocomplete.handleEmojiKeyDown, applyEmojiInsert, channelLinks.handleChannelKeyDown, @@ -881,6 +915,8 @@ function MessageComposerImpl({ audienceScope && editTarget == null, )} channelLinks={channelLinks} + slashCommands={slashCommands} + onSlashCommandSelect={applySlashCommandInsert} composerOwnsFocus={composerOwnsFocus} emojiAutocomplete={emojiAutocomplete} keepMentionedAgentsPinned={keepMentionedAgentsPinned} diff --git a/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx b/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx index a4e82121091..c9d9d3e8d38 100644 --- a/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx +++ b/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx @@ -8,6 +8,9 @@ import type { UseEmojiAutocompleteResult, } from "@/features/messages/lib/useEmojiAutocomplete"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { useSlashCommandAutocomplete } from "@/features/messages/lib/useSlashCommandAutocomplete"; +import type { SlashCommandSuggestion } from "@/features/messages/lib/slashCommandAutocomplete"; +import { SlashCommandAutocomplete } from "./SlashCommandAutocomplete"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { EmojiAutocomplete } from "./EmojiAutocomplete"; import { @@ -22,6 +25,8 @@ type MessageComposerAutocompletesProps = { */ audienceControlsEnabled: boolean; channelLinks: UseChannelLinksResult; + slashCommands: ReturnType; + onSlashCommandSelect: (suggestion: SlashCommandSuggestion) => void; composerOwnsFocus: boolean; emojiAutocomplete: UseEmojiAutocompleteResult; keepMentionedAgentsPinned: boolean; @@ -40,7 +45,7 @@ type MessageComposerAutocompletesProps = { }; /** - * The message composer's three suggestion overlays. Each one gates its own + * The message composer's suggestion overlays. Each one gates its own * rendering on `composerOwnsFocus`, so a background composer replaying a * stale update cannot resurrect a suggestion menu over the focused composer, * while keyboard focus moving into an overlay's own controls keeps that @@ -49,6 +54,8 @@ type MessageComposerAutocompletesProps = { export function MessageComposerAutocompletes({ audienceControlsEnabled, channelLinks, + slashCommands, + onSlashCommandSelect, composerOwnsFocus, emojiAutocomplete, keepMentionedAgentsPinned, @@ -63,6 +70,13 @@ export function MessageComposerAutocompletes({ }: MessageComposerAutocompletesProps) { return ( <> + void; + selectedIndex: number; +}; + +/** Group agent command suggestions while the owning composer keeps focus. */ +export const SlashCommandAutocomplete = React.memo( + function SlashCommandAutocomplete({ + groups, + onSelect, + selectedIndex, + }: SlashCommandAutocompleteProps) { + const listRef = React.useRef(null); + + React.useEffect(() => { + listRef.current + ?.querySelector(`[data-command-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (groups.length === 0) return null; + + let commandIndex = -1; + const selected = groups.flatMap((group) => group.commands)[selectedIndex]; + return ( +
+
+ {selected + ? `${selected.agentDisplayName}: /${selected.name}. Enter or Tab to insert, Escape to dismiss.` + : ""} +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: keep the editor focused when pressing the scrollbar or a group label */} +
event.preventDefault()} + ref={listRef} + style={POPOVER_SHADOW_STYLE} + > + {groups.map((group) => ( +
+
+
+ {group.commands.map((command) => { + commandIndex += 1; + const index = commandIndex; + return ( + + ); + })} +
+ ))} +
+
+ ); + }, +); diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 68f6b50aa39..0573e22a807 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -8,6 +8,7 @@ */ const PURE_CACHE_KEY_PREFIXES = [ + "buzz-agent-command-catalog.v1:", "buzz-channel-messages.v1:", "buzz-channels.v1:", "buzz-observed-unread.v1:", diff --git a/desktop/tests/e2e/slash-command-autocomplete.spec.ts b/desktop/tests/e2e/slash-command-autocomplete.spec.ts new file mode 100644 index 00000000000..a4bba581086 --- /dev/null +++ b/desktop/tests/e2e/slash-command-autocomplete.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const OWNER = "deadbeef".repeat(8); +const ALPHA = "a".repeat(64); +const BETA = "b".repeat(64); + +test.beforeEach(async ({ page }) => { + await page.addInitScript( + ({ owner, alpha, beta }) => { + localStorage.setItem( + `buzz-agent-command-catalog.v1:e2e-default-community:${owner}`, + JSON.stringify({ + version: 1, + agents: Object.fromEntries( + [alpha, beta].map((pubkey) => [ + pubkey, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [ + { name: "review", description: "Review the current changes" }, + ], + }, + ]), + ), + }), + ); + }, + { owner: OWNER, alpha: ALPHA, beta: BETA }, + ); + await installMockBridge(page, { + managedAgents: [ + { + pubkey: ALPHA, + name: "Alpha", + channelNames: ["general"], + status: "running", + }, + { + pubkey: BETA, + name: "Beta", + channelNames: ["general"], + status: "running", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +}); + +test("persisted commands group by provider and keyboard selection binds the sent recipient", async ({ + page, +}, testInfo) => { + const input = page.getByTestId("message-input"); + const menu = page.getByTestId("slash-command-autocomplete"); + await input.fill("/rev"); + await expect( + menu.getByRole("group", { name: "Alpha commands" }), + ).toBeVisible(); + await expect( + menu.getByRole("group", { name: "Beta commands" }), + ).toBeVisible(); + await waitForAnimations(page); + await menu.screenshot({ path: testInfo.outputPath("slash-commands.png") }); + await input.press("ArrowDown"); + await input.press("Tab"); + await expect(input).toHaveText("@Beta /review "); + await expect(menu).not.toBeVisible(); + await input.press("Enter"); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_SIGNED_EVENTS__ + ?.filter((event) => event.content.includes("/review")) + .map((event) => + event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]), + ), + ), + ) + .toContainEqual([BETA]); +}); + +test("leading mentions filter commands and escape preserves the literal text", async ({ + page, +}) => { + const input = page.getByTestId("message-input"); + const menu = page.getByTestId("slash-command-autocomplete"); + await input.fill("@Alpha /rev"); + await expect( + menu.getByRole("group", { name: "Alpha commands" }), + ).toBeVisible(); + await expect(menu.getByRole("group", { name: "Beta commands" })).toHaveCount( + 0, + ); + await input.press("Escape"); + await expect(menu).not.toBeVisible(); + await expect(input).toHaveText("@Alpha /rev"); + await input.fill("/review"); + await menu + .getByRole("group", { name: "Beta commands" }) + .getByRole("button") + .click(); + await expect(input).toHaveText("@Beta /review "); +});