fix(app): keep draft composers off the agent command list before a project and model are chosen (#5594)

A draft composer passed no command config when it was missing a project
or a provider, which is what a running agent's composer passes. The `/`
menu then listed commands by the draft key, got "Agent not found", and
showed /clear and /exit. Drafts now always report a command target
(ready, needs-project, needs-provider), and an incomplete draft asks for
the missing choice instead of querying.

Closes #5516

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-09-29 04:08:31 +02:00
committed by GitHub
co-authored by Ivan Malison
parent 68938b668e
commit 690f5298cb
20 changed files with 175 additions and 91 deletions
@@ -10,6 +10,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
{
@@ -338,6 +339,16 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
).toBeUndefined();
}
function recordCommandRequestsByAgentId(page: Page): string[] {
const requests: string[] = [];
const record = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
if (!payload.includes("draftConfig")) requests.push(payload);
};
page.on("websocket", (socket) => socket.on("framesent", record));
return requests;
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
@@ -359,6 +370,25 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page,
}) => {
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
await page.goto("/new");
await expectNewWorkspaceProjectSelected(page, "Choose project");
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ visible: true })
.first();
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
await expect(popover).not.toContainText("/clear");
await expect(popover).not.toContainText("/exit");
expect(commandRequestsByAgentId).toEqual([]);
});
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
@@ -198,12 +198,15 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
expect(getLatest().composerState?.commandDraft).toEqual({
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
const hydratedTextReplacement = getLatest().textReplacement;
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "@/provider-selection/provider-selection";
@@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
describe("buildDraftCommandTarget", () => {
const codexSelection = {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
};
it("needs a project when cwd is empty", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: " ",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
).toEqual({ status: "needs-project" });
});
it("needs a provider when none is selected", () => {
expect(
buildDraftCommandTarget({
selection: { ...codexSelection, provider: null },
cwd: "/repo",
effectiveModelId: "",
effectiveThinkingOptionId: "",
}),
).toEqual({ status: "needs-provider" });
});
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: "/repo",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
});
});
+14 -18
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
@@ -16,7 +16,7 @@ import {
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
commandDraft: DraftCommandTarget;
};
export interface AgentInputDraft {
@@ -287,19 +287,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[applyProfileFeatureValues, formState],
);
const commandDraftConfig = useMemo(
const commandDraft = useMemo(
() =>
composerOptions
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
})
: undefined,
buildDraftCommandTarget({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
}),
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
@@ -325,10 +322,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraftConfig,
commandDraft,
};
}, [
commandDraftConfig,
commandDraft,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
@@ -358,7 +355,6 @@ export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftCommandTarget,
buildDraftAgentControls,
};
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
+6 -6
View File
@@ -62,7 +62,7 @@ import {
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
@@ -973,8 +973,8 @@ interface ComposerProps {
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
commandDraft?: DraftCommandTarget;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void;
onComposerHeightChange?: (height: number) => void;
@@ -1264,7 +1264,7 @@ function ComposerContentImpl({
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
commandDraft,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
@@ -2346,7 +2346,7 @@ function ComposerContentImpl({
setUserInput: replaceUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
draft: commandDraft,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
pluginClientSlashCommands,
@@ -2355,7 +2355,7 @@ function ComposerContentImpl({
replaceUserInput,
serverId,
agentId,
commandDraftConfig,
commandDraft,
buildOutgoingAttachments,
attachments,
runClientSlashCommand,
@@ -7,6 +7,7 @@ import {
useAgentCommandsQuery,
type AgentSlashCommand,
type DraftCommandConfig,
type DraftCommandTarget,
} from "./use-agent-commands-query";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete";
@@ -34,7 +35,7 @@ interface UseAgentAutocompleteInput {
setUserInput: (nextValue: string) => void;
serverId: string;
agentId: string;
draftConfig?: DraftCommandConfig;
draft?: DraftCommandTarget;
onAutocompleteApplied?: () => void;
onClientSlashCommand?: (command: ClientSlashCommand) => void;
canExecuteClientSlashCommand?: boolean;
@@ -117,18 +118,13 @@ type AvailableCommand =
| { source: "plugin"; command: PluginClientSlashCommand }
| { source: "provider"; command: AgentSlashCommand };
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig,
): DraftCommandConfig | undefined {
if (!draftConfig) {
function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined {
if (draft?.status !== "ready") {
return undefined;
}
const draftConfig = draft.config;
const cwd = draftConfig.cwd.trim();
if (!cwd) {
return undefined;
}
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
@@ -337,6 +333,35 @@ function resolveAutocompleteErrorMessage(args: {
return undefined;
}
// A draft's agentId is a draft key the daemon does not know, so a draft lists
// commands by its config only, and not at all until that config is complete.
function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) {
const queryDraftConfig = resolveDraftQueryConfig(draft);
const isDraftContext = draft !== undefined;
return {
isDraftContext,
queryDraftConfig,
canListCommands: !isDraftContext || queryDraftConfig !== undefined,
};
}
function resolveAutocompleteEmptyText(args: {
mode: AutocompleteMode;
draft: DraftCommandTarget | undefined;
t: TFunction;
}): string {
if (args.mode === "file") {
return args.t("agentAutocomplete.noFiles");
}
if (args.draft?.status === "needs-project") {
return args.t("agentAutocomplete.chooseProjectForCommands");
}
if (args.draft?.status === "needs-provider") {
return args.t("agentAutocomplete.chooseModelForCommands");
}
return args.t("agentAutocomplete.noCommands");
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
const { t } = useTranslation();
const {
@@ -345,7 +370,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
setUserInput,
serverId,
agentId,
draftConfig,
draft,
onAutocompleteApplied,
onClientSlashCommand,
canExecuteClientSlashCommand,
@@ -380,13 +405,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
return () => clearTimeout(timer);
}, [fileFilterQuery]);
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
[draftConfig],
const { isDraftContext, queryDraftConfig, canListCommands } = useMemo(
() => resolveDraftCommandContext(draft),
[draft],
);
const isDraftContext = normalizedDraftConfig !== undefined;
const queryDraftConfig = normalizedDraftConfig;
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
const agentCwd = useSessionStore(
@@ -410,17 +432,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
autocompleteCwd,
});
const {
commands,
isLoading: isCommandsLoading,
isError,
error,
} = useAgentCommandsQuery({
const commandsQuery = useAgentCommandsQuery({
serverId,
agentId,
enabled: mode === "command" && canLoadCommands,
enabled: mode === "command" && canLoadCommands && canListCommands,
draftConfig: queryDraftConfig,
});
const { commands, isError, error } = commandsQuery;
const isCommandsLoading = canListCommands && commandsQuery.isLoading;
const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading);
@@ -587,8 +606,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
mode === "file"
? t("agentAutocomplete.searchingWorkspace")
: t("agentAutocomplete.loadingCommands");
const emptyText =
mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands");
const emptyText = resolveAutocompleteEmptyText({ mode, draft, t });
return {
isVisible,
@@ -17,6 +17,12 @@ export interface AgentSlashCommand {
export type DraftCommandConfig = AgentCommandsDraftConfig;
/** What a draft composer can list commands for, before an agent exists. */
export type DraftCommandTarget =
| { status: "ready"; config: DraftCommandConfig }
| { status: "needs-project" }
| { status: "needs-provider" };
interface ListAgentCommandsOptions {
agentId: string;
draftConfig?: DraftCommandConfig;
+2
View File
@@ -1522,6 +1522,8 @@ export const ar: TranslationResources = {
noFiles: "لم يتم العثور على ملفات أو أدلة",
noCommands: "لم يتم العثور على أي أوامر",
failedToLoad: "فشل التحميل",
chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر",
chooseModelForCommands: "اختر نموذجًا لعرض الأوامر",
},
loadOlderHistory: {
failed: "تعذر تحميل السجل الأقدم",
+2
View File
@@ -1547,6 +1547,8 @@ export const en = {
noFiles: "No files or directories found",
noCommands: "No commands found",
failedToLoad: "Failed to load",
chooseProjectForCommands: "Choose a project to see commands",
chooseModelForCommands: "Select a model to see commands",
},
loadOlderHistory: {
failed: "Couldn't load older history",
+2
View File
@@ -1568,6 +1568,8 @@ export const es: TranslationResources = {
noFiles: "No se encontraron archivos ni directorios",
noCommands: "No se encontraron comandos",
failedToLoad: "No se pudo cargar",
chooseProjectForCommands: "Elige un proyecto para ver los comandos",
chooseModelForCommands: "Selecciona un modelo para ver los comandos",
},
loadOlderHistory: {
failed: "No se pudo cargar el historial anterior",
+2
View File
@@ -1572,6 +1572,8 @@ export const fr: TranslationResources = {
noFiles: "Aucun fichier ou répertoire trouvé",
noCommands: "Aucune commande trouvée",
failedToLoad: "Échec du chargement",
chooseProjectForCommands: "Choisissez un projet pour voir les commandes",
chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes",
},
loadOlderHistory: {
failed: "Impossible de charger l'ancien historique",
+2
View File
@@ -1539,6 +1539,8 @@ export const ja: TranslationResources = {
noFiles: "ファイルまたはディレクトリが見つかりません",
noCommands: "コマンドが見つかりません",
failedToLoad: "読み込みに失敗しました",
chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください",
chooseModelForCommands: "コマンドを表示するにはモデルを選択してください",
},
loadOlderHistory: {
failed: "古い履歴を読み込めませんでした",
+2
View File
@@ -1532,6 +1532,8 @@ export const ko: TranslationResources = {
noFiles: "파일 또는 디렉터리를 찾을 수 없습니다",
noCommands: "명령을 찾을 수 없습니다",
failedToLoad: "불러오지 못했습니다",
chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요",
chooseModelForCommands: "명령을 보려면 모델을 선택하세요",
},
loadOlderHistory: {
failed: "이전 기록을 불러올 수 없습니다",
+2
View File
@@ -1553,6 +1553,8 @@ export const ptBR: TranslationResources = {
noFiles: "Nenhum arquivo ou diretório encontrado",
noCommands: "Nenhum comando encontrado",
failedToLoad: "Falha ao carregar",
chooseProjectForCommands: "Escolha um projeto para ver os comandos",
chooseModelForCommands: "Selecione um modelo para ver os comandos",
},
loadOlderHistory: {
failed: "Não foi possível carregar o histórico mais antigo",
+2
View File
@@ -1551,6 +1551,8 @@ export const ru: TranslationResources = {
noFiles: "Файлы и каталоги не найдены",
noCommands: "Команды не найдены",
failedToLoad: "Не удалось загрузить",
chooseProjectForCommands: "Выберите проект, чтобы увидеть команды",
chooseModelForCommands: "Выберите модель, чтобы увидеть команды",
},
loadOlderHistory: {
failed: "Не удалось загрузить старую историю.",
+2
View File
@@ -1505,6 +1505,8 @@ export const zhCN: TranslationResources = {
noFiles: "没有找到文件或目录",
noCommands: "没有找到 commands",
failedToLoad: "加载失败",
chooseProjectForCommands: "选择项目以查看命令",
chooseModelForCommands: "选择模型以查看命令",
},
loadOlderHistory: {
failed: "无法加载更早历史",
@@ -5,7 +5,7 @@ import type {
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import { i18n } from "@/i18n/i18next";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { filterSelectableModels } from "./model-catalog";
@@ -286,19 +286,22 @@ export function resolveEffectiveComposerThinkingOptionId(
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftCommandConfig(input: {
export function buildDraftCommandTarget(input: {
selection: ProviderSelectionState;
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
}): DraftCommandTarget {
const cwd = input.cwd.trim();
if (!input.selection.provider || !cwd) {
return undefined;
if (!cwd) {
return { status: "needs-project" };
}
if (!input.selection.provider) {
return { status: "needs-provider" };
}
return {
const config = {
provider: input.selection.provider,
cwd,
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
@@ -310,6 +313,7 @@ export function buildDraftCommandConfig(input: {
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
return { status: "ready", config };
}
export function resolveSubmissionReadiness(input: {
@@ -2422,7 +2422,7 @@ export function NewWorkspaceScreen({
clearDraft={handleClearDraft}
autoFocus
autoFocusKey={launchFocusKey}
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
/>
);