diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 9ebd58675..0e55506e2 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -4,6 +4,7 @@ import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { cycleModeOption } from "@renderer/features/sessions/stores/sessionStore"; +import { trpcClient } from "@renderer/trpc/client"; import { EditorContent } from "@tiptap/react"; import { hasOpenOverlay } from "@utils/overlay"; import clsx from "clsx"; @@ -176,6 +177,31 @@ export const PromptInput = forwardRef( clearFocusRequest(sessionId); }, [focusRequested, focus, clearFocusRequest, sessionId, isReady]); + // Populate the draft-store skills list as a fallback for the / command + // popup. The agent emits an `available_commands_update` shortly after a + // session starts, but typing `/` before that arrives would otherwise show + // only the built-in /good /bad /feedback commands. + useEffect(() => { + if (!enableCommands) return; + let cancelled = false; + trpcClient.skills.list + .query() + .then((skills) => { + if (cancelled) return; + useDraftStore.getState().actions.setCommands( + sessionId, + skills.map((s) => ({ name: s.name, description: s.description })), + ); + }) + .catch(() => { + // Best-effort fallback — agent-supplied commands remain authoritative. + }); + return () => { + cancelled = true; + useDraftStore.getState().actions.clearCommands(sessionId); + }; + }, [sessionId, enableCommands]); + useHotkeys( "escape", (e) => { diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts new file mode 100644 index 000000000..e1cdc2710 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts @@ -0,0 +1,160 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@renderer/trpc/client", () => ({ + trpc: { + git: { + searchGithubRefs: { + queryOptions: () => ({ queryKey: [], queryFn: () => [] }), + }, + }, + }, +})); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + secureStore: { + getItem: { query: vi.fn().mockResolvedValue(null) }, + setItem: { query: vi.fn().mockResolvedValue(undefined) }, + removeItem: { query: vi.fn().mockResolvedValue(undefined) }, + }, + }, +})); + +import { useSessionStore } from "../../sessions/stores/sessionStore"; +import { useDraftStore } from "../stores/draftStore"; +import { getCommandSuggestions } from "./getSuggestions"; + +const SESSION_ID = "task-123"; +const TASK_ID = "task-123"; +const TASK_RUN_ID = "run-1"; + +function seedDraftCommands(commands: { name: string; description: string }[]) { + useDraftStore.getState().actions.setCommands(SESSION_ID, commands); +} + +function seedSessionContext(taskId: string | undefined) { + useDraftStore.getState().actions.setContext(SESSION_ID, { taskId }); +} + +function seedSessionAvailableCommands( + commands: { name: string; description: string }[], +) { + const events: AcpMessage[] = [ + { + direction: "agent_to_client", + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: TASK_RUN_ID, + update: { + sessionUpdate: "available_commands_update", + availableCommands: commands, + }, + }, + }, + } as unknown as AcpMessage, + ]; + + useSessionStore.setState((state) => { + state.sessions[TASK_RUN_ID] = { + taskId: TASK_ID, + taskRunId: TASK_RUN_ID, + events, + processedLineCount: 0, + configOptions: [], + pendingPermissions: new Map(), + messageQueue: [], + optimisticItems: [], + } as unknown as (typeof state.sessions)[string]; + state.taskIdIndex[TASK_ID] = TASK_RUN_ID; + }); +} + +function resetStores() { + useDraftStore.setState((state) => { + state.commands = {}; + state.contexts = {}; + }); + useSessionStore.setState((state) => { + state.sessions = {}; + state.taskIdIndex = {}; + }); +} + +interface Scenario { + name: string; + contextTaskId?: string; + sessionCommands?: { name: string; description: string }[]; + draftCommands?: { name: string; description: string }[]; + expectContains: string[]; + expectNotContains?: string[]; +} + +const SCENARIOS: Scenario[] = [ + { + name: "built-ins are always present", + expectContains: ["good", "bad", "feedback"], + }, + { + name: "agent-supplied skills surface from session events", + contextTaskId: TASK_ID, + sessionCommands: [ + { name: "review", description: "Review code" }, + { name: "ship-it", description: "Ship the change" }, + ], + expectContains: ["review", "ship-it"], + }, + { + name: "falls back to draft-store skills when session has no commands_update yet", + contextTaskId: TASK_ID, + draftCommands: [{ name: "review", description: "Review code" }], + expectContains: ["review"], + }, + { + name: "agent-supplied commands win over draft-store fallback once reported", + contextTaskId: TASK_ID, + draftCommands: [ + { name: "fallback-only", description: "Should not appear" }, + ], + sessionCommands: [{ name: "agent-cmd", description: "From agent" }], + expectContains: ["agent-cmd"], + expectNotContains: ["fallback-only"], + }, + { + name: "uses draft-store skills when there is no running task", + draftCommands: [{ name: "my-skill", description: "User skill" }], + expectContains: ["my-skill"], + }, +]; + +describe("getCommandSuggestions", () => { + beforeEach(resetStores); + + it.each(SCENARIOS)( + "$name", + ({ + contextTaskId, + sessionCommands, + draftCommands, + expectContains, + expectNotContains, + }) => { + if (contextTaskId) seedSessionContext(contextTaskId); + if (draftCommands) seedDraftCommands(draftCommands); + if (sessionCommands) seedSessionAvailableCommands(sessionCommands); + + const names = getCommandSuggestions(SESSION_ID, "").map( + (s) => s.command.name, + ); + + for (const expected of expectContains) { + expect(names).toContain(expected); + } + for (const unexpected of expectNotContains ?? []) { + expect(names).not.toContain(unexpected); + } + }, + ); +}); diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts index 14aded371..f09eb2ac4 100644 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts @@ -159,9 +159,14 @@ export function getCommandSuggestions( ): CommandSuggestionItem[] { const store = useDraftStore.getState(); const taskId = store.contexts[sessionId]?.taskId; - const agentCommands = taskId - ? getAvailableCommandsForTask(taskId) - : (store.commands[sessionId] ?? []); + // Agent commands (from `available_commands_update`) are the source of truth + // once a session has reported them, but they arrive async after session + // startup — fall back to the trpc-fetched skills list so users don't see only + // the built-in /good /bad /feedback commands during that window. + const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : []; + const draftCommands = store.commands[sessionId] ?? []; + const agentCommands = + sessionCommands.length > 0 ? sessionCommands : draftCommands; const merged = [...CODE_COMMANDS, ...agentCommands]; const commands = [...new Map(merged.map((cmd) => [cmd.name, cmd])).values()]; const filtered = searchCommands(commands, query); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts index 80558b937..dfafa7c6f 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts @@ -12,7 +12,6 @@ export function createCommandMention(options: CommandMentionOptions) { name: "commandMention", char: "/", chipType: "command", - startOfLine: true, items: (query) => sessionId ? getCommandSuggestions(sessionId, query) : [], }); diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 0f7e3b571..4875400af 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -35,7 +35,7 @@ import { ButtonGroup } from "@posthog/quill"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { type TaskInputReportAssociation, @@ -506,22 +506,6 @@ export function TaskInput({ const { isOnline } = useConnectivity(); const promptSessionId = sessionId; - // Populate command list for @ file mentions + / skills on mount - useEffect(() => { - let cancelled = false; - trpcClient.skills.list.query().then((skills) => { - if (cancelled) return; - useDraftStore.getState().actions.setCommands( - promptSessionId, - skills.map((s) => ({ name: s.name, description: s.description })), - ); - }); - return () => { - cancelled = true; - useDraftStore.getState().actions.clearCommands(promptSessionId); - }; - }, [promptSessionId]); - const hasHistory = useTaskInputHistoryStore((s) => s.entries.length > 0); const getPromptHistory = useCallback( () => useTaskInputHistoryStore.getState().entries.map((e) => e.text),