From 65f9ab38a0627782fa7435223e98a0192120a700 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 19 May 2026 16:47:27 +0000 Subject: [PATCH 1/2] fix(code): show user skills in running tasks and trigger / after spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash-command popup in the prompt input had two annoyances: 1. In a running session, the / popup only showed the built-in /good /bad /feedback commands until the agent's `available_commands_update` arrived. The fallback to the locally-fetched skills list was gated on the *absence* of a taskId, so running sessions never used it. 2. The popup only triggered when `/` was the first character of the prompt, because `CommandMention` was configured with `startOfLine: true`. Typing `please /skill` mid-prompt did nothing — inconsistent with `@` and `#`. Fixes: - Drop `startOfLine: true` from `CommandMention` so `/` works after a space. - Fall back to draft-store skills in `getCommandSuggestions` when the session hasn't yet emitted `available_commands_update`. - Move the skills loader from `TaskInput` into `PromptInput` so the session-view editor also populates the fallback list. Generated-By: PostHog Code Task-Id: 300b3a9a-c74e-42a2-9099-5ede198c7570 --- .../message-editor/components/PromptInput.tsx | 26 +++ .../suggestions/getSuggestions.test.ts | 149 ++++++++++++++++++ .../suggestions/getSuggestions.ts | 11 +- .../message-editor/tiptap/CommandMention.ts | 1 - .../task-detail/components/TaskInput.tsx | 18 +-- 5 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts 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..d00455b20 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts @@ -0,0 +1,149 @@ +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 = {}; + }); +} + +describe("getCommandSuggestions", () => { + beforeEach(resetStores); + + it("returns built-in /good /bad /feedback commands when nothing else is available", () => { + const suggestions = getCommandSuggestions(SESSION_ID, ""); + const names = suggestions.map((s) => s.command.name); + expect(names).toContain("good"); + expect(names).toContain("bad"); + expect(names).toContain("feedback"); + }); + + it("includes agent-supplied skills from session events when available", () => { + seedSessionContext(TASK_ID); + seedSessionAvailableCommands([ + { name: "review", description: "Review code" }, + { name: "ship-it", description: "Ship the change" }, + ]); + + const names = getCommandSuggestions(SESSION_ID, "").map( + (s) => s.command.name, + ); + + expect(names).toEqual(expect.arrayContaining(["review", "ship-it"])); + }); + + it("falls back to draft-store skills when the session has no available_commands_update yet", () => { + // Running task whose agent hasn't sent commands yet. + seedSessionContext(TASK_ID); + seedDraftCommands([{ name: "review", description: "Review code" }]); + + const names = getCommandSuggestions(SESSION_ID, "").map( + (s) => s.command.name, + ); + + expect(names).toContain("review"); + }); + + it("prefers agent-supplied commands over draft-store fallback once the session reports them", () => { + seedSessionContext(TASK_ID); + seedDraftCommands([ + { name: "fallback-only", description: "Should not appear" }, + ]); + seedSessionAvailableCommands([ + { name: "agent-cmd", description: "From agent" }, + ]); + + const names = getCommandSuggestions(SESSION_ID, "").map( + (s) => s.command.name, + ); + + expect(names).toContain("agent-cmd"); + expect(names).not.toContain("fallback-only"); + }); + + it("uses draft-store skills when there is no running task", () => { + seedDraftCommands([{ name: "my-skill", description: "User skill" }]); + + const names = getCommandSuggestions(SESSION_ID, "").map( + (s) => s.command.name, + ); + + expect(names).toContain("my-skill"); + }); +}); 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), From 4c251cbca0a69fd0db83851c9b12d17e686c164b Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 19 May 2026 16:56:49 +0000 Subject: [PATCH 2/2] test(code): parameterise getCommandSuggestions scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback — tests 2-5 shared most of their scaffolding; collapse into an `it.each` table so adding scenarios is a row, not another `it` block. Generated-By: PostHog Code Task-Id: 300b3a9a-c74e-42a2-9099-5ede198c7570 --- .../suggestions/getSuggestions.test.ts | 125 ++++++++++-------- 1 file changed, 68 insertions(+), 57 deletions(-) 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 index d00455b20..e1cdc2710 100644 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts @@ -83,67 +83,78 @@ function resetStores() { }); } -describe("getCommandSuggestions", () => { - beforeEach(resetStores); - - it("returns built-in /good /bad /feedback commands when nothing else is available", () => { - const suggestions = getCommandSuggestions(SESSION_ID, ""); - const names = suggestions.map((s) => s.command.name); - expect(names).toContain("good"); - expect(names).toContain("bad"); - expect(names).toContain("feedback"); - }); +interface Scenario { + name: string; + contextTaskId?: string; + sessionCommands?: { name: string; description: string }[]; + draftCommands?: { name: string; description: string }[]; + expectContains: string[]; + expectNotContains?: string[]; +} - it("includes agent-supplied skills from session events when available", () => { - seedSessionContext(TASK_ID); - seedSessionAvailableCommands([ +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" }, - ]); - - const names = getCommandSuggestions(SESSION_ID, "").map( - (s) => s.command.name, - ); - - expect(names).toEqual(expect.arrayContaining(["review", "ship-it"])); - }); - - it("falls back to draft-store skills when the session has no available_commands_update yet", () => { - // Running task whose agent hasn't sent commands yet. - seedSessionContext(TASK_ID); - seedDraftCommands([{ name: "review", description: "Review code" }]); - - const names = getCommandSuggestions(SESSION_ID, "").map( - (s) => s.command.name, - ); - - expect(names).toContain("review"); - }); - - it("prefers agent-supplied commands over draft-store fallback once the session reports them", () => { - seedSessionContext(TASK_ID); - seedDraftCommands([ + ], + 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" }, - ]); - seedSessionAvailableCommands([ - { name: "agent-cmd", description: "From agent" }, - ]); - - const names = getCommandSuggestions(SESSION_ID, "").map( - (s) => s.command.name, - ); - - expect(names).toContain("agent-cmd"); - expect(names).not.toContain("fallback-only"); - }); - - it("uses draft-store skills when there is no running task", () => { - seedDraftCommands([{ name: "my-skill", description: "User skill" }]); + ], + 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"], + }, +]; - const names = getCommandSuggestions(SESSION_ID, "").map( - (s) => s.command.name, - ); +describe("getCommandSuggestions", () => { + beforeEach(resetStores); - expect(names).toContain("my-skill"); - }); + 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); + } + }, + ); });