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 @@ -182,6 +183,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,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);
}
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -51,7 +50,6 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {

if (commands) {
extensions.push(createCommandMention({ sessionId }));
extensions.push(createCommandGhostText(sessionId));
}

return extensions;
Expand Down
24 changes: 16 additions & 8 deletions apps/code/src/renderer/features/sessions/hooks/useSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 4 additions & 2 deletions apps/code/src/renderer/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -174,7 +176,7 @@ export function extractAvailableCommandsFromEvents(
}
}
}
return [];
return null;
}

/**
Expand Down
Loading