Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -176,6 +177,31 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export function createCommandMention(options: CommandMentionOptions) {
name: "commandMention",
char: "/",
chipType: "command",
startOfLine: true,
items: (query) =>
sessionId ? getCommandSuggestions(sessionId, query) : [],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Loading