Add slash commands for ending and restarting agents (#1034)
* Add slash commands for ending agents * Stabilize slash command e2e submit * Unslop client slash command draft setup * Fix slash commands while agent is running * Fix slash command submit race * Reshape client slash command execution * Fix slash command tab cleanup
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
applyFileMentionReplacement,
|
||||
findActiveFileMention,
|
||||
@@ -20,10 +25,13 @@ interface UseAgentAutocompleteInput {
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
onAutocompleteApplied?: () => void;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => void;
|
||||
canExecuteClientSlashCommand?: boolean;
|
||||
}
|
||||
|
||||
type AgentAutocompleteOption =
|
||||
| (AutocompleteOption & { type: "command" })
|
||||
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
|
||||
| (AutocompleteOption & { type: "provider_command" })
|
||||
| (AutocompleteOption & {
|
||||
type: "workspace_entry";
|
||||
entryPath: string;
|
||||
@@ -47,6 +55,10 @@ interface DirectorySuggestionEntry {
|
||||
kind: "file" | "directory";
|
||||
}
|
||||
|
||||
type AvailableCommand =
|
||||
| { source: "client"; command: ClientSlashCommand }
|
||||
| { source: "provider"; command: AgentSlashCommand };
|
||||
|
||||
function normalizeDraftCommandConfig(
|
||||
draftConfig?: DraftCommandConfig,
|
||||
): DraftCommandConfig | undefined {
|
||||
@@ -96,6 +108,28 @@ function mapDirectorySuggestionsToEntries(payload: {
|
||||
}));
|
||||
}
|
||||
|
||||
function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
|
||||
const command = entry.command;
|
||||
const base = {
|
||||
id: command.name,
|
||||
label: `/${command.name}`,
|
||||
detail: command.argumentHint || undefined,
|
||||
description: command.description,
|
||||
kind: "command" as const,
|
||||
};
|
||||
if (entry.source === "client") {
|
||||
return {
|
||||
...base,
|
||||
type: "client_command",
|
||||
command: entry.command,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: "provider_command",
|
||||
};
|
||||
}
|
||||
|
||||
type AutocompleteMode = "command" | "file" | null;
|
||||
|
||||
function resolveAutocompleteMode(args: {
|
||||
@@ -170,6 +204,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
agentId,
|
||||
draftConfig,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
canExecuteClientSlashCommand,
|
||||
} = input;
|
||||
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
@@ -277,16 +313,22 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
|
||||
if (mode === "command") {
|
||||
const filterLower = commandFilterQuery.toLowerCase();
|
||||
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
|
||||
const providerCommands = commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const availableCommands: AvailableCommand[] = isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands,
|
||||
];
|
||||
const matches = availableCommands.filter((entry) =>
|
||||
entry.command.name.toLowerCase().includes(filterLower),
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map((cmd) => ({
|
||||
type: "command" as const,
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
detail: cmd.argumentHint || undefined,
|
||||
description: cmd.description,
|
||||
kind: "command",
|
||||
}));
|
||||
return orderedMatches.map(mapCommandToOption);
|
||||
}
|
||||
|
||||
if (mode === "file" && activeFileMention) {
|
||||
@@ -302,12 +344,30 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]);
|
||||
}, [
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
const selected = option as AgentAutocompleteOption;
|
||||
if (selected.type === "command") {
|
||||
if (
|
||||
selected.type === "client_command" &&
|
||||
selected.command.execution === "immediate" &&
|
||||
canExecuteClientSlashCommand &&
|
||||
onClientSlashCommand
|
||||
) {
|
||||
onClientSlashCommand(selected.command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.type === "client_command" || selected.type === "provider_command") {
|
||||
setUserInput(`/${selected.id} `);
|
||||
onAutocompleteApplied?.();
|
||||
return;
|
||||
@@ -321,7 +381,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
setUserInput(nextInput);
|
||||
onAutocompleteApplied?.();
|
||||
},
|
||||
[onAutocompleteApplied, setUserInput, userInput],
|
||||
[
|
||||
canExecuteClientSlashCommand,
|
||||
onAutocompleteApplied,
|
||||
onClientSlashCommand,
|
||||
setUserInput,
|
||||
userInput,
|
||||
],
|
||||
);
|
||||
|
||||
const { selectedIndex, onKeyPress } = useAutocomplete({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||
|
||||
interface AgentSlashCommand {
|
||||
export interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ type AttachmentUpdater =
|
||||
interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
isVisible?: boolean;
|
||||
onlineServerIds?: string[];
|
||||
lockedWorkingDir?: string;
|
||||
@@ -229,6 +230,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
modeId: formState.selectedMode,
|
||||
modelId: effectiveModelId,
|
||||
thinkingOptionId: effectiveThinkingOptionId,
|
||||
initialFeatureValues: composerOptions?.initialFeatureValues,
|
||||
});
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -21,14 +21,19 @@ export function useDraftAgentFeatures(input: {
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
thinkingOptionId: string | null | undefined;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
}) {
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
|
||||
input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
|
||||
() => initialFeatureValues ?? {},
|
||||
);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
|
||||
const persistedFeatureValues = useMemo(
|
||||
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||
[preferences.providerPreferences, provider],
|
||||
@@ -88,15 +93,25 @@ export function useDraftAgentFeatures(input: {
|
||||
}, [availableFeatures, featureValues]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalFeatureValues({});
|
||||
}, [provider]);
|
||||
const previousProvider = previousProviderRef.current;
|
||||
previousProviderRef.current = normalizedProvider;
|
||||
if (previousProvider === null) {
|
||||
return;
|
||||
}
|
||||
if (previousProvider !== normalizedProvider) {
|
||||
setLocalFeatureValues({});
|
||||
}
|
||||
}, [normalizedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableFeaturesRaw === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
|
||||
if (next !== localFeatureValues) {
|
||||
setLocalFeatureValues(next);
|
||||
}
|
||||
}, [availableFeatures, localFeatureValues]);
|
||||
}, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
|
||||
|
||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
||||
const setFeatureValue = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user