diff --git a/desktop/src/features/messages/lib/composerAgentSkills.test.mjs b/desktop/src/features/messages/lib/composerAgentSkills.test.mjs new file mode 100644 index 00000000000..c7ec2f4e832 --- /dev/null +++ b/desktop/src/features/messages/lib/composerAgentSkills.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + buildSkillInsertion, + extractAvailableAgentSkills, +} from "./composerAgentSkills.ts"; + +function event(seq, overrides = {}) { + return { + seq, + timestamp: `2026-08-22T00:00:0${seq}.000Z`, + kind: "acp_read", + agentIndex: 0, + channelId: "channel-a", + sessionId: "session-a", + turnId: "turn-a", + payload: {}, + ...overrides, + }; +} + +function commandsEvent(seq, commands, overrides = {}) { + return event(seq, { + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "available_commands_update", + availableCommands: commands, + }, + }, + }, + ...overrides, + }); +} + +describe("extractAvailableAgentSkills", () => { + test("returns the latest commands for the current channel and session", () => { + const skills = extractAvailableAgentSkills( + [ + commandsEvent(1, [{ name: "old", description: "Old command" }]), + commandsEvent( + 2, + [ + { name: "/review", description: "Review this change" }, + { name: "plan", description: "Create a plan" }, + { name: "plan", description: "Duplicate" }, + ], + { sessionId: "session-b" }, + ), + ], + "channel-a", + ); + + assert.deepEqual(skills, [ + { + name: "review", + description: "Review this change", + inputHint: "", + }, + { name: "plan", description: "Create a plan", inputHint: "" }, + ]); + }); + + test("does not leak commands from a previous session", () => { + const skills = extractAvailableAgentSkills( + [ + commandsEvent(1, [{ name: "review" }]), + event(2, { sessionId: "session-b", kind: "turn_start" }), + ], + "channel-a", + ); + + assert.deepEqual(skills, []); + }); + + test("keeps commands advertised while a new session id is still unknown", () => { + const skills = extractAvailableAgentSkills( + [ + commandsEvent(1, [{ name: "review" }], { sessionId: null }), + event(2, { kind: "session_resolved" }), + ], + "channel-a", + ); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["review"], + ); + }); + + test("ignores command updates from other channels", () => { + const skills = extractAvailableAgentSkills( + [ + commandsEvent(1, [{ name: "review" }]), + commandsEvent(2, [{ name: "wrong" }], { + channelId: "channel-b", + sessionId: "session-b", + }), + ], + "channel-a", + ); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["review"], + ); + }); +}); + +describe("buildSkillInsertion", () => { + test("inserts a slash command at an empty caret", () => { + assert.deepEqual(buildSkillInsertion("", 0, "review"), { + insertText: "/review ", + replaceFromOffset: 0, + replaceToOffset: 0, + }); + }); + + test("adds spacing when inserting between words", () => { + assert.deepEqual(buildSkillInsertion("helloworld", 5, "/review"), { + insertText: " /review ", + replaceFromOffset: 5, + replaceToOffset: 5, + }); + }); + + test("does not duplicate existing whitespace", () => { + assert.deepEqual(buildSkillInsertion("hello world", 6, "review"), { + insertText: "/review ", + replaceFromOffset: 6, + replaceToOffset: 6, + }); + }); + + test("rejects an invalid command name", () => { + assert.equal(buildSkillInsertion("", 0, "two words"), null); + }); +}); diff --git a/desktop/src/features/messages/lib/composerAgentSkills.ts b/desktop/src/features/messages/lib/composerAgentSkills.ts new file mode 100644 index 00000000000..fab1e5d64dc --- /dev/null +++ b/desktop/src/features/messages/lib/composerAgentSkills.ts @@ -0,0 +1,123 @@ +import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; + +export type ComposerAgentSkill = { + description: string; + inputHint: string; + name: string; +}; + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + return typeof value === "object" && value !== null + ? (value as UnknownRecord) + : null; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeSkill(value: unknown): ComposerAgentSkill | null { + const record = asRecord(value); + const rawName = record ? asString(record.name) : asString(value); + const name = rawName.replace(/^\/+/, ""); + if (!name || /\s/.test(name)) return null; + + return { + description: record ? asString(record.description) : "", + inputHint: record ? asString(record.inputHint) : "", + name, + }; +} + +function availableSkillsFromEvent( + event: ObserverEvent, +): ComposerAgentSkill[] | null { + const payload = asRecord(event.payload); + if (payload?.method !== "session/update") return null; + const params = asRecord(payload.params); + const update = asRecord(params?.update); + if (update?.sessionUpdate !== "available_commands_update") return null; + if (!Array.isArray(update.availableCommands)) return []; + + const seen = new Set(); + const skills: ComposerAgentSkill[] = []; + for (const value of update.availableCommands) { + const skill = normalizeSkill(value); + if (!skill || seen.has(skill.name)) continue; + seen.add(skill.name); + skills.push(skill); + } + return skills; +} + +/** + * Return the commands most recently advertised by an agent session in a + * channel. A newer session without a command update intentionally returns an + * empty list instead of leaking stale commands from the previous session. + */ +export function extractAvailableAgentSkills( + events: readonly ObserverEvent[], + channelId: string | null, +): ComposerAgentSkill[] { + const scopedEvents = events.filter((event) => event.channelId === channelId); + let latestSessionId: string | null = null; + for (let index = scopedEvents.length - 1; index >= 0; index -= 1) { + const sessionId = scopedEvents[index]?.sessionId; + if (sessionId) { + latestSessionId = sessionId; + break; + } + } + const latestSessionTurnIds = latestSessionId + ? new Set( + scopedEvents + .filter((event) => event.sessionId === latestSessionId) + .map((event) => event.turnId) + .filter((turnId): turnId is string => turnId !== null), + ) + : null; + + for (let index = scopedEvents.length - 1; index >= 0; index -= 1) { + const event = scopedEvents[index]; + if (!event) continue; + const belongsToLatestSession = + !latestSessionId || + event.sessionId === latestSessionId || + (event.sessionId === null && + event.turnId !== null && + latestSessionTurnIds?.has(event.turnId)); + if (!belongsToLatestSession) continue; + const skills = availableSkillsFromEvent(event); + if (skills !== null) return skills; + } + return []; +} + +export function buildSkillInsertion( + text: string, + cursor: number, + skillName: string, +): { + insertText: string; + replaceFromOffset: number; + replaceToOffset: number; +} | null { + const name = skillName.trim().replace(/^\/+/, ""); + if (!name || /\s/.test(name)) return null; + + const safeCursor = Math.max(0, Math.min(cursor, text.length)); + const needsLeadingSpace = + safeCursor > 0 && !/\s/.test(text.charAt(safeCursor - 1)); + const needsTrailingSpace = + safeCursor === text.length || !/\s/.test(text.charAt(safeCursor)); + + return { + insertText: `${needsLeadingSpace ? " " : ""}/${name}${ + needsTrailingSpace ? " " : "" + }`, + replaceFromOffset: safeCursor, + replaceToOffset: safeCursor, + }; +} diff --git a/desktop/src/features/messages/lib/useComposerAgentSkills.ts b/desktop/src/features/messages/lib/useComposerAgentSkills.ts new file mode 100644 index 00000000000..76cf6a203c7 --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerAgentSkills.ts @@ -0,0 +1,35 @@ +import * as React from "react"; + +import { + getAgentObserverSnapshot, + subscribeAgentObserverStore, +} from "@/features/agents/observerRelayStore"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { extractAvailableAgentSkills } from "./composerAgentSkills"; + +export function useComposerAgentSkills( + agentPubkey: string | null, + channelId: string | null, +) { + const [revision, setRevision] = React.useState(0); + + React.useEffect(() => { + if (!agentPubkey) return; + const normalizedAgentPubkey = normalizePubkey(agentPubkey); + return subscribeAgentObserverStore((update) => { + if ( + !update || + normalizePubkey(update.agentPubkey) === normalizedAgentPubkey + ) { + setRevision((current) => current + 1); + } + }); + }, [agentPubkey]); + + if (!agentPubkey) return []; + void revision; + return extractAvailableAgentSkills( + getAgentObserverSnapshot(agentPubkey, true).events, + channelId, + ); +} diff --git a/desktop/src/features/messages/lib/useComposerInsertionActions.ts b/desktop/src/features/messages/lib/useComposerInsertionActions.ts new file mode 100644 index 00000000000..e20384f28fd --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerInsertionActions.ts @@ -0,0 +1,73 @@ +import * as React from "react"; + +import type { AutocompleteEdit } from "@/features/messages/lib/useRichTextEditor"; +import { + buildSkillInsertion, + type ComposerAgentSkill, +} from "./composerAgentSkills"; +import { useComposerAgentSkills } from "./useComposerAgentSkills"; + +type AddressedAgent = { displayName: string; pubkey: string }; +type PlainTextCursor = { cursor: number; text: string }; + +export function useComposerInsertionActions< + ChannelSuggestion, + EmojiSuggestion, +>({ + addressedAgents, + applyAutocompleteEdit, + channelId, + enabled, + getPlainTextAndCursor, + insertChannel, + insertEmoji, +}: { + addressedAgents: readonly AddressedAgent[]; + applyAutocompleteEdit: (edit: AutocompleteEdit) => void; + channelId: string | null; + enabled: boolean; + getPlainTextAndCursor: () => PlainTextCursor; + insertChannel: ( + suggestion: ChannelSuggestion, + cursor: number, + ) => AutocompleteEdit; + insertEmoji: ( + suggestion: EmojiSuggestion, + cursor: number, + ) => AutocompleteEdit; +}) { + const skillAgent = + enabled && addressedAgents.length === 1 ? addressedAgents[0] : null; + const skills = useComposerAgentSkills(skillAgent?.pubkey ?? null, channelId); + + const applyChannelInsert = React.useCallback( + (suggestion: ChannelSuggestion) => { + const { cursor } = getPlainTextAndCursor(); + applyAutocompleteEdit(insertChannel(suggestion, cursor)); + }, + [applyAutocompleteEdit, getPlainTextAndCursor, insertChannel], + ); + const applyEmojiInsert = React.useCallback( + (suggestion: EmojiSuggestion) => { + const { cursor } = getPlainTextAndCursor(); + applyAutocompleteEdit(insertEmoji(suggestion, cursor)); + }, + [applyAutocompleteEdit, getPlainTextAndCursor, insertEmoji], + ); + const insertSkill = React.useCallback( + (skill: ComposerAgentSkill) => { + const { cursor, text } = getPlainTextAndCursor(); + const edit = buildSkillInsertion(text, cursor, skill.name); + if (edit) applyAutocompleteEdit(edit); + }, + [applyAutocompleteEdit, getPlainTextAndCursor], + ); + + return { + applyChannelInsert, + applyEmojiInsert, + insertSkill, + skillAgentDisplayName: skillAgent?.displayName, + skills, + }; +} diff --git a/desktop/src/features/messages/ui/ComposerSkillPicker.tsx b/desktop/src/features/messages/ui/ComposerSkillPicker.tsx new file mode 100644 index 00000000000..f07f0062941 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerSkillPicker.tsx @@ -0,0 +1,183 @@ +import { Search, Zap } from "lucide-react"; +import * as React from "react"; + +import type { ComposerAgentSkill } from "@/features/messages/lib/composerAgentSkills"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +type ComposerSkillPickerProps = { + agentDisplayName: string; + disabled: boolean; + onClose: () => void; + onSelect: (skill: ComposerAgentSkill) => void; + onTriggerMouseDown: () => void; + skills: readonly ComposerAgentSkill[]; +}; + +export const ComposerSkillPicker = React.memo(function ComposerSkillPicker({ + agentDisplayName, + disabled, + onClose, + onSelect, + onTriggerMouseDown, + skills, +}: ComposerSkillPickerProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + + const filteredSkills = React.useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return skills; + return skills.filter( + (skill) => + skill.name.toLowerCase().includes(normalizedQuery) || + skill.description.toLowerCase().includes(normalizedQuery), + ); + }, [query, skills]); + + const activeHighlightedIndex = Math.min( + highlightedIndex, + Math.max(filteredSkills.length - 1, 0), + ); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(""); + setHighlightedIndex(0); + requestAnimationFrame(onClose); + } + }, + [onClose], + ); + + const selectSkill = React.useCallback( + (skill: ComposerAgentSkill) => { + onSelect(skill); + handleOpenChange(false); + }, + [handleOpenChange, onSelect], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" && filteredSkills.length > 0) { + event.preventDefault(); + setHighlightedIndex((current) => + Math.min(current + 1, filteredSkills.length - 1), + ); + } else if (event.key === "ArrowUp" && filteredSkills.length > 0) { + event.preventDefault(); + setHighlightedIndex((current) => Math.max(current - 1, 0)); + } else if (event.key === "Enter") { + const skill = filteredSkills[activeHighlightedIndex]; + if (skill) { + event.preventDefault(); + selectSkill(skill); + } + } else if (event.key === "Escape") { + event.preventDefault(); + handleOpenChange(false); + } + }, + [activeHighlightedIndex, filteredSkills, handleOpenChange, selectSkill], + ); + + if (skills.length === 0) return null; + + return ( + + + + + + + + Insert skill + + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + side="top" + sideOffset={10} + > +
+

Skills for {agentDisplayName}

+
+
+ + { + setQuery(event.target.value); + setHighlightedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Search skills…" + ref={(element) => element?.focus()} + spellCheck={false} + value={query} + /> +
+
event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + role="listbox" + > + {filteredSkills.length > 0 ? ( + filteredSkills.map((skill, index) => ( + + )) + ) : ( +

+ No skills match +

+ )} +
+
+
+ ); +}); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0cc8650795e..774050e72e4 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,16 +1,10 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; -import { - useChannelLinks, - type ChannelSuggestion, -} from "@/features/messages/lib/useChannelLinks"; +import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; -import { - useEmojiAutocomplete, - type EmojiSuggestion, -} from "@/features/messages/lib/useEmojiAutocomplete"; +import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { findSpoileredImetaMediaUrls, @@ -70,6 +64,7 @@ import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPrevi import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; +import { useComposerInsertionActions } from "@/features/messages/lib/useComposerInsertionActions"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ audienceContext = null, @@ -471,28 +466,21 @@ function MessageComposerImpl({ restoreAddressedAgentMentions(); }, [audienceScope, editTarget, restoreAddressedAgentMentions]); syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; - const applyChannelInsert = React.useCallback( - (suggestion: ChannelSuggestion) => { - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(channelLinks.insertChannel(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - channelLinks.insertChannel, - richText.getPlainTextAndCursor, - ], - ); - const applyEmojiInsert = React.useCallback( - (suggestion: EmojiSuggestion) => { - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(emojiAutocomplete.insertEmoji(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - emojiAutocomplete.insertEmoji, - richText.getPlainTextAndCursor, - ], - ); + const { + applyChannelInsert, + applyEmojiInsert, + insertSkill, + skillAgentDisplayName, + skills, + } = useComposerInsertionActions({ + addressedAgents: lockedAgents, + applyAutocompleteEdit, + channelId, + enabled: editTarget == null, + getPlainTextAndCursor: richText.getPlainTextAndCursor, + insertChannel: channelLinks.insertChannel, + insertEmoji: emojiAutocomplete.insertEmoji, + }); // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -960,9 +948,13 @@ function MessageComposerImpl({ onOpenMentionPicker={mentionPicker.openMentionSettings} onPaperclip={handlePaperclipClick} onRemoveAddressedAgent={removeAddressedAgent} + onSkillPickerClose={richText.focusPreserve} + onSkillSelect={insertSkill} pulseVersionByPubkey={addressPulse.pulseVersionByPubkey} sendDisabled={sendDisabled} shakeVersionByPubkey={addressPulse.shakeVersionByPubkey} + skillAgentDisplayName={skillAgentDisplayName} + skills={skills} /> diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index f5a44e6b7f3..d8c0ced4286 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -12,8 +12,10 @@ import { ComposerSendButton, } from "./ComposerAddressControls"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; +import { ComposerSkillPicker } from "./ComposerSkillPicker"; import { FormattingToolbar } from "./FormattingToolbar"; import { SelectionFormattingTray } from "./SelectionFormattingTray"; +import type { ComposerAgentSkill } from "@/features/messages/lib/composerAgentSkills"; /** Spring for enter/exit of button groups — all fire simultaneously. */ const presenceSpring = { @@ -37,6 +39,8 @@ export const MessageComposerToolbar = React.memo( isFormattingOpen, isSending, isUploading, + skillAgentDisplayName, + skills, onCaptureSelection, onAutoPinConfirmationDismiss, onAutoPinConfirmationHoverChange, @@ -48,6 +52,8 @@ export const MessageComposerToolbar = React.memo( onOpenMentionPicker, onPaperclip, onRemoveAddressedAgent = ignoreAddressRemoval, + onSkillPickerClose, + onSkillSelect, pulseVersionByPubkey, sendDisabled, shakeVersionByPubkey, @@ -63,6 +69,8 @@ export const MessageComposerToolbar = React.memo( isFormattingOpen: boolean; isSending: boolean; isUploading: boolean; + skillAgentDisplayName?: string; + skills?: readonly ComposerAgentSkill[]; onCaptureSelection: () => void; onAutoPinConfirmationDismiss?: () => void; onAutoPinConfirmationHoverChange?: (hovered: boolean) => void; @@ -74,6 +82,8 @@ export const MessageComposerToolbar = React.memo( onOpenMentionPicker: () => void; onPaperclip: () => void; onRemoveAddressedAgent?: (pubkey: string) => void; + onSkillPickerClose?: () => void; + onSkillSelect?: (skill: ComposerAgentSkill) => void; pulseVersionByPubkey?: Readonly>; sendDisabled: boolean; shakeVersionByPubkey?: Readonly>; @@ -223,6 +233,16 @@ export const MessageComposerToolbar = React.memo( onTriggerMouseDown={onCaptureSelection} open={isEmojiPickerOpen} /> + {skillAgentDisplayName && skills && onSkillSelect ? ( + {})} + onSelect={onSkillSelect} + onTriggerMouseDown={onCaptureSelection} + skills={skills} + /> + ) : null} typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + ); + await page.evaluate( + ({ agentPubkey, channelId, seq, timestamp }) => { + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey, + events: [ + { + seq, + timestamp, + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "skill-picker-session", + turnId: "skill-picker-turn", + payload: { + method: "session/update", + params: { + sessionId: "skill-picker-session", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "create_plan", + description: "Create a structured plan for the task", + }, + { + name: "research_codebase", + description: "Research and understand the codebase", + }, + ], + }, + }, + }, + }, + ], + }); + }, + { + agentPubkey: AGENT_A, + channelId: CHANNEL_ID, + seq: Date.now(), + timestamp: new Date().toISOString(), + }, + ); +} + +test("inserts a selected agent skill into the composer", async ({ page }) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + await automaticallyMention(composer, "Morgarita"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/skill-picker-before.png` }); + await seedAvailableSkills(page); + + const input = composer.getByTestId("message-input"); + await input.click(); + await page.keyboard.press("End"); + await input.pressSequentially("Start "); + const skillPicker = composer.getByTestId("composer-skill-picker"); + await expect(skillPicker).toBeVisible(); + await skillPicker.click(); + + const search = page.getByRole("textbox", { name: "Search skills" }); + await expect(search).toBeFocused(); + await search.fill("structured"); + await expect( + page.getByRole("option", { name: /\/create_plan/ }), + ).toBeVisible(); + await expect( + page.getByRole("option", { name: /\/research_codebase/ }), + ).toHaveCount(0); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/skill-picker-after.png` }); + + await page.getByRole("option", { name: /\/create_plan/ }).click(); + await expect(input).toContainText("/create_plan"); + await expect(input).toBeFocused(); +}); + test("automatically mentions multiple agents from the mention picker", async ({ page, }) => {