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 4e5482872..0c1bc1730 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"; @@ -182,6 +183,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..0b643b2df --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts @@ -0,0 +1,170 @@ +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"], + }, + { + name: "agent reporting an empty list suppresses the draft-store fallback", + contextTaskId: TASK_ID, + draftCommands: [ + { name: "fallback-only", description: "Should not appear" }, + ], + sessionCommands: [], + expectContains: ["good", "bad", "feedback"], + expectNotContains: ["fallback-only"], + }, +]; + +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..1a05728f4 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,15 @@ 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 authoritative 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. `null` means + // "agent hasn't reported yet"; an empty array means "agent reported empty" + // and we respect it. + const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : null; + const draftCommands = store.commands[sessionId] ?? []; + const agentCommands = 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 399a300e5..fff6f47f3 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts @@ -13,8 +13,6 @@ export function createCommandMention(options: CommandMentionOptions) { name: "commandMention", char: "/", chipType: "command", - startOfLine: true, - autoCommit: true, items: (query) => sessionId ? getCommandSuggestions(sessionId, query) : [], resolveChipAttrs: (item) => { diff --git a/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts b/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts index 6eb8deb02..64330e77b 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts @@ -1,6 +1,5 @@ import Placeholder from "@tiptap/extension-placeholder"; import StarterKit from "@tiptap/starter-kit"; -import { createCommandGhostText } from "./CommandGhostText"; import { createCommandMention } from "./CommandMention"; import { createFileMention } from "./FileMention"; import { createIssueMention } from "./IssueMention"; @@ -51,7 +50,6 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { if (commands) { extensions.push(createCommandMention({ sessionId })); - extensions.push(createCommandGhostText(sessionId)); } return extensions; diff --git a/apps/code/src/renderer/features/sessions/hooks/useSession.ts b/apps/code/src/renderer/features/sessions/hooks/useSession.ts index 12edb747c..31326083d 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSession.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSession.ts @@ -30,28 +30,36 @@ export const useSessionForTask = ( return s.sessions[taskRunId]; }); +/** + * Returns `null` when the agent hasn't sent an `available_commands_update` yet, + * so callers can distinguish that from an explicit empty list. + */ export const useAvailableCommandsForTask = ( taskId: string | undefined, -): AvailableCommand[] => { +): AvailableCommand[] | null => { return useSessionStore((s) => { - if (!taskId) return []; + if (!taskId) return null; const taskRunId = s.taskIdIndex[taskId]; - if (!taskRunId) return []; + if (!taskRunId) return null; const session = s.sessions[taskRunId]; - if (!session?.events) return []; + if (!session?.events) return null; return extractAvailableCommandsFromEvents(session.events); }, shallow); }; +/** + * Returns `null` when the agent hasn't sent an `available_commands_update` yet, + * so callers can distinguish that from an explicit empty list. + */ export function getAvailableCommandsForTask( taskId: string | undefined, -): AvailableCommand[] { - if (!taskId) return []; +): AvailableCommand[] | null { + if (!taskId) return null; const state = useSessionStore.getState(); const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return []; + if (!taskRunId) return null; const session = state.sessions[taskRunId]; - if (!session?.events) return []; + if (!session?.events) return null; return extractAvailableCommandsFromEvents(session.events); } 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 85253604a..b4c2b3475 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -38,7 +38,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, @@ -499,22 +499,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), diff --git a/apps/code/src/renderer/utils/session.ts b/apps/code/src/renderer/utils/session.ts index ec99a997d..721d6ca9d 100644 --- a/apps/code/src/renderer/utils/session.ts +++ b/apps/code/src/renderer/utils/session.ts @@ -155,10 +155,12 @@ export function convertStoredEntriesToEvents( /** * Extract available commands from session events. * Scans backwards to find the most recent available_commands_update. + * Returns `null` if the agent has not emitted one yet — callers can use this + * to distinguish "not yet received" from "received an empty list". */ export function extractAvailableCommandsFromEvents( events: AcpMessage[], -): AvailableCommand[] { +): AvailableCommand[] | null { for (let i = events.length - 1; i >= 0; i--) { const msg = events[i].message; if ( @@ -174,7 +176,7 @@ export function extractAvailableCommandsFromEvents( } } } - return []; + return null; } /**