diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 0b39464d4d..6fa9312e55 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -32,6 +32,8 @@ import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { perfLog } from "@/shared/lib/perfLog"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer"; +import type { SkillInfo } from "@/features/skills/api/skills"; +import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt"; export type AppView = | "home" @@ -339,6 +341,25 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [createNewTab], ); + const handleStartChatWithSkill = useCallback( + (skill: SkillInfo, projectId?: string | null) => { + const project = projectId + ? projectStore.projects.find((candidate) => candidate.id === projectId) + : undefined; + + void createNewTab(DEFAULT_CHAT_TITLE, project) + .then((session) => { + useChatStore + .getState() + .setSkillDrafts(session.id, [toChatSkillDraft(skill)]); + }) + .catch((error) => { + console.error("Failed to start chat with skill:", error); + }); + }, + [createNewTab, projectStore.projects], + ); + const handleNewChatInProject = useCallback( (projectId: string) => { const project = projectStore.projects.find((p) => p.id === projectId); @@ -708,6 +729,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onSelectSession={handleSelectSession} onSelectSearchResult={handleSelectSearchResult} onStartChatFromProject={handleStartChatFromProject} + onStartChatWithSkill={handleStartChatWithSkill} /> )} diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index c7b1fde8a6..1e09baa054 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -5,6 +5,7 @@ import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import type { SkillInfo } from "@/features/skills/api/skills"; import type { ProjectInfo } from "@/features/projects/api/projects"; import type { AppView } from "../AppShell"; @@ -27,6 +28,7 @@ interface AppShellContentProps { query?: string, ) => void; onStartChatFromProject: (project: ProjectInfo) => void; + onStartChatWithSkill: (skill: SkillInfo, projectId?: string | null) => void; } export function AppShellContent({ @@ -41,10 +43,11 @@ export function AppShellContent({ onSelectSession, onSelectSearchResult, onStartChatFromProject, + onStartChatWithSkill, }: AppShellContentProps) { switch (activeView) { case "skills": - return ; + return ; case "agents": return ; case "projects": diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts new file mode 100644 index 0000000000..f73b955049 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts @@ -0,0 +1,99 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useChatSessionStore } from "../../stores/chatSessionStore"; +import { useChatStore } from "../../stores/chatStore"; + +const mockAcpSendMessage = vi.fn(); +const mockAcpCancelSession = vi.fn(); +const mockAcpLoadSession = vi.fn(); +const mockAcpPrepareSession = vi.fn(); +const mockAcpSetModel = vi.fn(); +const mockGetGooseSessionId = vi.fn(); + +vi.mock("@/shared/api/acp", () => ({ + acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args), + acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args), + acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), + acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), + acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), +})); + +vi.mock("@/shared/api/acpSessionTracker", () => ({ + getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args), +})); + +import { useChat } from "../useChat"; + +describe("useChat skill chips", () => { + beforeEach(() => { + mockAcpSendMessage.mockReset(); + mockAcpCancelSession.mockReset(); + mockAcpLoadSession.mockReset(); + mockAcpPrepareSession.mockReset(); + mockAcpSetModel.mockReset(); + mockGetGooseSessionId.mockReset(); + mockAcpSendMessage.mockResolvedValue(undefined); + mockGetGooseSessionId.mockReturnValue(null); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + activeSessionId: null, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + contextPanelOpenBySession: {}, + activeWorkspaceBySession: {}, + }); + useAgentStore.setState({ + personas: [], + personasLoading: false, + agents: [], + agentsLoading: false, + activeAgentId: null, + isLoading: false, + personaEditorOpen: false, + editingPersona: null, + personaEditorMode: "create", + }); + }); + + it("stores user-visible chips separately from the agent prompt", async () => { + const { result } = renderHook(() => useChat("session-1")); + + await act(async () => { + await result.current.sendMessage( + "redo the settings modal", + undefined, + undefined, + { + displayText: "redo the settings modal", + assistantPrompt: "Use these skills for this request: capture-task.", + chips: [{ label: "capture-task", type: "skill" }], + }, + ); + }); + + const message = useChatStore.getState().messagesBySession["session-1"][0]; + expect(message.content).toEqual([ + { type: "text", text: "redo the settings modal" }, + ]); + expect(message.metadata?.chips).toEqual([ + { label: "capture-task", type: "skill" }, + ]); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "redo the settings modal", + { + assistantPrompt: "Use these skills for this request: capture-task.", + systemPrompt: undefined, + personaId: undefined, + personaName: undefined, + images: undefined, + }, + ); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 57cc0b0322..3dd2b0589e 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -28,6 +28,8 @@ import { } from "../lib/attachments"; import { sanitizeReplayMessages } from "../lib/replaySanitizer"; import { i18n } from "@/shared/i18n"; +import type { ChatSendOptions } from "../types"; +import { buildSkillRetryOptions } from "../lib/skillSendPayload"; // TODO: Remove this fallback once goose2 has first-class /-commands. const MANUAL_COMPACT_TRIGGER = "/compact"; @@ -146,16 +148,18 @@ export function useChat( text: string, overridePersona?: { id: string; name?: string }, attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, ) => { const sid = sessionId.slice(0, 8); const tSendStart = performance.now(); const images = buildAcpImages(attachments); const hasAttachments = (attachments?.length ?? 0) > 0; + const hasAssistantPrompt = Boolean(sendOptions?.assistantPrompt?.trim()); const currentChatState = useChatStore .getState() .getSessionRuntime(sessionId).chatState; if ( - (!text.trim() && !hasAttachments) || + (!text.trim() && !hasAttachments && !hasAssistantPrompt) || currentChatState === "streaming" || currentChatState === "thinking" || currentChatState === "compacting" @@ -180,8 +184,9 @@ export function useChat( // Create and add user message const userMessage = createUserMessage( - text, + sendOptions?.displayText ?? text, buildMessageAttachments(attachments), + sendOptions?.chips, ); if (effectivePersonaInfo) { userMessage.metadata = { @@ -250,6 +255,9 @@ export function useChat( ); await acpSendMessage(sessionId, acpPrompt, { systemPrompt, + ...(sendOptions?.assistantPrompt + ? { assistantPrompt: sendOptions.assistantPrompt } + : {}), personaId: effectivePersonaInfo?.id, personaName: effectivePersonaInfo?.name, images: images?.map( @@ -350,11 +358,17 @@ export function useChat( if (textContent && "text" in textContent) { const targetPersonaId = lastUserMessage.metadata?.targetPersonaId; const targetPersonaName = lastUserMessage.metadata?.targetPersonaName; - await sendMessage( + const retryOptions = buildSkillRetryOptions( textContent.text, + lastUserMessage.metadata?.chips, + ); + await sendMessage( + textContent.text || (retryOptions ? " " : ""), targetPersonaId ? { id: targetPersonaId, name: targetPersonaName } : undefined, + undefined, + retryOptions, ); } }, [sessionId, store, sendMessage]); diff --git a/ui/goose2/src/features/chat/hooks/useChatInputFilePicker.ts b/ui/goose2/src/features/chat/hooks/useChatInputFilePicker.ts new file mode 100644 index 0000000000..2f5945a737 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useChatInputFilePicker.ts @@ -0,0 +1,51 @@ +import { useCallback } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { useTranslation } from "react-i18next"; +import { normalizeDialogSelection } from "./useChatInputAttachments"; + +interface UseChatInputFilePickerOptions { + disabled: boolean; + addPathAttachments: (paths: string[]) => Promise; +} + +export function useChatInputFilePicker({ + disabled, + addPathAttachments, +}: UseChatInputFilePickerOptions) { + const { t } = useTranslation("chat"); + + const handleAttachFiles = useCallback(async () => { + if (disabled) { + return; + } + + try { + const selected = await open({ + title: t("attachments.chooseFilesDialogTitle"), + multiple: true, + }); + await addPathAttachments(normalizeDialogSelection(selected)); + } catch { + // Dialog plugin may be unavailable in some environments. + } + }, [addPathAttachments, disabled, t]); + + const handleAttachFolders = useCallback(async () => { + if (disabled) { + return; + } + + try { + const selected = await open({ + directory: true, + title: t("attachments.chooseFoldersDialogTitle"), + multiple: true, + }); + await addPathAttachments(normalizeDialogSelection(selected)); + } catch { + // Dialog plugin may be unavailable in some environments. + } + }, [addPathAttachments, disabled, t]); + + return { handleAttachFiles, handleAttachFolders }; +} diff --git a/ui/goose2/src/features/chat/hooks/useChatInputSubmit.ts b/ui/goose2/src/features/chat/hooks/useChatInputSubmit.ts new file mode 100644 index 0000000000..c6e0595459 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useChatInputSubmit.ts @@ -0,0 +1,70 @@ +import { useCallback, type RefObject } from "react"; +import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import { skillDraftSnapshotsMatch } from "../lib/chatInputSnapshots"; +import { submitComposerMessage } from "../lib/submitComposerMessage"; +import type { ChatInputProps, ChatSkillDraft } from "../types"; + +interface UseChatInputSubmitOptions { + attachmentsRef: RefObject; + selectedSkillsRef: RefObject; + selectedPersonaId?: string | null; + onSend: ChatInputProps["onSend"]; + setSelectedSkills: (skills: ChatSkillDraft[]) => void; + resolveSkillSlashCommand: ( + message: string, + ) => SkillCommandMatch | null; +} + +export function useChatInputSubmit({ + attachmentsRef, + selectedSkillsRef, + selectedPersonaId, + onSend, + setSelectedSkills, + resolveSkillSlashCommand, +}: UseChatInputSubmitOptions) { + const submitChatInputMessage = useCallback( + ( + submittedText: string, + submittedAttachments: ChatAttachmentDraft[], + submittedSkills: ChatSkillDraft[], + ) => + submitComposerMessage({ + text: submittedText, + attachments: submittedAttachments, + skills: submittedSkills, + selectedPersonaId, + onSend, + resolveSkillSlashCommand, + }), + [onSend, resolveSkillSlashCommand, selectedPersonaId], + ); + + const handleVoiceAutoSubmit = useCallback( + async (submittedText: string) => { + const submittedAttachments = attachmentsRef.current; + const submittedSkills = selectedSkillsRef.current; + const accepted = await submitChatInputMessage( + submittedText, + submittedAttachments, + submittedSkills, + ); + if ( + accepted && + skillDraftSnapshotsMatch(selectedSkillsRef.current, submittedSkills) + ) { + setSelectedSkills([]); + } + return accepted; + }, + [ + attachmentsRef, + selectedSkillsRef, + setSelectedSkills, + submitChatInputMessage, + ], + ); + + return { submitChatInputMessage, handleVoiceAutoSubmit }; +} diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index fcbdc7435d..7c04e6ea15 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ChatSkillDraft } from "../types"; import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import type { ChatSendOptions } from "../types"; import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; import { useChat } from "./useChat"; import { useAutoCompactPreferences } from "./useAutoCompactPreferences"; @@ -36,6 +38,7 @@ interface UseChatSessionControllerOptions { } const PENDING_HOME_SESSION_ID = "__home_pending__"; +const EMPTY_SKILL_DRAFTS: ChatSkillDraft[] = []; export function useChatSessionController({ sessionId, @@ -71,6 +74,10 @@ export function useChatSessionController({ const pendingDraftValue = useChatStore( (s) => s.draftsBySession[PENDING_HOME_SESSION_ID] ?? "", ); + const pendingSkillDrafts = useChatStore( + (s) => + s.skillDraftsBySession[PENDING_HOME_SESSION_ID] ?? EMPTY_SKILL_DRAFTS, + ); const pendingQueuedMessage = useChatStore( (s) => s.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null, ); @@ -478,9 +485,14 @@ export function useChatSessionController({ text: string, overridePersona?: { id: string; name?: string }, attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, ) => { if (!canAutoCompactBeforeSend(overridePersona)) { - void sendMessage(text, overridePersona, attachments); + if (sendOptions) { + void sendMessage(text, overridePersona, attachments, sendOptions); + } else { + void sendMessage(text, overridePersona, attachments); + } return true; } @@ -490,7 +502,11 @@ export function useChatSessionController({ return false; } - void sendMessage(text, overridePersona, attachments); + if (sendOptions) { + void sendMessage(text, overridePersona, attachments, sendOptions); + } else { + void sendMessage(text, overridePersona, attachments); + } return true; })(); }, @@ -505,6 +521,7 @@ export function useChatSessionController({ const deferredSend = useRef<{ text: string; attachments?: ChatAttachmentDraft[]; + sendOptions?: ChatSendOptions; resolve?: (accepted: boolean) => void; } | null>(null); const queue = useMessageQueue( @@ -514,10 +531,15 @@ export function useChatSessionController({ ); const handleSend = useCallback( - (text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => { + ( + text: string, + personaId?: string, + attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, + ) => { if (!sessionId) { if (!queue.queuedMessage) { - queue.enqueue(text, personaId, attachments); + queue.enqueue(text, personaId, attachments, sendOptions); } return true; } @@ -525,16 +547,16 @@ export function useChatSessionController({ if (personaId && personaId !== selectedPersonaId) { handlePersonaChange(personaId); return new Promise((resolve) => { - deferredSend.current = { text, attachments, resolve }; + deferredSend.current = { text, attachments, sendOptions, resolve }; }); } if (chatState !== "idle" && !queue.queuedMessage) { - queue.enqueue(text, personaId, attachments); + queue.enqueue(text, personaId, attachments, sendOptions); return true; } - return sendWithAutoCompact(text, undefined, attachments); + return sendWithAutoCompact(text, undefined, attachments, sendOptions); }, [ chatState, @@ -548,9 +570,14 @@ export function useChatSessionController({ useEffect(() => { if (deferredSend.current && selectedPersona) { - const { text, attachments, resolve } = deferredSend.current; + const { text, attachments, sendOptions, resolve } = deferredSend.current; deferredSend.current = null; - const sendResult = sendWithAutoCompact(text, undefined, attachments); + const sendResult = sendWithAutoCompact( + text, + undefined, + attachments, + sendOptions, + ); if (sendResult instanceof Promise) { void sendResult.then((accepted) => { if (accepted === false) { @@ -575,13 +602,25 @@ export function useChatSessionController({ const sessionDraftValue = useChatStore((s) => sessionId ? (s.draftsBySession[sessionId] ?? "") : "", ); + const sessionSkillDrafts = useChatStore((s) => + sessionId + ? (s.skillDraftsBySession[sessionId] ?? EMPTY_SKILL_DRAFTS) + : EMPTY_SKILL_DRAFTS, + ); const draftValue = sessionId ? sessionDraftValue : pendingDraftValue; + const selectedSkills = sessionId ? sessionSkillDrafts : pendingSkillDrafts; const handleDraftChange = useCallback( (text: string) => { useChatStore.getState().setDraft(stateSessionId, text); }, [stateSessionId], ); + const handleSkillsChange = useCallback( + (skills: typeof selectedSkills) => { + useChatStore.getState().setSkillDrafts(stateSessionId, skills); + }, + [stateSessionId], + ); const scrollTarget = useChatStore((s) => sessionId ? (s.scrollTargetMessageBySession[sessionId] ?? null) : null, ); @@ -599,16 +638,25 @@ export function useChatSessionController({ let cancelled = false; void pendingDraftValue; + void pendingSkillDrafts; void pendingQueuedMessage; const syncPendingHomeState = async () => { const chatState = useChatStore.getState(); const pendingDraft = chatState.draftsBySession[PENDING_HOME_SESSION_ID] ?? ""; + const pendingSkills = + chatState.skillDraftsBySession[PENDING_HOME_SESSION_ID] ?? []; if (pendingDraft && !chatState.draftsBySession[sessionId]) { chatState.setDraft(sessionId, pendingDraft); } + if ( + pendingSkills.length > 0 && + !chatState.skillDraftsBySession[sessionId]?.length + ) { + chatState.setSkillDrafts(sessionId, pendingSkills); + } const hasPendingProvider = pendingProviderId !== undefined; const hasPendingPersona = pendingPersonaId !== undefined; @@ -704,6 +752,7 @@ export function useChatSessionController({ } useChatStore.getState().clearDraft(PENDING_HOME_SESSION_ID); + useChatStore.getState().clearSkillDrafts(PENDING_HOME_SESSION_ID); useChatStore.getState().dismissQueuedMessage(PENDING_HOME_SESSION_ID); useChatStore.getState().cleanupSession(PENDING_HOME_SESSION_ID); }; @@ -716,6 +765,7 @@ export function useChatSessionController({ }, [ activeWorkspace?.path, pendingDraftValue, + pendingSkillDrafts, pendingModelSelection, pendingPersonaId, pendingProjectId, @@ -750,6 +800,8 @@ export function useChatSessionController({ handleSend, draftValue, handleDraftChange, + selectedSkills, + handleSkillsChange, scrollTarget, handleScrollTargetHandled, projectMetadataPending, diff --git a/ui/goose2/src/features/chat/hooks/useMentionHandlers.ts b/ui/goose2/src/features/chat/hooks/useMentionHandlers.ts index 1263d5b812..ab97f1f5bd 100644 --- a/ui/goose2/src/features/chat/hooks/useMentionHandlers.ts +++ b/ui/goose2/src/features/chat/hooks/useMentionHandlers.ts @@ -1,10 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { listSkills } from "@/features/skills/api/skills"; +import { + expandSkillSlashCommand, + resolveSkillSlashCommand, +} from "@/features/skills/lib/skillChatPrompt"; import { listFilesForMentions } from "@/shared/api/system"; import type { Persona } from "@/shared/types/agents"; import { useMentionDetection, type FileMentionItem, type MentionItem, + type SkillMentionItem, } from "../ui/MentionAutocomplete"; import { useArtifactPolicyContext } from "./ArtifactPolicyContext"; @@ -15,6 +21,7 @@ interface MentionHandlersOptions { setText: (value: string) => void; textareaRef: React.RefObject; onPersonaChange?: ((id: string | null) => void) | undefined; + onSkillMentionSelect?: (skill: SkillMentionItem) => void; } function basename(path: string): string { @@ -55,7 +62,7 @@ function sameStringArray(a: string[], b: string[]): boolean { } /** - * Combines persona + file mention detection, filtering, and selection handlers. + * Combines persona + skill + file mention detection, filtering, and selection handlers. * Keeps ChatInput under the file-size limit by centralising mention logic. */ export function useMentionHandlers({ @@ -65,6 +72,7 @@ export function useMentionHandlers({ setText, textareaRef, onPersonaChange, + onSkillMentionSelect, }: MentionHandlersOptions) { const { getAllSessionArtifacts } = useArtifactPolicyContext(); const normalizedProjectRoots = useMemo( @@ -76,6 +84,35 @@ export function useMentionHandlers({ [normalizedProjectRoots], ); const [projectFilePaths, setProjectFilePaths] = useState([]); + const [skillMentionItems, setSkillMentionItems] = useState< + SkillMentionItem[] + >([]); + + useEffect(() => { + let cancelled = false; + + void listSkills(normalizedProjectRoots) + .then((skills) => { + if (cancelled) return; + setSkillMentionItems( + skills.map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + sourceLabel: skill.sourceLabel, + })), + ); + }) + .catch((error) => { + if (cancelled) return; + console.error("Failed to load skills for mentions:", error); + setSkillMentionItems([]); + }); + + return () => { + cancelled = true; + }; + }, [normalizedProjectRoots]); useEffect(() => { // Clear stale results immediately so users never see files from the @@ -140,12 +177,13 @@ export function useMentionHandlers({ mentionStartIndex, mentionSelectedIndex, filteredPersonas, + filteredSkills, filteredFiles, detectMention, closeMention, navigateMention, confirmMention, - } = useMentionDetection(personas, fileMentionItems); + } = useMentionDetection(personas, skillMentionItems, fileMentionItems); // ---- post-selection cursor placement ------------------------------------ // After a mention is confirmed we update `text` via setState. A useEffect @@ -202,30 +240,63 @@ export function useMentionHandlers({ [text, mentionStartIndex, mentionQuery, closeMention, setText], ); + const handleSkillMentionSelect = useCallback( + (skill: SkillMentionItem) => { + const before = text.slice(0, mentionStartIndex); + const after = text.slice(mentionStartIndex + 1 + mentionQuery.length); + const newText = `${before}${after}`.trimStart(); + pendingCursorRef.current = Math.min(before.length, newText.length); + setText(newText); + closeMention(); + onSkillMentionSelect?.(skill); + }, + [ + text, + mentionStartIndex, + mentionQuery, + closeMention, + onSkillMentionSelect, + setText, + ], + ); + const handleMentionConfirm = useCallback( (item: MentionItem) => { if (item.type === "persona") { handlePersonaMentionSelect(item.persona); + } else if (item.type === "skill") { + handleSkillMentionSelect(item.skill); } else { handleFileMentionSelect(item.file); } }, - [handlePersonaMentionSelect, handleFileMentionSelect], + [ + handlePersonaMentionSelect, + handleSkillMentionSelect, + handleFileMentionSelect, + ], ); return { fileMentionItems, + skillMentionItems, mentionOpen, mentionQuery, mentionStartIndex, mentionSelectedIndex, filteredPersonas, + filteredSkills, filteredFiles, + expandSkillSlashCommand: (message: string) => + expandSkillSlashCommand(message, skillMentionItems), + resolveSkillSlashCommand: (message: string) => + resolveSkillSlashCommand(message, skillMentionItems), detectMention, closeMention, navigateMention, confirmMention, handlePersonaMentionSelect, + handleSkillMentionSelect, handleFileMentionSelect, handleMentionConfirm, }; diff --git a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts index 09138e506c..85a3eea621 100644 --- a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts +++ b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts @@ -3,6 +3,7 @@ import type { ChatState } from "@/shared/types/chat"; import { isPromiseLike } from "@/shared/lib/isPromiseLike"; import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { useChatStore } from "../stores/chatStore"; +import type { ChatSendOptions } from "../types"; const MAX_CONSECUTIVE_SEND_FAILURES = 2; @@ -11,6 +12,7 @@ function getQueuedMessageKey( text: string; personaId?: string; attachments?: ChatAttachmentDraft[]; + sendOptions?: ChatSendOptions; } | null, ): string | null { if (!queuedMessage) { @@ -20,6 +22,7 @@ function getQueuedMessageKey( return JSON.stringify({ text: queuedMessage.text, personaId: queuedMessage.personaId ?? null, + sendOptions: queuedMessage.sendOptions ?? null, attachments: queuedMessage.attachments?.map((attachment) => ({ id: attachment.id, @@ -45,6 +48,7 @@ export function useMessageQueue( text: string, overridePersona?: { id: string; name?: string }, attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, ) => boolean | Promise, ) { const queuedMessage = useChatStore( @@ -104,12 +108,19 @@ export function useMessageQueue( idleCycle: idleCycleRef.current, }; - const { text, personaId, attachments } = queuedMessage; - const sendResult = sendMessage( - text, - personaId ? { id: personaId } : undefined, - attachments, - ); + const { text, personaId, attachments, sendOptions } = queuedMessage; + const sendResult = sendOptions + ? sendMessage( + text, + personaId ? { id: personaId } : undefined, + attachments, + sendOptions, + ) + : sendMessage( + text, + personaId ? { id: personaId } : undefined, + attachments, + ); const finalize = (accepted: boolean | undefined) => { const latestQueuedMessage = @@ -145,11 +156,17 @@ export function useMessageQueue( }, [chatState, queuedMessage, queuedMessageKey, sendMessage, sessionId]); const enqueue = useCallback( - (text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => { + ( + text: string, + personaId?: string, + attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, + ) => { useChatStore.getState().enqueueMessage(sessionId, { text, personaId, attachments, + sendOptions, }); }, [sessionId], diff --git a/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts b/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts index c2be67afb5..de6bcea360 100644 --- a/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts +++ b/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts @@ -23,6 +23,7 @@ interface UseVoiceDictationOptions { personaId?: string, attachments?: ChatAttachmentDraft[], ) => boolean | Promise; + onAutoSubmit?: (text: string) => boolean | Promise; resetTextarea: () => void; /** * When true, auto-submit on trigger phrase will NOT call `onSend`. @@ -43,6 +44,7 @@ export function useVoiceDictation({ clearAttachments, selectedPersonaId, onSend, + onAutoSubmit, resetTextarea, isSendLocked = false, }: UseVoiceDictationOptions) { @@ -140,11 +142,13 @@ export function useVoiceDictation({ textRef.current = merged; return; } - const sendResult = onSend( - merged.trim(), - selectedPersonaId ?? undefined, - attachments.length > 0 ? attachments : undefined, - ); + const sendResult = onAutoSubmit + ? onAutoSubmit(merged.trim()) + : onSend( + merged.trim(), + selectedPersonaId ?? undefined, + attachments.length > 0 ? attachments : undefined, + ); if (isPromiseLike(sendResult)) { void sendResult .then((accepted) => { @@ -183,6 +187,7 @@ export function useVoiceDictation({ attachments, clearAttachments, isSendLocked, + onAutoSubmit, onSend, resetTextarea, selectedPersonaId, diff --git a/ui/goose2/src/features/chat/lib/chatInputPlaceholder.ts b/ui/goose2/src/features/chat/lib/chatInputPlaceholder.ts new file mode 100644 index 0000000000..2deb66c8a7 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/chatInputPlaceholder.ts @@ -0,0 +1,10 @@ +export function getChatInputPlaceholder( + t: (key: string, options?: { agent: string }) => string, + agent: string, + isRecording: boolean, + isTranscribing: boolean, +): string { + if (isRecording) return t("toolbar.voiceInputRecording"); + if (isTranscribing) return t("toolbar.voiceInputTranscribing"); + return t("input.placeholder", { agent }); +} diff --git a/ui/goose2/src/features/chat/lib/chatInputSnapshots.ts b/ui/goose2/src/features/chat/lib/chatInputSnapshots.ts new file mode 100644 index 0000000000..76f235ddb7 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/chatInputSnapshots.ts @@ -0,0 +1,22 @@ +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import type { ChatSkillDraft } from "../types"; + +export function attachmentSnapshotsMatch( + current: ChatAttachmentDraft[], + snapshot: ChatAttachmentDraft[], +) { + return ( + current.length === snapshot.length && + current.every((attachment, index) => attachment.id === snapshot[index]?.id) + ); +} + +export function skillDraftSnapshotsMatch( + current: ChatSkillDraft[], + snapshot: ChatSkillDraft[], +) { + return ( + current.length === snapshot.length && + current.every((skill, index) => skill.id === snapshot[index]?.id) + ); +} diff --git a/ui/goose2/src/features/chat/lib/skillSendPayload.ts b/ui/goose2/src/features/chat/lib/skillSendPayload.ts new file mode 100644 index 0000000000..de40c52af4 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/skillSendPayload.ts @@ -0,0 +1,68 @@ +import { + formatSkillInstructionPrompt, + type SkillCommandMatch, +} from "@/features/skills/lib/skillChatPrompt"; +import type { MessageChip } from "@/shared/types/messages"; +import type { ChatSendOptions, ChatSkillDraft } from "../types"; + +interface SkillSendPayload { + messageText: string; + sendOptions?: ChatSendOptions; +} + +export function buildSkillSendPayload( + submittedText: string, + submittedSkills: ChatSkillDraft[], + slashSkillCommand: SkillCommandMatch | null, +): SkillSendPayload { + const chips = + submittedSkills.length > 0 + ? submittedSkills.map((skill) => ({ + label: skill.name, + type: "skill" as const, + })) + : slashSkillCommand + ? [{ label: slashSkillCommand.skill.name, type: "skill" as const }] + : []; + + if (chips.length === 0) { + return { messageText: submittedText }; + } + + const skillsForPrompt = + submittedSkills.length > 0 + ? submittedSkills + : slashSkillCommand + ? [slashSkillCommand.skill] + : []; + const assistantPrompt = formatSkillInstructionPrompt(skillsForPrompt); + const displayText = + submittedSkills.length > 0 + ? submittedText.trim() + : (slashSkillCommand?.displayText ?? ""); + + return { + messageText: displayText || " ", + sendOptions: { + chips, + displayText, + assistantPrompt, + }, + }; +} + +export function buildSkillRetryOptions( + text: string, + chips?: MessageChip[], +): ChatSendOptions | undefined { + const skillChips = chips?.filter((chip) => chip.type === "skill") ?? []; + if (skillChips.length === 0) return undefined; + + return { + displayText: text, + assistantPrompt: formatSkillInstructionPrompt( + skillChips.map((chip) => ({ name: chip.label })), + ), + chips: skillChips, + }; +} diff --git a/ui/goose2/src/features/chat/lib/submitComposerMessage.ts b/ui/goose2/src/features/chat/lib/submitComposerMessage.ts new file mode 100644 index 0000000000..e8541b6dfb --- /dev/null +++ b/ui/goose2/src/features/chat/lib/submitComposerMessage.ts @@ -0,0 +1,50 @@ +import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt"; +import { isPromiseLike } from "@/shared/lib/isPromiseLike"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import type { ChatInputProps, ChatSkillDraft } from "../types"; +import { buildSkillSendPayload } from "./skillSendPayload"; + +interface SubmitComposerMessageOptions { + text: string; + attachments: ChatAttachmentDraft[]; + skills: ChatSkillDraft[]; + selectedPersonaId?: string | null; + onSend: ChatInputProps["onSend"]; + resolveSkillSlashCommand: ( + message: string, + ) => SkillCommandMatch | null; +} + +export async function submitComposerMessage({ + text, + attachments, + skills, + selectedPersonaId, + onSend, + resolveSkillSlashCommand, +}: SubmitComposerMessageOptions) { + const slashSkillCommand = + skills.length === 0 ? resolveSkillSlashCommand(text) : null; + const { messageText, sendOptions } = buildSkillSendPayload( + text, + skills, + slashSkillCommand, + ); + const submittedAttachments = attachments.length > 0 ? attachments : undefined; + const sendResult = sendOptions + ? onSend( + messageText, + selectedPersonaId ?? undefined, + submittedAttachments, + sendOptions, + ) + : onSend( + messageText.trim(), + selectedPersonaId ?? undefined, + submittedAttachments, + ); + const accepted = isPromiseLike(sendResult) + ? await sendResult + : sendResult; + return accepted !== false; +} diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts index 8f35b3c868..56b6c08db9 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts @@ -25,6 +25,7 @@ describe("chatStore", () => { sessionStateById: {}, queuedMessageBySession: {}, draftsBySession: {}, + skillDraftsBySession: {}, activeSessionId: null, isConnected: false, }); @@ -144,6 +145,18 @@ describe("chatStore", () => { expect(useChatStore.getState().draftsBySession.s1).toBeUndefined(); }); + it("stores and clears skill draft chips per session", () => { + const store = useChatStore.getState(); + + store.setSkillDrafts("s1", [{ id: "skill-1", name: "code-review" }]); + expect(useChatStore.getState().skillDraftsBySession.s1).toEqual([ + { id: "skill-1", name: "code-review" }, + ]); + + store.clearSkillDrafts("s1"); + expect(useChatStore.getState().skillDraftsBySession.s1).toBeUndefined(); + }); + it("removes session data during cleanup including queued messages and drafts", () => { const store = useChatStore.getState(); @@ -151,6 +164,7 @@ describe("chatStore", () => { store.setChatState("s1", "streaming"); store.enqueueMessage("s1", { text: "queued" }); store.setDraft("s1", "draft text"); + store.setSkillDrafts("s1", [{ id: "skill-1", name: "code-review" }]); store.setActiveSession("s1"); store.cleanupSession("s1"); @@ -158,6 +172,7 @@ describe("chatStore", () => { expect(store.sessionStateById.s1).toBeUndefined(); expect(store.queuedMessageBySession.s1).toBeUndefined(); expect(store.draftsBySession.s1).toBeUndefined(); + expect(useChatStore.getState().skillDraftsBySession.s1).toBeUndefined(); expect(store.activeSessionId).toBeNull(); }); @@ -187,6 +202,7 @@ describe("chatStore draft localStorage persistence", () => { sessionStateById: {}, queuedMessageBySession: {}, draftsBySession: {}, + skillDraftsBySession: {}, activeSessionId: null, isConnected: false, }); @@ -245,6 +261,7 @@ describe("chatStore session loading state", () => { sessionStateById: {}, queuedMessageBySession: {}, draftsBySession: {}, + skillDraftsBySession: {}, activeSessionId: null, isConnected: false, loadingSessionIds: new Set(), diff --git a/ui/goose2/src/features/chat/stores/chatStore.ts b/ui/goose2/src/features/chat/stores/chatStore.ts index 19c983e074..64544da750 100644 --- a/ui/goose2/src/features/chat/stores/chatStore.ts +++ b/ui/goose2/src/features/chat/stores/chatStore.ts @@ -14,36 +14,8 @@ import { INITIAL_SESSION_CHAT_RUNTIME, INITIAL_TOKEN_STATE, } from "@/shared/types/chat"; - -const DRAFTS_STORAGE_KEY = "goose:chat-drafts"; - -function loadCachedDrafts(): Record { - if (typeof window === "undefined") return {}; - try { - const stored = window.localStorage.getItem(DRAFTS_STORAGE_KEY); - if (!stored) return {}; - const parsed = JSON.parse(stored); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - return {}; - } -} - -function persistDrafts(drafts: Record): void { - if (typeof window === "undefined") return; - try { - const nonEmpty = Object.fromEntries( - Object.entries(drafts).filter(([, v]) => v.length > 0), - ); - if (Object.keys(nonEmpty).length === 0) { - window.localStorage.removeItem(DRAFTS_STORAGE_KEY); - } else { - window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(nonEmpty)); - } - } catch { - // localStorage may be unavailable - } -} +import type { ChatSendOptions, ChatSkillDraft } from "../types"; +import { loadCachedDrafts, persistDrafts } from "./draftPersistence"; function createInitialSessionRuntime(): SessionChatRuntime { return { @@ -56,6 +28,7 @@ export interface QueuedMessage { text: string; personaId?: string; attachments?: ChatAttachmentDraft[]; + sendOptions?: ChatSendOptions; } export interface ScrollTargetMessage { @@ -68,6 +41,7 @@ interface ChatStoreState { sessionStateById: Record; queuedMessageBySession: Record; draftsBySession: Record; + skillDraftsBySession: Record; activeSessionId: string | null; isConnected: boolean; loadingSessionIds: Set; @@ -113,6 +87,8 @@ interface ChatStoreActions { dismissQueuedMessage: (sessionId: string) => void; setDraft: (sessionId: string, text: string) => void; clearDraft: (sessionId: string) => void; + setSkillDrafts: (sessionId: string, skills: ChatSkillDraft[]) => void; + clearSkillDrafts: (sessionId: string) => void; setSessionLoading: (sessionId: string, loading: boolean) => void; setScrollTargetMessage: ( sessionId: string, @@ -131,6 +107,7 @@ export const useChatStore = create((set, get) => ({ sessionStateById: {}, queuedMessageBySession: {}, draftsBySession: loadCachedDrafts(), + skillDraftsBySession: {}, activeSessionId: null, isConnected: false, loadingSessionIds: new Set(), @@ -450,6 +427,27 @@ export const useChatStore = create((set, get) => ({ persistDrafts(get().draftsBySession); }, + setSkillDrafts: (sessionId, skills) => + set((state) => { + if (skills.length === 0) { + const { [sessionId]: _, ...rest } = state.skillDraftsBySession; + return { skillDraftsBySession: rest }; + } + + return { + skillDraftsBySession: { + ...state.skillDraftsBySession, + [sessionId]: skills, + }, + }; + }), + + clearSkillDrafts: (sessionId) => + set((state) => { + const { [sessionId]: _, ...rest } = state.skillDraftsBySession; + return { skillDraftsBySession: rest }; + }), + // Session loading (replay) setSessionLoading: (sessionId, loading) => set((state) => { @@ -495,13 +493,18 @@ export const useChatStore = create((set, get) => ({ const { [sessionId]: ___, ...remainingQueued } = state.queuedMessageBySession; const { [sessionId]: ____, ...remainingDrafts } = state.draftsBySession; - const { [sessionId]: _____, ...remainingTargets } = + const { [sessionId]: removedSkillDrafts, ...remainingSkillDrafts } = + state.skillDraftsBySession; + void removedSkillDrafts; + const { [sessionId]: removedTarget, ...remainingTargets } = state.scrollTargetMessageBySession; + void removedTarget; return { messagesBySession: rest, sessionStateById: remainingSessionState, queuedMessageBySession: remainingQueued, draftsBySession: remainingDrafts, + skillDraftsBySession: remainingSkillDrafts, scrollTargetMessageBySession: remainingTargets, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, diff --git a/ui/goose2/src/features/chat/stores/draftPersistence.ts b/ui/goose2/src/features/chat/stores/draftPersistence.ts new file mode 100644 index 0000000000..b0c8355895 --- /dev/null +++ b/ui/goose2/src/features/chat/stores/draftPersistence.ts @@ -0,0 +1,29 @@ +const DRAFTS_STORAGE_KEY = "goose:chat-drafts"; + +export function loadCachedDrafts(): Record { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(DRAFTS_STORAGE_KEY); + if (!stored) return {}; + const parsed = JSON.parse(stored); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +export function persistDrafts(drafts: Record): void { + if (typeof window === "undefined") return; + try { + const nonEmpty = Object.fromEntries( + Object.entries(drafts).filter(([, v]) => v.length > 0), + ); + if (Object.keys(nonEmpty).length === 0) { + window.localStorage.removeItem(DRAFTS_STORAGE_KEY); + } else { + window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(nonEmpty)); + } + } catch { + // localStorage may be unavailable + } +} diff --git a/ui/goose2/src/features/chat/types.ts b/ui/goose2/src/features/chat/types.ts index f8ac15e2a2..3472253bec 100644 --- a/ui/goose2/src/features/chat/types.ts +++ b/ui/goose2/src/features/chat/types.ts @@ -1,6 +1,6 @@ import type { AcpProvider } from "@/shared/api/acp"; import type { Persona } from "@/shared/types/agents"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import type { ChatAttachmentDraft, MessageChip } from "@/shared/types/messages"; export interface ModelOption { id: string; @@ -21,11 +21,25 @@ export interface ProjectOption { color?: string | null; } +export interface ChatSkillDraft { + id: string; + name: string; + description?: string; + sourceLabel?: string; +} + +export interface ChatSendOptions { + displayText?: string; + assistantPrompt?: string; + chips?: MessageChip[]; +} + export interface ChatInputProps { onSend: ( text: string, personaId?: string, attachments?: ChatAttachmentDraft[], + options?: ChatSendOptions, ) => boolean | Promise; onStop?: () => void; isStreaming?: boolean; @@ -34,11 +48,12 @@ export interface ChatInputProps { onDismissQueue?: () => void; initialValue?: string; onDraftChange?: (text: string) => void; + selectedSkills?: ChatSkillDraft[]; + onSkillsChange?: (skills: ChatSkillDraft[]) => void; className?: string; personas?: Persona[]; selectedPersonaId?: string | null; onPersonaChange?: (personaId: string | null) => void; - onCreatePersona?: () => void; providers?: AcpProvider[]; providersLoading?: boolean; selectedProvider?: string; diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index 0953bdda80..438b1494fe 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -1,37 +1,27 @@ import { useState, useRef, useCallback, useEffect, useMemo } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + attachmentSnapshotsMatch, + skillDraftSnapshotsMatch, +} from "../lib/chatInputSnapshots"; +import { getChatInputPlaceholder } from "../lib/chatInputPlaceholder"; import { cn } from "@/shared/lib/cn"; -import { isPromiseLike } from "@/shared/lib/isPromiseLike"; import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; import { Popover, PopoverAnchor } from "@/shared/ui/popover"; import { MentionAutocomplete } from "./MentionAutocomplete"; import { useMentionHandlers } from "../hooks/useMentionHandlers"; import { ChatInputToolbar } from "./ChatInputToolbar"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; import { TooltipProvider } from "@/shared/ui/tooltip"; -import { PersonaAvatar } from "./PersonaPicker"; import { useAttachmentDropTarget } from "../hooks/useAttachmentDropTarget"; -import { - normalizeDialogSelection, - useChatInputAttachments, -} from "../hooks/useChatInputAttachments"; +import { useChatInputAttachments } from "../hooks/useChatInputAttachments"; +import { useChatInputFilePicker } from "../hooks/useChatInputFilePicker"; import { ChatInputAttachments } from "./ChatInputAttachments"; +import { ChatInputSelectionChips } from "./ChatInputSelectionChips"; +import { useChatInputSubmit } from "../hooks/useChatInputSubmit"; import { useVoiceDictation } from "../hooks/useVoiceDictation"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; -import type { ChatInputProps } from "../types"; - -function attachmentSnapshotsMatch( - current: ChatAttachmentDraft[], - snapshot: ChatAttachmentDraft[], -) { - return ( - current.length === snapshot.length && - current.every((attachment, index) => attachment.id === snapshot[index]?.id) - ); -} +import type { ChatInputProps, ChatSkillDraft } from "../types"; export function ChatInput({ onSend, @@ -42,11 +32,12 @@ export function ChatInput({ onDismissQueue, initialValue = "", onDraftChange, + selectedSkills: selectedSkillsProp, + onSkillsChange, className, personas = [], selectedPersonaId = null, onPersonaChange, - onCreatePersona, providers = [], providersLoading = false, selectedProvider = "goose", @@ -71,6 +62,11 @@ export function ChatInput({ }: ChatInputProps) { const { t } = useTranslation("chat"); const [text, setTextRaw] = useState(initialValue); + const [internalSelectedSkills, setInternalSelectedSkills] = useState< + ChatSkillDraft[] + >([]); + const selectedSkills = selectedSkillsProp ?? internalSelectedSkills; + const setSelectedSkills = onSkillsChange ?? setInternalSelectedSkills; const textRef = useRef(initialValue); useEffect(() => { setTextRaw(initialValue); @@ -96,6 +92,8 @@ export function ChatInput({ } = useChatInputAttachments(); const attachmentsRef = useRef(attachments); attachmentsRef.current = attachments; + const selectedSkillsRef = useRef(selectedSkills); + selectedSkillsRef.current = selectedSkills; const resetTextarea = useCallback(() => { if (textareaRef.current) { @@ -105,17 +103,6 @@ export function ChatInput({ const hasQueuedMessage = queuedMessage !== null; - const dictation = useVoiceDictation({ - text, - setText, - attachments, - clearAttachments, - selectedPersonaId, - onSend, - resetTextarea, - isSendLocked: hasQueuedMessage || disabled, - }); - const activePersona = useMemo( () => personas.find((persona) => persona.id === selectedPersonaId) ?? null, [personas, selectedPersonaId], @@ -129,20 +116,37 @@ export function ChatInput({ const stickyPersona = activePersona; const canSend = - (text.trim().length > 0 || attachments.length > 0) && + (text.trim().length > 0 || + attachments.length > 0 || + selectedSkills.length > 0) && !hasQueuedMessage && !disabled; + const handleSkillMentionAdded = useCallback( + (skill: (typeof selectedSkills)[number]) => { + if ( + selectedSkills.some((selectedSkill) => selectedSkill.id === skill.id) + ) { + return; + } + setSelectedSkills([...selectedSkills, skill]); + }, + [selectedSkills, setSelectedSkills], + ); + const { mentionOpen, mentionSelectedIndex, filteredPersonas, + filteredSkills, filteredFiles, + resolveSkillSlashCommand, detectMention, closeMention, navigateMention, confirmMention, handlePersonaMentionSelect, + handleSkillMentionSelect, handleFileMentionSelect, handleMentionConfirm, } = useMentionHandlers({ @@ -152,6 +156,7 @@ export function ChatInput({ setText, textareaRef, onPersonaChange, + onSkillMentionSelect: handleSkillMentionAdded, }); useEffect(() => { @@ -171,21 +176,34 @@ export function ChatInput({ useEffect(() => textareaRef.current?.focus(), []); + const { submitChatInputMessage, handleVoiceAutoSubmit } = useChatInputSubmit({ + attachmentsRef, + selectedSkillsRef, + selectedPersonaId, + onSend, + setSelectedSkills, + resolveSkillSlashCommand, + }); + + const dictation = useVoiceDictation({ + text, + setText, + attachments, + clearAttachments, + selectedPersonaId, + onSend, + onAutoSubmit: handleVoiceAutoSubmit, + resetTextarea, + isSendLocked: hasQueuedMessage || disabled, + }); + const handleSend = useCallback(async () => { if (!canSend) { return; } - // If recording, stop without waiting for final flush and send what's - // already transcribed into the textarea. This makes Send a single click - // even while the mic is hot; any in-flight audio after the user clicked - // Send is intentionally dropped. - // - // Also handles the edge case where the user clicks Send while a - // getUserMedia startup is still pending (isRecording is still false but - // a stream is about to be acquired) — stopRecording sets the internal - // cancel flag so the pending startup tears itself down instead of - // leaving the OS mic indicator on. + // Stop without flushing so Send uses the text already in the composer. + // This also cancels an in-flight microphone startup. if ( dictation.isRecording || dictation.isTranscribing || @@ -195,25 +213,25 @@ export function ChatInput({ } const submittedText = text; + const submittedSkills = selectedSkills; const submittedAttachments = attachments; - const sendResult = onSend( - submittedText.trim(), - selectedPersonaId ?? undefined, - submittedAttachments.length > 0 ? submittedAttachments : undefined, + const accepted = await submitChatInputMessage( + submittedText, + submittedAttachments, + submittedSkills, ); - const accepted = isPromiseLike(sendResult) - ? await sendResult - : sendResult; - if (accepted === false) { + if (!accepted) { return; } const draftStillMatchesSubmission = textRef.current === submittedText && + skillDraftSnapshotsMatch(selectedSkillsRef.current, submittedSkills) && attachmentSnapshotsMatch(attachmentsRef.current, submittedAttachments); if (!draftStillMatchesSubmission) { return; } setText(""); + setSelectedSkills([]); clearAttachments(); if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -223,9 +241,10 @@ export function ChatInput({ canSend, clearAttachments, dictation, - onSend, - selectedPersonaId, + selectedSkills, + setSelectedSkills, setText, + submitChatInputMessage, text, ]); @@ -302,39 +321,10 @@ export function ChatInput({ void addPathAttachments(paths); }, }); - - const handleAttachFiles = useCallback(async () => { - if (disabled) { - return; - } - - try { - const selected = await open({ - title: t("attachments.chooseFilesDialogTitle"), - multiple: true, - }); - await addPathAttachments(normalizeDialogSelection(selected)); - } catch { - // Dialog plugin may be unavailable in some environments. - } - }, [addPathAttachments, disabled, t]); - - const handleAttachFolders = useCallback(async () => { - if (disabled) { - return; - } - - try { - const selected = await open({ - directory: true, - title: t("attachments.chooseFoldersDialogTitle"), - multiple: true, - }); - await addPathAttachments(normalizeDialogSelection(selected)); - } catch { - // Dialog plugin may be unavailable in some environments. - } - }, [addPathAttachments, disabled, t]); + const { handleAttachFiles, handleAttachFolders } = useChatInputFilePicker({ + disabled, + addPathAttachments, + }); const providerDisplayName = providers.find((provider) => provider.id === selectedProvider)?.label ?? @@ -352,14 +342,24 @@ export function ChatInput({ ); return selectedModel?.displayName ?? selectedModel?.name ?? currentModelId; }, [availableModels, currentModel, currentModelId]); - const effectivePlaceholder = t("input.placeholder", { - agent: agentDisplayName, - }); + const inputPlaceholder = getChatInputPlaceholder( + t, + agentDisplayName, + dictation.isRecording, + dictation.isTranscribing, + ); const handleClearStickyPersona = useCallback(() => { onPersonaChange?.(null); }, [onPersonaChange]); + const handleRemoveSkill = useCallback( + (skillId: string) => { + setSelectedSkills(selectedSkills.filter((skill) => skill.id !== skillId)); + }, + [selectedSkills, setSelectedSkills], + ); + return (
@@ -390,9 +390,11 @@ export function ChatInput({ - {stickyPersona && ( -
- - - @{stickyPersona.displayName} - - -
- )} + {queuedMessage && (
@@ -445,13 +435,7 @@ export function ChatInput({ onChange={handleInput} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder={ - dictation.isRecording - ? t("toolbar.voiceInputRecording") - : dictation.isTranscribing - ? t("toolbar.voiceInputTranscribing") - : effectivePlaceholder - } + placeholder={inputPlaceholder} disabled={disabled} rows={1} className="mb-3 min-h-[36px] max-h-[200px] w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed text-foreground placeholder:font-light placeholder:text-muted-foreground/60 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-60" @@ -460,10 +444,7 @@ export function ChatInput({ void; }) { const { t } = useTranslation("chat"); - const Icon = attachment.kind === "directory" ? FolderClosed : FileText; return ( -
} title={attachment.path ?? attachment.name} - > - - {attachment.name} - -
+ onRemove={() => onRemove(attachment.id)} + removeLabel={t("attachments.remove")} + /> ); } @@ -97,7 +91,7 @@ export function ChatInputAttachments({ } return ( -
+
{attachments.map((attachment, index) => attachment.kind === "image" ? ( void; + onRemoveSkill: (skillId: string) => void; +} + +export function ChatInputSelectionChips({ + persona, + skills, + onClearPersona, + onRemoveSkill, +}: ChatInputSelectionChipsProps) { + const { t } = useTranslation("chat"); + + if (!persona && skills.length === 0) { + return null; + } + + return ( +
+ {persona && ( + } + onRemove={onClearPersona} + removeLabel={t("persona.clearActive")} + /> + )} + {skills.map((skill) => ( + } + onRemove={() => onRemoveSkill(skill.id)} + removeLabel={t("skill.clearSelected", { + skill: skill.name, + })} + /> + ))} +
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index bde1f4c894..741fb8af69 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -12,11 +12,9 @@ import { useTranslation } from "react-i18next"; import { useLocaleFormatting } from "@/shared/i18n"; import { IconLibraryPlusFilled } from "@tabler/icons-react"; import type { AcpProvider } from "@/shared/api/acp"; -import type { Persona } from "@/shared/types/agents"; import { cn } from "@/shared/lib/cn"; import { ChatInputSelector } from "./ChatInputSelector"; import { ContextRing } from "./ContextRing"; -import { PersonaPicker } from "./PersonaPicker"; import type { ProjectOption } from "../types"; import { Button } from "@/shared/ui/button"; import { @@ -52,11 +50,7 @@ function ProjectDot({ color }: { color?: string | null }) { } interface ChatInputToolbarProps { - // Personas - personas: Persona[]; selectedPersonaId: string | null; - onPersonaChange?: (personaId: string | null) => void; - onCreatePersona?: () => void; // Provider providers: AcpProvider[]; providersLoading?: boolean; @@ -103,10 +97,7 @@ interface ChatInputToolbarProps { } export function ChatInputToolbar({ - personas, selectedPersonaId, - onPersonaChange, - onCreatePersona, providers, providersLoading, selectedProvider, @@ -298,16 +289,6 @@ export function ChatInputToolbar({ {/* Right side: actions */}
- {personas.length > 0 && ( - onPersonaChange?.(id)} - onCreatePersona={onCreatePersona} - triggerVariant="icon" - /> - )} - {showContextUsage && ( - + diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index 38ed5186e6..3d93ecbb70 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -119,6 +119,8 @@ export function ChatView({ onDismissQueue={controller.queue.dismiss} initialValue={controller.draftValue} onDraftChange={controller.handleDraftChange} + selectedSkills={controller.selectedSkills} + onSkillsChange={controller.handleSkillsChange} onStop={controller.stopStreaming} isStreaming={ controller.chatState === "streaming" || @@ -127,7 +129,6 @@ export function ChatView({ personas={controller.personas} selectedPersonaId={controller.selectedPersonaId} onPersonaChange={controller.handlePersonaChange} - onCreatePersona={controller.handleCreatePersona} providers={controller.pickerAgents} providersLoading={controller.providersLoading} selectedProvider={controller.selectedProvider} diff --git a/ui/goose2/src/features/chat/ui/ComposerChip.tsx b/ui/goose2/src/features/chat/ui/ComposerChip.tsx new file mode 100644 index 0000000000..0d6b9245d8 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/ComposerChip.tsx @@ -0,0 +1,61 @@ +import { X } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/shared/lib/cn"; + +type ComposerChipTone = "file" | "agent" | "skill" | "automation"; + +const toneClasses: Record = { + file: "bg-gray-100/70 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700", + agent: + "bg-blue-100/20 text-blue-700 hover:bg-blue-100/30 dark:bg-blue-100/10 dark:text-blue-100 dark:hover:bg-blue-100/15", + skill: + "bg-yellow-100/25 text-yellow-700 hover:bg-yellow-100/35 dark:bg-yellow-100/10 dark:text-yellow-100 dark:hover:bg-yellow-100/15", + automation: + "bg-green-100/20 text-green-700 hover:bg-green-100/30 dark:bg-green-100/10 dark:text-green-100 dark:hover:bg-green-100/15", +}; + +interface ComposerChipProps { + tone: ComposerChipTone; + label: string; + removeLabel: string; + onRemove: () => void; + leading?: ReactNode; + title?: string; + className?: string; +} + +export function ComposerChip({ + tone, + label, + removeLabel, + onRemove, + leading, + title, + className, +}: ComposerChipProps) { + return ( + + + {label} + + ); +} diff --git a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx index 4b9a0cbea7..6e5b5c22c1 100644 --- a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx +++ b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx @@ -1,54 +1,33 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Sparkles, User } from "lucide-react"; +import { Sparkles, User, Zap } from "lucide-react"; import { IconFile, IconFolder } from "@tabler/icons-react"; import { cn } from "@/shared/lib/cn"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import { PopoverContent } from "@/shared/ui/popover"; import type { Persona } from "@/shared/types/agents"; - -// --------------------------------------------------------------------------- -// Fuzzy subsequence matcher (fzf-style) -// --------------------------------------------------------------------------- - -/** Returns true when every character in `query` appears in `target` in order. */ -export function fuzzyMatch(query: string, target: string): boolean { - let qi = 0; - for (let ti = 0; ti < target.length && qi < query.length; ti++) { - if (query[qi] === target[ti]) qi++; - } - return qi === query.length; -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface FileMentionItem { - /** Absolute path used when inserting into the message. */ - resolvedPath: string; - /** Shortened display path (e.g. ~/project/src/foo.ts). */ - displayPath: string; - /** Just the filename portion. */ - filename: string; - kind: "file" | "folder" | "path"; -} - -export type MentionItem = - | { type: "persona"; persona: Persona } - | { type: "file"; file: FileMentionItem }; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- +import type { + FileMentionItem, + MentionItem, + SkillMentionItem, +} from "./mentionDetection"; +export { fuzzyMatch, useMentionDetection } from "./mentionDetection"; +export type { + FileMentionItem, + MentionItem, + SkillMentionItem, +} from "./mentionDetection"; interface MentionAutocompleteProps { /** Pre-filtered personas from the hook. */ filteredPersonas: Persona[]; + /** Pre-filtered skills from the hook. */ + filteredSkills?: SkillMentionItem[]; /** Pre-filtered files from the hook. */ filteredFiles?: FileMentionItem[]; isOpen: boolean; onSelectPersona: (persona: Persona) => void; + onSelectSkill?: (skill: SkillMentionItem) => void; onSelectFile?: (file: FileMentionItem) => void; onClose?: (() => void) | undefined; selectedIndex?: number; @@ -56,9 +35,11 @@ interface MentionAutocompleteProps { export function MentionAutocomplete({ filteredPersonas, + filteredSkills = [], filteredFiles = [], isOpen, onSelectPersona, + onSelectSkill, onSelectFile, selectedIndex: controlledIndex, }: MentionAutocompleteProps) { @@ -80,21 +61,26 @@ export function MentionAutocomplete({ type: "persona" as const, persona: p, })); + for (const skill of filteredSkills) { + result.push({ type: "skill" as const, skill }); + } for (const f of filteredFiles) { result.push({ type: "file" as const, file: f }); } return result; - }, [filteredPersonas, filteredFiles]); + }, [filteredPersonas, filteredSkills, filteredFiles]); const handleSelect = useCallback( (item: MentionItem) => { if (item.type === "persona") { onSelectPersona(item.persona); + } else if (item.type === "skill") { + onSelectSkill?.(item.skill); } else { onSelectFile?.(item.file); } }, - [onSelectPersona, onSelectFile], + [onSelectPersona, onSelectSkill, onSelectFile], ); if (!isOpen || items.length === 0) return null; @@ -152,13 +138,53 @@ export function MentionAutocomplete({ ))} + {filteredSkills.length > 0 && ( +
+ {t("mention.skillsTitle")} +
+ )} + {filteredSkills.map((skill, i) => { + const globalIndex = filteredPersonas.length + i; + return ( + + ); + })} + {filteredFiles.length > 0 && (
{t("mention.filesTitle")}
)} {filteredFiles.map((file, i) => { - const globalIndex = filteredPersonas.length + i; + const globalIndex = + filteredPersonas.length + filteredSkills.length + i; return (
diff --git a/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx b/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx new file mode 100644 index 0000000000..699b0b9ff7 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { Check, Copy, Pencil, RotateCcw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/shared/lib/cn"; +import { MessageAction, MessageActions } from "@/shared/ui/ai-elements/message"; + +interface MessageBubbleActionsProps { + isUser: boolean; + messageId: string; + timestamp: ReactNode; + textContent: string; + copied: boolean; + onCopy: () => void; + onRetryMessage?: (messageId: string) => void; + onEditMessage?: (messageId: string) => void; +} + +export function MessageBubbleActions({ + isUser, + messageId, + timestamp, + textContent, + copied, + onCopy, + onRetryMessage, + onEditMessage, +}: MessageBubbleActionsProps) { + const { t } = useTranslation(["chat", "common"]); + + return ( + + {isUser && timestamp} + {textContent && ( + + {copied ? ( + + ) : ( + + )} + + )} + {!isUser && onRetryMessage && ( + onRetryMessage(messageId)} + > + + + )} + {isUser && onEditMessage && ( + onEditMessage(messageId)} + > + + + )} + {!isUser && timestamp} + + ); +} diff --git a/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx b/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx new file mode 100644 index 0000000000..ad32dfade5 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx @@ -0,0 +1,28 @@ +import { IconStack2 } from "@tabler/icons-react"; +import { cn } from "@/shared/lib/cn"; +import type { MessageChip } from "@/shared/types/messages"; + +const messageChipClasses: Record = { + skill: + "bg-yellow-100/25 text-yellow-700 dark:bg-yellow-100/10 dark:text-yellow-100", + extension: + "bg-blue-100/20 text-blue-700 dark:bg-blue-100/10 dark:text-blue-100", + recipe: + "bg-green-100/20 text-green-700 dark:bg-green-100/10 dark:text-green-100", +}; + +export function MessageMetadataChip({ chip }: { chip: MessageChip }) { + const Icon = chip.type === "skill" ? IconStack2 : null; + + return ( + + {Icon ? : null} + {chip.label} + + ); +} diff --git a/ui/goose2/src/features/chat/ui/PersonaPicker.tsx b/ui/goose2/src/features/chat/ui/PersonaPicker.tsx index 0e422be8c5..6435f0a40d 100644 --- a/ui/goose2/src/features/chat/ui/PersonaPicker.tsx +++ b/ui/goose2/src/features/chat/ui/PersonaPicker.tsx @@ -194,10 +194,11 @@ function PersonaAvatar({ size = "sm", }: { persona?: Persona; - size?: "sm" | "md"; + size?: "xs" | "sm" | "md"; }) { - const dim = size === "sm" ? "h-4 w-4" : "h-6 w-6"; - const iconDim = size === "sm" ? "h-2.5 w-2.5" : "h-3.5 w-3.5"; + const dim = + size === "xs" ? "h-3.5 w-3.5" : size === "sm" ? "h-4 w-4" : "h-6 w-6"; + const iconDim = size === "md" ? "h-3.5 w-3.5" : "h-2.5 w-2.5"; const avatarSrc = useAvatarSrc(persona?.avatar); if (avatarSrc) { diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx index 1f8dbc3064..a5f09301b6 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.asyncSend.test.tsx @@ -33,6 +33,10 @@ vi.mock("@/shared/api/system", () => ({ mockListFilesForMentions(roots, maxResults), })); +vi.mock("@/features/skills/api/skills", () => ({ + listSkills: vi.fn().mockResolvedValue([]), +})); + describe("ChatInput async send handling", () => { beforeEach(() => { mockListFilesForMentions.mockClear(); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx index b99a26b21b..fb0138b6c9 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx @@ -46,6 +46,10 @@ vi.mock("@/shared/api/system", () => ({ readImageAttachment: (path: string) => mockReadImageAttachment(path), })); +vi.mock("@/features/skills/api/skills", () => ({ + listSkills: vi.fn().mockResolvedValue([]), +})); + const mockOpenDialog = vi.fn(); vi.mock("@tauri-apps/plugin-dialog", () => ({ open: (...args: unknown[]) => mockOpenDialog(...args), diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.skills.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.skills.test.tsx new file mode 100644 index 0000000000..38e6584c20 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.skills.test.tsx @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChatInput } from "../ChatInput"; + +const mockVoiceDictation = { + isEnabled: true, + isRecording: false, + isTranscribing: false, + isStarting: vi.fn(() => false), + stopRecording: vi.fn(), + toggleRecording: vi.fn(), +}; +let lastVoiceDictationOptions: { + onAutoSubmit?: (text: string) => boolean | Promise; +} | null = null; + +vi.mock("../../hooks/useVoiceDictation", () => ({ + useVoiceDictation: (options: { + onAutoSubmit?: (text: string) => boolean | Promise; + }) => { + lastVoiceDictationOptions = options; + return mockVoiceDictation; + }, +})); + +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]), + loading: false, + refresh: vi.fn(), + }), +})); + +vi.mock("@/shared/api/system", () => ({ + listFilesForMentions: vi.fn().mockResolvedValue([]), +})); + +type SkillMentionFixture = { + id: string; + name: string; + description: string; + sourceLabel: string; +}; +const mockListSkills = vi.fn< + (projectDirs?: string[]) => Promise +>(async () => []); +vi.mock("@/features/skills/api/skills", () => ({ + listSkills: (projectDirs?: string[]) => mockListSkills(projectDirs), +})); + +describe("ChatInput skill mentions", () => { + beforeEach(() => { + mockListSkills.mockClear(); + mockListSkills.mockResolvedValue([]); + lastVoiceDictationOptions = null; + mockVoiceDictation.isStarting.mockReset(); + mockVoiceDictation.isStarting.mockReturnValue(false); + }); + + it("shows skills in @mention results and creates a skill chip", async () => { + const user = userEvent.setup(); + mockListSkills.mockResolvedValue([ + { + id: "global:/skills/code-review", + name: "code-review", + description: "Reviews code", + sourceLabel: "Personal", + }, + ]); + + render(); + + await waitFor(() => { + expect(mockListSkills).toHaveBeenCalled(); + }); + + const input = screen.getByRole("textbox"); + await user.type(input, "@code"); + + expect(await screen.findByText("Skills")).toBeInTheDocument(); + + await user.click( + await screen.findByRole("option", { name: /code-review/i }), + ); + + expect(input).toHaveValue(""); + expect(screen.getByText("code-review")).toBeInTheDocument(); + }); + + it("expands selected skill chips before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.type(screen.getByRole("textbox"), "check this diff"); + await user.keyboard("{Enter}"); + + expect(onSend).toHaveBeenCalledWith( + "check this diff", + undefined, + undefined, + { + assistantPrompt: "Use these skills for this request: code-review.", + chips: [{ label: "code-review", type: "skill" }], + displayText: "check this diff", + }, + ); + }); + + it("expands direct slash skill commands before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + mockListSkills.mockResolvedValue([ + { + id: "global:/skills/code-review", + name: "code-review", + description: "Reviews code", + sourceLabel: "Personal", + }, + ]); + + render(); + + await waitFor(() => { + expect(mockListSkills).toHaveBeenCalled(); + }); + + const input = screen.getByRole("textbox"); + await user.type(input, "/code-review check this diff"); + await user.keyboard("{Enter}"); + + expect(onSend).toHaveBeenCalledWith( + "check this diff", + undefined, + undefined, + { + assistantPrompt: "Use these skills for this request: code-review.", + chips: [{ label: "code-review", type: "skill" }], + displayText: "check this diff", + }, + ); + }); + + it("expands colon-qualified slash skill commands before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + mockListSkills.mockResolvedValue([ + { + id: "global:/skills/github", + name: "github:github", + description: "Works with GitHub", + sourceLabel: "Personal", + }, + ]); + + render(); + + await waitFor(() => { + expect(mockListSkills).toHaveBeenCalled(); + }); + + const input = screen.getByRole("textbox"); + await user.type(input, "/github:github triage this PR"); + await user.keyboard("{Enter}"); + + expect(onSend).toHaveBeenCalledWith( + "triage this PR", + undefined, + undefined, + { + assistantPrompt: "Use these skills for this request: github:github.", + chips: [{ label: "github:github", type: "skill" }], + displayText: "triage this PR", + }, + ); + }); + + it("expands selected skill chips for voice auto-submit", async () => { + const onSend = vi.fn(); + const onSkillsChange = vi.fn(); + + render( + , + ); + + await act(async () => { + const accepted = + await lastVoiceDictationOptions?.onAutoSubmit?.("check this diff"); + expect(accepted).toBe(true); + }); + + expect(onSend).toHaveBeenCalledWith( + "check this diff", + undefined, + undefined, + { + assistantPrompt: "Use these skills for this request: code-review.", + chips: [{ label: "code-review", type: "skill" }], + displayText: "check this diff", + }, + ); + expect(onSkillsChange).toHaveBeenCalledWith([]); + }); + + it("does not expand reserved slash commands as skills", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + mockListSkills.mockResolvedValue([ + { + id: "global:/skills/compact", + name: "compact", + description: "A compacting skill", + sourceLabel: "Personal", + }, + ]); + + render(); + + await waitFor(() => { + expect(mockListSkills).toHaveBeenCalled(); + }); + + const input = screen.getByRole("textbox"); + await user.type(input, "/compact"); + await user.keyboard("{Enter}"); + + expect(onSend).toHaveBeenCalledWith("/compact", undefined, undefined); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index f59623df07..9ca9f8b2c7 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -36,6 +36,10 @@ vi.mock("@/shared/api/system", () => ({ mockListFilesForMentions(roots, maxResults), })); +vi.mock("@/features/skills/api/skills", () => ({ + listSkills: vi.fn().mockResolvedValue([]), +})); + const TEST_PERSONAS: Persona[] = [ { id: "builtin-solo", @@ -90,7 +94,9 @@ describe("ChatInput", () => { it("renders with default placeholder", () => { render(); expect( - screen.getByPlaceholderText("Message Goose, @ to mention agents"), + screen.getByPlaceholderText( + "Message Goose, @ to mention agents or skills", + ), ).toBeInTheDocument(); }); @@ -385,7 +391,7 @@ describe("ChatInput", () => { await user.click(screen.getByRole("option", { name: /reviewer/i })); expect(input).toHaveValue(""); - expect(screen.getByText("@Reviewer")).toBeInTheDocument(); + expect(screen.getByText("Reviewer")).toBeInTheDocument(); }); it("shows project files in @mention results and inserts the selected path", async () => { @@ -518,7 +524,6 @@ describe("ChatInput", () => { it("keeps the mic toggle enabled while recording even if voice input becomes unavailable", () => { render( { await user.keyboard("{Enter}"); expect(onSend).toHaveBeenCalledWith("hello", "reviewer", undefined); - expect(screen.getByText("@Reviewer")).toBeInTheDocument(); + expect(screen.getByText("Reviewer")).toBeInTheDocument(); }); }); diff --git a/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx index 9042702e04..3dea2c156f 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MentionAutocomplete.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { MentionAutocomplete, type FileMentionItem, + type SkillMentionItem, fuzzyMatch, } from "../MentionAutocomplete"; import { Popover, PopoverAnchor } from "@/shared/ui/popover"; @@ -48,9 +49,19 @@ const FILES: FileMentionItem[] = [ }, ]; +const SKILLS: SkillMentionItem[] = [ + { + id: "global:/skills/code-review", + name: "code-review", + description: "Reviews code before it ships", + sourceLabel: "Personal", + }, +]; + function renderAutocomplete(props: { selectedIndex?: number; filteredPersonas?: Persona[]; + filteredSkills?: SkillMentionItem[]; filteredFiles?: FileMentionItem[]; }) { return render( @@ -60,9 +71,11 @@ function renderAutocomplete(props: { @@ -77,6 +90,15 @@ describe("MentionAutocomplete", () => { expect(screen.getByText("file0.ts")).toBeInTheDocument(); }); + it("renders skill items", () => { + renderAutocomplete({ filteredSkills: SKILLS, filteredFiles: [] }); + expect(screen.getByText("Skills")).toBeInTheDocument(); + expect(screen.getByText("code-review")).toBeInTheDocument(); + expect( + screen.getByText("Reviews code before it ships"), + ).toBeInTheDocument(); + }); + it("calls scrollIntoView on the selected item", () => { const scrollIntoView = vi.fn(); Element.prototype.scrollIntoView = scrollIntoView; @@ -88,9 +110,11 @@ describe("MentionAutocomplete", () => { @@ -106,9 +130,11 @@ describe("MentionAutocomplete", () => { @@ -140,9 +166,11 @@ describe("MentionAutocomplete", () => { , diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.skillChips.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.skillChips.test.tsx new file mode 100644 index 0000000000..f726042f55 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.skillChips.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MessageBubble } from "../MessageBubble"; +import type { Message } from "@/shared/types/messages"; + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openPath: vi.fn(), +})); + +function userMessage(text: string, overrides: Partial = {}): Message { + return { + id: "u1", + role: "user", + created: Date.now(), + content: [{ type: "text", text }], + ...overrides, + }; +} + +describe("MessageBubble skill chips", () => { + it("renders user message chips from metadata", () => { + render( + , + ); + + expect(screen.getByText("capture-task")).toBeInTheDocument(); + expect(screen.getByText("redo the settings modal")).toBeInTheDocument(); + expect(screen.queryByText(/Use the capture-task skill/i)).toBeNull(); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/mentionDetection.ts b/ui/goose2/src/features/chat/ui/mentionDetection.ts new file mode 100644 index 0000000000..4be1a1417a --- /dev/null +++ b/ui/goose2/src/features/chat/ui/mentionDetection.ts @@ -0,0 +1,239 @@ +import { useCallback, useMemo, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { isReservedSlashCommand } from "@/features/skills/lib/skillChatPrompt"; +import type { Persona } from "@/shared/types/agents"; + +export function fuzzyMatch(query: string, target: string): boolean { + let qi = 0; + for (let ti = 0; ti < target.length && qi < query.length; ti++) { + if (query[qi] === target[ti]) qi++; + } + return qi === query.length; +} + +export interface FileMentionItem { + resolvedPath: string; + displayPath: string; + filename: string; + kind: "file" | "folder" | "path"; +} + +export interface SkillMentionItem { + id: string; + name: string; + description: string; + sourceLabel: string; +} + +export type MentionItem = + | { type: "persona"; persona: Persona } + | { type: "skill"; skill: SkillMentionItem } + | { type: "file"; file: FileMentionItem }; + +export function useMentionDetection( + personas: Persona[] = [], + skills: SkillMentionItem[] = [], + files: FileMentionItem[] = [], +) { + const [mentionState, setMentionState] = useState<{ + isOpen: boolean; + trigger: "@" | "/"; + query: string; + startIndex: number; + selectedIndex: number; + }>({ + isOpen: false, + trigger: "@", + query: "", + startIndex: -1, + selectedIndex: 0, + }); + + const { filteredPersonas, filteredSkills, filteredFiles } = useMemo(() => { + if (!mentionState.isOpen) { + return { + filteredPersonas: personas, + filteredSkills: skills, + filteredFiles: files, + }; + } + + const q = mentionState.query.toLowerCase(); + const matchesSkill = (skill: SkillMentionItem) => + fuzzyMatch(q, skill.name.toLowerCase()) || + fuzzyMatch(q, skill.description.toLowerCase()) || + fuzzyMatch(q, skill.sourceLabel.toLowerCase()); + const matchingSkills = q ? skills.filter(matchesSkill) : skills; + + if (mentionState.trigger === "/") { + return { + filteredPersonas: [], + filteredSkills: matchingSkills, + filteredFiles: [], + }; + } + + if (!q) { + return { + filteredPersonas: personas, + filteredSkills: skills, + filteredFiles: files, + }; + } + + return { + filteredPersonas: personas.filter((p) => + fuzzyMatch(q, p.displayName.toLowerCase()), + ), + filteredSkills: matchingSkills, + filteredFiles: files.filter( + (f) => + fuzzyMatch(q, f.filename.toLowerCase()) || + fuzzyMatch(q, f.displayPath.toLowerCase()), + ), + }; + }, [ + personas, + skills, + files, + mentionState.isOpen, + mentionState.query, + mentionState.trigger, + ]); + + const totalCount = + filteredPersonas.length + filteredSkills.length + filteredFiles.length; + + const detectMention = useCallback( + (value: string, cursorPos: number) => { + const beforeCursor = value.slice(0, cursorPos); + const lastAt = beforeCursor.lastIndexOf("@"); + const slashAtStart = beforeCursor.startsWith("/") ? 0 : -1; + + if (lastAt === -1 && slashAtStart === -1) { + if (mentionState.isOpen) closeMentionState(setMentionState); + return; + } + + if (slashAtStart === 0 && lastAt === -1) { + const query = beforeCursor.slice(1); + if ( + query.includes(" ") || + query.length > 50 || + isReservedSlashCommand(query) + ) { + if (mentionState.isOpen) closeMentionState(setMentionState); + return; + } + + setMentionState((prev) => ({ + isOpen: true, + trigger: "/", + query, + startIndex: 0, + selectedIndex: + prev.query !== query || prev.trigger !== "/" + ? 0 + : prev.selectedIndex, + })); + return; + } + + if (lastAt > 0 && !/\s/.test(beforeCursor[lastAt - 1])) { + if (mentionState.isOpen) closeMentionState(setMentionState); + return; + } + + const query = beforeCursor.slice(lastAt + 1); + if (query.includes(" ") || query.length > 50) { + if (mentionState.isOpen) closeMentionState(setMentionState); + return; + } + + setMentionState((prev) => ({ + isOpen: true, + trigger: "@", + query, + startIndex: lastAt, + selectedIndex: + prev.query !== query || prev.trigger !== "@" ? 0 : prev.selectedIndex, + })); + }, + [mentionState.isOpen], + ); + + const closeMention = useCallback(() => { + closeMentionState(setMentionState); + }, []); + + const navigateMention = useCallback( + (direction: "up" | "down"): boolean => { + if (!mentionState.isOpen || totalCount === 0) return false; + setMentionState((prev) => { + const delta = direction === "down" ? 1 : -1; + const next = (prev.selectedIndex + delta + totalCount) % totalCount; + return { ...prev, selectedIndex: next }; + }); + return true; + }, + [mentionState.isOpen, totalCount], + ); + + const confirmMention = useCallback((): MentionItem | null => { + if (!mentionState.isOpen || totalCount === 0) return null; + const idx = mentionState.selectedIndex; + if (idx < filteredPersonas.length) { + return { type: "persona", persona: filteredPersonas[idx] }; + } + const skillIdx = idx - filteredPersonas.length; + if (skillIdx < filteredSkills.length) { + return { type: "skill", skill: filteredSkills[skillIdx] }; + } + const fileIdx = skillIdx - filteredSkills.length; + if (fileIdx < filteredFiles.length) { + return { type: "file", file: filteredFiles[fileIdx] }; + } + return null; + }, [ + mentionState.isOpen, + mentionState.selectedIndex, + totalCount, + filteredPersonas, + filteredSkills, + filteredFiles, + ]); + + return { + mentionOpen: mentionState.isOpen, + mentionQuery: mentionState.query, + mentionStartIndex: mentionState.startIndex, + mentionSelectedIndex: mentionState.selectedIndex, + filteredPersonas, + filteredSkills, + filteredFiles, + detectMention, + closeMention, + navigateMention, + confirmMention, + }; +} + +function closeMentionState( + setMentionState: Dispatch< + SetStateAction<{ + isOpen: boolean; + trigger: "@" | "/"; + query: string; + startIndex: number; + selectedIndex: number; + }> + >, +) { + setMentionState({ + isOpen: false, + trigger: "@", + query: "", + startIndex: -1, + selectedIndex: 0, + }); +} diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx index 3492b20abb..143904465b 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx @@ -83,6 +83,10 @@ vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ }), })); +vi.mock("@/features/skills/api/skills", () => ({ + listSkills: vi.fn().mockResolvedValue([]), +})); + vi.mock("@/features/chat/hooks/useChatSessionController", () => ({ useChatSessionController: () => mockController, })); @@ -179,14 +183,16 @@ describe("HomeScreen", () => { it("renders the chat input placeholder with default agent name when no persona selected", () => { renderHome(); expect( - screen.getByPlaceholderText("Message Goose, @ to mention agents"), + screen.getByPlaceholderText( + "Message Goose, @ to mention agents or skills", + ), ).toBeInTheDocument(); }); - it("renders the assistant chooser affordance", () => { + it("renders the agent/model chooser affordance", () => { renderHome(); expect( - screen.getByRole("button", { name: /choose assistant/i }), + screen.getByRole("button", { name: /choose agent and model/i }), ).toBeInTheDocument(); }); @@ -200,17 +206,17 @@ describe("HomeScreen", () => { ).toBeInTheDocument(); }); - it("forwards persona selection through the shared session controller", async () => { + it("forwards agent selection through the shared session controller", async () => { vi.useRealTimers(); const user = userEvent.setup(); renderHome(); - await user.click(screen.getByRole("button", { name: /choose assistant/i })); - await user.click(screen.getByRole("menuitem", { name: /solo/i })); - - expect(mockController.handlePersonaChange).toHaveBeenLastCalledWith( - "builtin-solo", + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), ); + await user.click(screen.getByRole("button", { name: /claude code/i })); + + expect(setSelectedProvider).toHaveBeenLastCalledWith("claude-acp"); }); }); diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx index 77b1b2e477..55dc96f7d2 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx @@ -72,6 +72,8 @@ function HomeComposer({ onDismissQueue={controller.queue.dismiss} initialValue={controller.draftValue} onDraftChange={controller.handleDraftChange} + selectedSkills={controller.selectedSkills} + onSkillsChange={controller.handleSkillsChange} onStop={controller.stopStreaming} isStreaming={ controller.chatState === "streaming" || @@ -80,7 +82,6 @@ function HomeComposer({ personas={controller.personas} selectedPersonaId={controller.selectedPersonaId} onPersonaChange={controller.handlePersonaChange} - onCreatePersona={controller.handleCreatePersona} providers={controller.pickerAgents} providersLoading={controller.providersLoading} selectedProvider={controller.selectedProvider} diff --git a/ui/goose2/src/features/skills/lib/skillChatPrompt.ts b/ui/goose2/src/features/skills/lib/skillChatPrompt.ts new file mode 100644 index 0000000000..611a36c407 --- /dev/null +++ b/ui/goose2/src/features/skills/lib/skillChatPrompt.ts @@ -0,0 +1,130 @@ +import type { SkillInfo } from "../api/skills"; +import type { ChatSkillDraft } from "@/features/chat/types"; + +type SkillLike = Pick; +type SkillDraftLike = Pick; +export type SkillCommandMatch = { + skill: TSkill; + promptText: string; + displayText: string; +}; +const SKILL_INSTRUCTION_PREFIX = "Use these skills for this request:"; + +const RESERVED_SLASH_COMMANDS = new Set([ + "clear", + "compact", + "doctor", + "prompt", + "prompts", + "skills", +]); + +export function isReservedSlashCommand(command: string): boolean { + return RESERVED_SLASH_COMMANDS.has(command.trim().toLowerCase()); +} + +export function formatSkillChatPrompt( + skillName: string, + taskText = "", +): string { + const name = skillName.trim(); + const task = taskText.trimStart(); + if (!task) { + return `Use the ${name} skill`; + } + return `Use the ${name} skill to ${task}`; +} + +export function formatSkillDraftsChatPrompt( + skills: SkillDraftLike[], + taskText = "", +): string { + if (skills.length === 0) { + return taskText; + } + + if (skills.length === 1) { + return formatSkillChatPrompt(skills[0].name, taskText); + } + + const skillNames = skills + .map((skill) => skill.name.trim()) + .filter(Boolean) + .join(", "); + const task = taskText.trimStart(); + if (!task) { + return `Use the ${skillNames} skills`; + } + return `Use the ${skillNames} skills to ${task}`; +} + +export function formatSkillInstructionPrompt(skills: SkillDraftLike[]): string { + const skillNames = skills + .map((skill) => skill.name.trim()) + .filter(Boolean) + .join(", "); + return `${SKILL_INSTRUCTION_PREFIX} ${skillNames}.`; +} + +export function parseSkillInstructionPrompt(text: string): string[] { + const trimmed = text.trim(); + if (!trimmed.startsWith(SKILL_INSTRUCTION_PREFIX)) { + return []; + } + + return trimmed + .slice(SKILL_INSTRUCTION_PREFIX.length) + .trim() + .replace(/[.。]+$/, "") + .split(",") + .map((name) => name.trim()) + .filter(Boolean); +} + +export function toChatSkillDraft( + skill: Pick, +): ChatSkillDraft { + return { + id: skill.id, + name: skill.name, + description: skill.description, + sourceLabel: skill.sourceLabel, + }; +} + +export function expandSkillSlashCommand( + text: string, + skills: SkillLike[], +): string | null { + const match = resolveSkillSlashCommand(text, skills); + return match?.promptText ?? null; +} + +export function resolveSkillSlashCommand( + text: string, + skills: TSkill[], +): SkillCommandMatch | null { + const match = text.trimStart().match(/^\/(\S+)(?:\s+([\s\S]*))?$/); + if (!match) { + return null; + } + + const command = match[1].toLowerCase(); + if (isReservedSlashCommand(command)) { + return null; + } + + const skill = skills.find( + (candidate) => candidate.name.toLowerCase() === command, + ); + if (!skill) { + return null; + } + + const displayText = match[2]?.trimStart() ?? ""; + return { + skill, + promptText: formatSkillChatPrompt(skill.name, displayText), + displayText, + }; +} diff --git a/ui/goose2/src/features/skills/ui/SkillsListSections.tsx b/ui/goose2/src/features/skills/ui/SkillsListSections.tsx index 55b41caebd..9a2f7cb58d 100644 --- a/ui/goose2/src/features/skills/ui/SkillsListSections.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsListSections.tsx @@ -6,6 +6,8 @@ import { AccordionSectionTrigger, } from "@/shared/ui/accordion"; import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { IconMessagePlus } from "@tabler/icons-react"; import type { SkillViewInfo } from "../lib/skillCategories"; export interface SkillsSection { @@ -58,7 +60,7 @@ export function SkillsListSections({ {section.skills.map((skill) => (
+ + + + + +

{t("view.startChatShort")}

+
+
) : null}
))} diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index ed35d2b211..589ae9a024 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -122,7 +122,7 @@ function SkillCategoryFilter({ } interface SkillsViewProps { - onStartChatWithSkill?: (skillName: string, projectId?: string | null) => void; + onStartChatWithSkill?: (skill: SkillInfo, projectId?: string | null) => void; } function FilterButton({ @@ -369,7 +369,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) { const handleStartChat = useCallback( (skill: SkillInfo) => { - onStartChatWithSkill?.(skill.name, skill.projectLinks[0]?.id ?? null); + onStartChatWithSkill?.(skill, skill.projectLinks[0]?.id ?? null); }, [onStartChatWithSkill], ); diff --git a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx index 53daa70194..dacb6e157e 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx @@ -242,6 +242,43 @@ describe("SkillsView", () => { expect(screen.getByText("Quality")).toBeInTheDocument(); }); + it("starts a chat with the selected skill from the list", async () => { + listSkills.mockResolvedValue(mockSkills); + const onStartChatWithSkill = vi.fn(); + const user = userEvent.setup(); + + render(); + await screen.findByText("test-writer"); + + await user.click( + screen.getByRole("button", { name: "Start chat with test-writer" }), + ); + + expect(onStartChatWithSkill).toHaveBeenCalledWith( + expect.objectContaining({ name: "test-writer" }), + "project-alpha", + ); + }); + + it("starts a chat with the selected skill from the detail page", async () => { + listSkills.mockResolvedValue(mockSkills); + const onStartChatWithSkill = vi.fn(); + const user = userEvent.setup(); + + render(); + await screen.findByText("code-review"); + + await user.click( + screen.getByRole("button", { name: "Open code-review details" }), + ); + await user.click(screen.getByRole("button", { name: "Start chat" })); + + expect(onStartChatWithSkill).toHaveBeenCalledWith( + expect.objectContaining({ name: "code-review" }), + null, + ); + }); + it("returns to the list without losing filters", async () => { listSkills.mockResolvedValue(mockSkills); const user = userEvent.setup(); diff --git a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts index 89fabcb3b4..81341cf460 100644 --- a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts +++ b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts @@ -245,6 +245,49 @@ describe("acpNotificationHandler", () => { }); }); + it("replay restores skill chips from assistant-only user chunks", async () => { + const replaySessionId = "replay-skill-session"; + useChatStore.setState({ + loadingSessionIds: new Set([replaySessionId]), + }); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "user_message_chunk", + messageId: "user-1", + content: { + type: "text", + text: "Use these skills for this request: capture-task.", + annotations: { audience: ["assistant"] }, + }, + }, + } as never); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "user_message_chunk", + messageId: "user-1", + content: { + type: "text", + text: "redo the settings modal", + }, + }, + } as never); + + const buffer = getReplayBuffer(replaySessionId); + expect(buffer).toHaveLength(1); + expect(buffer?.[0]).toMatchObject({ + id: "user-1", + role: "user", + content: [{ type: "text", text: "redo the settings modal" }], + metadata: { + chips: [{ label: "capture-task", type: "skill" }], + }, + }); + }); + it("replay preserves gooseSessionId in MCP app payloads before tracker registration", async () => { const replaySessionId = "replay-goose-session-2"; useChatStore.setState({ diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 4506f48c58..f43c8fad62 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -20,6 +20,7 @@ export interface AcpProvider { export interface AcpSendMessageOptions { systemPrompt?: string; + assistantPrompt?: string; personaId?: string; personaName?: string; /** Image attachments as [base64Data, mimeType] pairs. */ @@ -64,7 +65,7 @@ export async function acpSendMessage( prompt: string, options: AcpSendMessageOptions = {}, ): Promise { - const { systemPrompt, personaId, images } = options; + const { systemPrompt, assistantPrompt, personaId, images } = options; const sid = sessionId.slice(0, 8); const tStart = performance.now(); @@ -81,6 +82,13 @@ export async function acpSendMessage( annotations: { audience: ["assistant"] }, }); } + if (assistantPrompt?.trim()) { + content.push({ + type: "text", + text: assistantPrompt, + annotations: { audience: ["assistant"] }, + }); + } content.push({ type: "text", text: prompt }); if (images) { for (const [data, mimeType] of images) { diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts index f0936598d0..61435bf809 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts @@ -5,16 +5,15 @@ import type { import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { - ensureReplayBuffer, getBufferedMessage, findLatestUnpairedToolRequest, } from "@/features/chat/hooks/replayBuffer"; import type { - TextContent, ToolRequestContent, ToolResponseContent, } from "@/shared/types/messages"; import type { AcpNotificationHandler } from "./acpConnection"; +import { handleReplayUserMessageChunk } from "./acpSkillReplayChips"; import { attachMcpAppPayload, extractToolResultText, @@ -168,31 +167,7 @@ function handleReplay( clearReplayAssistantMessage(sessionId); if (update.content.type !== "text" || !("text" in update.content)) break; const messageId = update.messageId ?? crypto.randomUUID(); - const buffer = ensureReplayBuffer(sessionId); - const existing = getBufferedMessage(sessionId, messageId); - // biome-ignore lint/suspicious/noExplicitAny: wire format has annotations but SDK types don't - const rawAnn = (update.content as any).annotations; - const ann: TextContent["annotations"] | undefined = - typeof rawAnn === "object" && rawAnn !== null ? rawAnn : undefined; - // Drop assistant-only blocks so they never enter chat state. - if ( - ann?.audience && - ann.audience.length > 0 && - !ann.audience.includes("user") - ) - break; - const textBlock = makeTextBlock(update.content.text, ann); - if (!existing) { - buffer.push({ - id: messageId, - role: "user", - created: Date.now(), - content: [textBlock], - metadata: { userVisible: true, agentVisible: true }, - }); - } else { - existing.content.push(textBlock); - } + handleReplayUserMessageChunk(sessionId, messageId, update.content); break; } @@ -469,13 +444,6 @@ function findStreamingMessageId(sessionId: string): string | null { .streamingMessageId; } -function makeTextBlock( - text: string, - ann?: TextContent["annotations"], -): TextContent { - return { type: "text", text, ...(ann ? { annotations: ann } : {}) }; -} - function ensureLiveAssistantMessage( sessionId: string, gooseSessionId: string, diff --git a/ui/goose2/src/shared/api/acpSkillReplayChips.ts b/ui/goose2/src/shared/api/acpSkillReplayChips.ts new file mode 100644 index 0000000000..872f8d8c1d --- /dev/null +++ b/ui/goose2/src/shared/api/acpSkillReplayChips.ts @@ -0,0 +1,124 @@ +import { parseSkillInstructionPrompt } from "@/features/skills/lib/skillChatPrompt"; +import { + ensureReplayBuffer, + getBufferedMessage, +} from "@/features/chat/hooks/replayBuffer"; +import type { MessageChip, TextContent } from "@/shared/types/messages"; + +const pendingReplayChips = new Map>(); + +export function getPendingReplayChips(sessionId: string, messageId: string) { + const byMessage = pendingReplayChips.get(sessionId); + return byMessage?.get(messageId) ?? []; +} + +export function setPendingReplayChips( + sessionId: string, + messageId: string, + chips: MessageChip[], +) { + if (chips.length === 0) return; + const byMessage = pendingReplayChips.get(sessionId) ?? new Map(); + byMessage.set(messageId, chips); + pendingReplayChips.set(sessionId, byMessage); +} + +export function clearPendingReplayChips(sessionId: string, messageId: string) { + const byMessage = pendingReplayChips.get(sessionId); + if (!byMessage) return; + byMessage.delete(messageId); + if (byMessage.size === 0) { + pendingReplayChips.delete(sessionId); + } +} + +export function skillInstructionToChips(text: string): MessageChip[] { + return parseSkillInstructionPrompt(text).map((label) => ({ + label, + type: "skill" as const, + })); +} + +export function handleReplayUserMessageChunk( + sessionId: string, + messageId: string, + content: { text: string }, +): void { + const buffer = ensureReplayBuffer(sessionId); + const existing = getBufferedMessage(sessionId, messageId); + const ann = getTextAnnotations(content); + + if (isAssistantOnly(ann)) { + const chips = skillInstructionToChips(content.text); + if (chips.length > 0) { + attachReplayChips(sessionId, messageId, existing, chips); + } + return; + } + + const textBlock = makeTextBlock(content.text, ann); + const chips = getPendingReplayChips(sessionId, messageId); + if (!existing) { + buffer.push({ + id: messageId, + role: "user", + created: Date.now(), + content: [textBlock], + metadata: { + userVisible: true, + agentVisible: true, + ...(chips.length > 0 ? { chips } : {}), + }, + }); + } else { + existing.content.push(textBlock); + attachReplayChips(sessionId, messageId, existing, chips); + } + clearPendingReplayChips(sessionId, messageId); +} + +export function clearSkillReplayChips(): void { + pendingReplayChips.clear(); +} + +function getTextAnnotations(content: { + text: string; + annotations?: unknown; +}): TextContent["annotations"] | undefined { + const rawAnn = content.annotations; + return typeof rawAnn === "object" && rawAnn !== null + ? (rawAnn as TextContent["annotations"]) + : undefined; +} + +function isAssistantOnly(ann?: TextContent["annotations"]) { + return Boolean( + ann?.audience && ann.audience.length > 0 && !ann.audience.includes("user"), + ); +} + +function attachReplayChips( + sessionId: string, + messageId: string, + existing: ReturnType, + chips: MessageChip[], +) { + if (chips.length === 0) return; + if (existing) { + existing.metadata = { + ...existing.metadata, + chips: [...(existing.metadata?.chips ?? []), ...chips], + }; + } else { + setPendingReplayChips(sessionId, messageId, chips); + } +} + +function makeTextBlock( + text: string, + ann?: TextContent["annotations"], +): TextContent { + return ann + ? { type: "text", text, annotations: ann } + : { type: "text", text }; +} diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index 1a0bdfcd74..32663d97e6 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -108,7 +108,7 @@ }, "input": { "ariaLabel": "Chat message input", - "placeholder": "Message {{agent}}, @ to mention agents" + "placeholder": "Message {{agent}}, @ to mention agents or skills" }, "loading": { "compacting": "Compacting conversation...", @@ -118,6 +118,7 @@ "mention": { "ariaLabel": "Mention suggestions", "title": "Mention an agent", + "skillsTitle": "Skills", "filesTitle": "Files" }, "notifications": { @@ -135,6 +136,9 @@ "clearActive": "Clear active assistant", "defaultDescription": "No agent selected - chat directly with Goose" }, + "skill": { + "clearSelected": "Remove {{skill}} skill" + }, "queue": { "dismiss": "Dismiss queued message", "label": "Queued: {{text}}" diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index c11e9df401..9f7b27ec38 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -108,7 +108,7 @@ }, "input": { "ariaLabel": "Entrada de mensaje del chat", - "placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes" + "placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes o habilidades" }, "loading": { "compacting": "Compactando conversación...", @@ -118,6 +118,7 @@ "mention": { "ariaLabel": "Sugerencias de menciones", "title": "Menciona un agente", + "skillsTitle": "Habilidades", "filesTitle": "Archivos" }, "notifications": { @@ -135,6 +136,9 @@ "clearActive": "Quitar asistente activo", "defaultDescription": "Sin agente seleccionado: chatea directamente con Goose" }, + "skill": { + "clearSelected": "Quitar habilidad {{skill}}" + }, "queue": { "dismiss": "Descartar mensaje en cola", "label": "En cola: {{text}}" diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts index ba4d434eff..6caac990ec 100644 --- a/ui/goose2/src/shared/types/messages.ts +++ b/ui/goose2/src/shared/types/messages.ts @@ -242,6 +242,7 @@ export function getTextContent(message: Message): string { export function createUserMessage( text: string, attachments?: MessageAttachment[], + chips?: MessageChip[], ): Message { return { id: crypto.randomUUID(), @@ -252,6 +253,7 @@ export function createUserMessage( userVisible: true, agentVisible: true, ...(attachments ? { attachments } : {}), + ...(chips && chips.length > 0 ? { chips } : {}), }, }; }