From 1008e9fffc20fc809d5d1023d43642b39df19c39 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 31 Oct 2026 19:25:16 +0700 Subject: [PATCH] Insert suggested commands into the composer (#8371) * feat(composer): insert suggested commands into drafts * fix(composer): close suggestions after command insertion * fix(composer): place caret after inserted commands --- .../e2e/browser/composer-autocomplete.spec.ts | 56 ++++++- .../cache/index.ts} | 48 ++++++ packages/app/src/agent-commands/index.tsx | 99 +++++++++++ .../query.test.ts} | 52 +++++- .../use-query.test.ts} | 12 +- .../use-query.ts} | 36 +++- .../app/src/components/markdown/images.ts | 10 +- .../src/components/markdown/renderer.test.ts | 24 +++ .../app/src/components/markdown/renderer.tsx | 39 +++-- .../app/src/components/markdown/source.ts | 18 +- packages/app/src/components/message.tsx | 1 + .../app/src/composer/draft/input-draft.ts | 2 +- .../composer/editing-session/index.test.ts | 25 +++ .../src/composer/editing-session/index.tsx | 19 +++ packages/app/src/composer/index.tsx | 2 +- packages/app/src/composer/input/input.tsx | 10 ++ packages/app/src/data/providers-snapshot.ts | 2 +- packages/app/src/data/push-router.ts | 20 ++- packages/app/src/data/query.ts | 4 +- .../app/src/git/checkout-status-cache.test.ts | 2 +- packages/app/src/git/checkout-status-cache.ts | 2 +- .../app/src/hooks/use-agent-autocomplete.ts | 6 +- .../src/hooks/use-providers-snapshot.test.ts | 155 +++++++++++++++++- packages/app/src/panels/agent-panel.tsx | 35 +++- .../provider-selection/provider-selection.ts | 2 +- 25 files changed, 626 insertions(+), 55 deletions(-) rename packages/app/src/{hooks/agent-commands-query.ts => agent-commands/cache/index.ts} (54%) create mode 100644 packages/app/src/agent-commands/index.tsx rename packages/app/src/{hooks/agent-commands-query.test.ts => agent-commands/query.test.ts} (51%) rename packages/app/src/{hooks/use-agent-commands-query.test.ts => agent-commands/use-query.test.ts} (82%) rename packages/app/src/{hooks/use-agent-commands-query.ts => agent-commands/use-query.ts} (70%) diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index d83046d8b2..f40b51304b 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -1,5 +1,9 @@ import { expect, test, type Page } from "../support/fixtures"; -import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; +import { + attachFileFromMenu, + composerLocator, + expectComposerVisible, +} from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace, @@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): } test.describe("Composer autocomplete", () => { + test("inserts suggested commands from assistant messages without sending", async ({ page }) => { + const requests = recordCommandRequestsByAgentId(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "suggested-command-", + title: "Suggested commands", + initialPrompt: "Suggest `/mock` without running it", + featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." }, + }); + try { + await openAgentRoute(page, session); + const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" }); + await expect(message).toBeVisible(); + await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0); + await expect.poll(() => requests.length).toBe(1); + await attachFileFromMenu(page, { + name: "note.txt", + mimeType: "text/plain", + buffer: Buffer.from("attachment"), + }); + await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt"); + await message.getByRole("link", { name: "/mock", exact: true }).click(); + await expect(composerLocator(page)).toHaveValue("/mock "); + await expect( + page.getByTestId("composer-autocomplete-popover").filter({ visible: true }), + ).toHaveCount(0); + await expect(composerLocator(page)).toBeFocused(); + await message.getByRole("link", { name: "Run mock" }).click(); + await expect(composerLocator(page)).toHaveValue("/mock /mock "); + await expect( + page.getByTestId("composer-autocomplete-popover").filter({ visible: true }), + ).toHaveCount(0); + await expect(composerLocator(page)).toBeFocused(); + await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt"); + await composerLocator(page).fill("draft"); + await composerLocator(page).evaluate((element: HTMLTextAreaElement) => + element.setSelectionRange(0, 0), + ); + await message.getByRole("link", { name: "/mock", exact: true }).click(); + await composerLocator(page).pressSequentially("argument"); + await expect(composerLocator(page)).toHaveValue("draft /mock argument"); + await composerLocator(page).fill("/"); + await expect( + page.getByTestId("composer-autocomplete-popover").filter({ visible: true }), + ).toContainText("/mock"); + expect(requests).toHaveLength(1); + } finally { + await session.cleanup(); + } + }); + test("keeps New workspace off the running-agent command list before a project is chosen", async ({ page, }) => { diff --git a/packages/app/src/hooks/agent-commands-query.ts b/packages/app/src/agent-commands/cache/index.ts similarity index 54% rename from packages/app/src/hooks/agent-commands-query.ts rename to packages/app/src/agent-commands/cache/index.ts index 276c0fd9ba..474491f297 100644 --- a/packages/app/src/hooks/agent-commands-query.ts +++ b/packages/app/src/agent-commands/cache/index.ts @@ -1,8 +1,16 @@ import type { QueryClient } from "@tanstack/react-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import equal from "fast-deep-equal"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands"; +export const sessionCommandQueryPolicy = { + staleTime: 600_000, + gcTime: 1_800_000, + refetchOnMount: true, + refetchOnWindowFocus: false, +} as const; export interface AgentCommandsDraftConfig { provider: AgentProvider; @@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) { return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const; } +/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial + * discovery and unrelated providers; explicit refresh still invalidates every catalog. */ +export function invalidateCommandsForProviderChange(input: { + queryClient: QueryClient; + serverId: string; + cwd: string | null; + previous: readonly ProviderSnapshotEntry[] | undefined; + current: readonly ProviderSnapshotEntry[]; +}): Promise { + const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry])); + const current = new Map(input.current.map((entry) => [entry.provider, entry])); + const relevant = (entry: ProviderSnapshotEntry | undefined) => + entry && { + status: entry.status, + enabled: entry.enabled, + source: entry.source, + models: entry.models, + modes: entry.modes, + defaultModeId: entry.defaultModeId, + }; + return input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + predicate: (query) => { + const isDraft = query.queryKey[2] === "draft"; + const provider = + query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined); + const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined); + if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd) + return false; + if (typeof provider !== "string") return false; + const before = previous.get(provider); + const after = current.get(provider); + if (!before) return after?.status === "ready" && query.state.status === "error"; + if (before.status !== "ready") + return after?.status === "ready" && query.state.status === "error"; + return !equal(relevant(before), relevant(after)); + }, + }); +} + export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) { return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const; } diff --git a/packages/app/src/agent-commands/index.tsx b/packages/app/src/agent-commands/index.tsx new file mode 100644 index 0000000000..23d5a61e5f --- /dev/null +++ b/packages/app/src/agent-commands/index.tsx @@ -0,0 +1,99 @@ +import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { ComposerEditingSession } from "@/composer/editing-session"; +import { agentCommandsQueryKey } from "./cache"; +import { + useAgentCommandsQuery, + type AgentSlashCommand, + type DraftCommandConfig, +} from "./use-query"; + +export type { + AgentSlashCommand, + DraftCommandConfig, + DraftCommandTarget, + AgentCommandsClient, +} from "./use-query"; +export { fetchAgentCommands } from "./use-query"; + +type CommandQuery = ReturnType; +const QueryContext = createContext(null); +export interface CommandHints { + names: ReadonlySet; + insert(command: string): void; +} +const HintsContext = createContext(null); + +function useLiveAgentCommands() { + return useContext(QueryContext); +} +export function useAutocompleteCommands(input: { + serverId: string; + agentId: string; + enabled: boolean; + draftConfig?: DraftCommandConfig; +}) { + const live = useLiveAgentCommands(); + const fallback = useAgentCommandsQuery({ + serverId: input.serverId, + agentId: input.agentId, + enabled: !live && input.enabled, + draftConfig: input.draftConfig, + }); + return live ?? fallback; +} +export function useAgentCommandHints() { + return useContext(HintsContext); +} +export function matchCommandLink( + url: string, + sourceType: "markdown" | "text" | "inline-code" | undefined, + names: ReadonlySet, +): string | null { + return (sourceType === "markdown" || sourceType === "inline-code") && + url.startsWith("/") && + names.has(url.slice(1)) + ? url + : null; +} + +export function AgentCommandsProvider({ + serverId, + agentId, + editingSession, + enabled, + children, +}: { + serverId: string; + agentId: string; + editingSession: ComposerEditingSession; + enabled: boolean; + children: ReactNode; +}) { + const query = useAgentCommandsQuery({ serverId, agentId, enabled }); + const queryClient = useQueryClient(); + const namesKey = JSON.stringify( + [...new Set(query.commands.map((command) => command.name))].sort(), + ); + const names = useMemo>( + () => new Set(JSON.parse(namesKey) as string[]), + [namesKey], + ); + const insert = useCallback( + (token: string) => { + const commands = queryClient.getQueryData( + agentCommandsQueryKey({ serverId, agentId }), + ); + if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) { + editingSession.insertCommand(token); + } + }, + [agentId, editingSession, queryClient, serverId], + ); + const hints = useMemo(() => ({ names, insert }), [names, insert]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/hooks/agent-commands-query.test.ts b/packages/app/src/agent-commands/query.test.ts similarity index 51% rename from packages/app/src/hooks/agent-commands-query.test.ts rename to packages/app/src/agent-commands/query.test.ts index e8d8ee0b94..33b55b4464 100644 --- a/packages/app/src/hooks/agent-commands-query.test.ts +++ b/packages/app/src/agent-commands/query.test.ts @@ -1,11 +1,61 @@ import { describe, expect, it } from "vitest"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { fetchQueryOptions } from "@/data/query"; import { agentCommandsQueryKey, agentCommandsQueryRoot, draftAgentCommandsQueryKey, normalizeAgentCommandsCwd, sessionAgentCommandsQueryKey, -} from "./agent-commands-query"; + sessionCommandQueryPolicy, +} from "./cache"; + +it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => { + const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } }); + const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" }); + let calls = 0; + let fail = false; + let resolveRefresh: (value: string[]) => void = () => {}; + const refresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + const options = fetchQueryOptions({ + ...sessionCommandQueryPolicy, + staleTimeMs: sessionCommandQueryPolicy.staleTime, + dataShape: "value", + queryKey: key, + retry: false, + queryFn: async () => { + calls++; + if (fail) throw new Error("offline"); + return refresh; + }, + }); + client.setQueryData(key, ["old"]); + const fresh = new QueryObserver(client, options); + const unsubscribeFresh = fresh.subscribe(() => {}); + expect(calls).toBe(0); + unsubscribeFresh(); + client.setQueryData(key, ["old"], { updatedAt: 1 }); + const first = new QueryObserver(client, options); + const second = new QueryObserver(client, options); + const unsubscribeFirst = first.subscribe(() => {}); + const unsubscribeSecond = second.subscribe(() => {}); + expect(calls).toBe(1); + expect(first.getCurrentResult().data).toEqual(["old"]); + expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000); + resolveRefresh(["new"]); + await refresh; + await first.refetch({ cancelRefetch: false }); + expect(second.getCurrentResult().data).toEqual(["new"]); + fail = true; + await client.invalidateQueries({ queryKey: key }); + expect(first.getCurrentResult().data).toEqual(["new"]); + expect(first.getCurrentResult().isError).toBe(true); + unsubscribeFirst(); + unsubscribeSecond(); + client.clear(); +}); describe("agent command query keys", () => { it("shares draft commands across draft tabs with the same provider setup", () => { diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/agent-commands/use-query.test.ts similarity index 82% rename from packages/app/src/hooks/use-agent-commands-query.test.ts rename to packages/app/src/agent-commands/use-query.test.ts index 87f5919da8..db14491923 100644 --- a/packages/app/src/hooks/use-agent-commands-query.test.ts +++ b/packages/app/src/agent-commands/use-query.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - type AgentCommandsClient, - type DraftCommandConfig, - fetchAgentCommands, -} from "./use-agent-commands-query"; +import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query"; type ListCommands = AgentCommandsClient["listCommands"]; type ListCommandsResult = Awaited>; @@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands } describe("fetchAgentCommands", () => { + it("rejects a discovery error so a previous catalog is retained", async () => { + const client = createClient({ ...commandsPayload([]), error: "provider unavailable" }); + await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow( + "provider unavailable", + ); + }); it("loads commands for a draft composer without an agent id", async () => { const client = createClient( commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]), diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/agent-commands/use-query.ts similarity index 70% rename from packages/app/src/hooks/use-agent-commands-query.ts rename to packages/app/src/agent-commands/use-query.ts index eddc0ab02b..000e6e8057 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/agent-commands/use-query.ts @@ -1,12 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; +import { useFetchQuery } from "@/data/query"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useSessionStore } from "@/stores/session-store"; +import { useShallow } from "zustand/shallow"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query"; - -const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY; -const SESSION_COMMANDS_STALE_TIME = 60_000; +import { + agentCommandsQueryKey, + sessionCommandQueryPolicy, + type AgentCommandsDraftConfig, +} from "./cache"; export interface AgentSlashCommand { name: string; @@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: { agentId: input.agentId, draftConfig: input.draftConfig, }); + if (response.error) throw new Error(response.error); return response.commands as AgentSlashCommand[]; } @@ -62,8 +66,16 @@ export function useAgentCommandsQuery({ const queryEnabled = enabled && retainedPanelActive; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const { provider, cwd } = useSessionStore( + useShallow((state) => { + const agent = + state.sessions[serverId]?.agents?.get(agentId) ?? + state.sessions[serverId]?.agentDetails?.get(agentId); + return { provider: agent?.provider, cwd: agent?.cwd }; + }), + ); - const query = useQuery({ + const query = useFetchQuery({ queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryFn: async () => { if (!client) { @@ -72,7 +84,15 @@ export function useAgentCommandsQuery({ return fetchAgentCommands({ client, agentId, draftConfig }); }, enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig), - staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME, + dataShape: "value", + meta: { + agentCommandProvider: draftConfig?.provider ?? provider, + agentCommandCwd: draftConfig?.cwd ?? cwd, + }, + ...(draftConfig + ? { immutableWhen: () => true as const } + : { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }), + subscribed: queryEnabled, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), }); @@ -84,7 +104,7 @@ export function useAgentCommandsQuery({ return { commands: query.data ?? [], isLoading, - isError: query.isError, + isError: query.isError && query.data === undefined, error: query.error, }; } diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index 83b24b481b..e2f8d07992 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -35,11 +35,15 @@ export interface MarkdownImageContext { filePath?: string; } -export function useMarkdownSource(text: string, context: MarkdownImageContext) { +export function useMarkdownSource( + text: string, + context: MarkdownImageContext, + commandNames?: ReadonlySet, +) { const occurrenceKey = useId(); const prepared = useMemo( - () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }), - [text, context.workspaceRoot], + () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }), + [text, context.workspaceRoot, commandNames], ); const sources = [...new Set(prepared.images.map((image) => image.source))]; const acquisitions = sources.map((source) => { diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index bb5679f6f8..4256685f9c 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -2,6 +2,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; +import { matchCommandLink } from "@/agent-commands"; + import { classifyAssistantFileLink } from "@/assistant-file-links"; import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; @@ -13,6 +15,28 @@ import { createMarkdownStyles } from "./styles"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { normalizeMarkdownHtml } from "./html"; +it("recognizes complete known commands in inline code", () => { + const names = new Set(["code-review", "review:fast"]); + const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, { + commandNames: names, + }); + expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true); + expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe( + prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"), + ); + const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names }); + expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true); + expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review"); +}); + +it("resolves authored root-relative links only when the target is a known command", () => { + const names = new Set(["code-review"]); + expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review"); + expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull(); + expect(matchCommandLink("/code-review", "text", names)).toBeNull(); + expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull(); +}); + describe("shared Markdown source adaptation", () => { it("keeps paragraphs, fences and quotes together for the library", () => { const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 8e51495369..c14ecb5198 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -6,6 +6,7 @@ import { useAssistantFileLinkEnvironment, } from "@/assistant-file-links"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import type { Theme } from "@/styles/theme"; import { isWeb } from "@/constants/platform"; @@ -48,6 +49,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext { selectable?: boolean; streaming?: boolean; onLinkPress?: (url: string) => unknown; + agentCommands?: boolean; } /** All Markdown surfaces share the same engine and source adaptation. */ @@ -59,15 +61,21 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe () => splitStreamingMarkdown(completeLines, true).head.length, [completeLines], ); - if (!streaming || boundary === 0) return ; + const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart; + if (!streaming || boundary === 0) return ; return ( - - + + ); }); +const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) { + const commandHints = useAgentCommandHints(); + return ; +}); + const MarkdownPart = memo(function MarkdownPart({ text, compact = false, @@ -75,8 +83,10 @@ const MarkdownPart = memo(function MarkdownPart({ selectable = true, streaming = false, onLinkPress, + agentCommands: _agentCommands, + commandHints = null, ...imageContext -}: MarkdownRendererProps) { +}: MarkdownRendererProps & { commandHints?: CommandHints | null }) { const environment = useAssistantFileLinkEnvironment(); const actions = useAssistantFileLinkActions(); const client = @@ -84,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({ (environment.client?.readFile ? { readFile: environment.client.readFile.bind(environment.client) } : undefined); - const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, { - workspaceRoot: environment.workspaceRoot, - serverId: environment.serverId, - ...imageContext, - client, - }); + const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource( + text, + { + workspaceRoot: environment.workspaceRoot, + serverId: environment.serverId, + ...imageContext, + client, + }, + commandHints?.names, + ); const [image, setImage] = useState(null); const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => { + const command = commandHints && matchCommandLink(url, sourceType, commandHints.names); + if (command) { + commandHints.insert(command); + return; + } const source = linkSource(url, sourceType); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); else actions.open(source, "preferred"); diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 5c82088898..3462320d7d 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -56,7 +56,11 @@ function linkMetadata(inlineCodePaths: ReadonlySet) { }; } -function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet { +function collectInlineCodePaths( + source: string, + workspaceRoot?: string, + commandNames?: ReadonlySet, +): ReadonlySet { const paths = new Set(); const previousEnds = new Map(); // Collect candidates between consecutive runs of equal length. The engine owns @@ -74,7 +78,11 @@ function collectInlineCodePaths(source: string, workspaceRoot?: string): Readonl value = value.slice(1, -1); } const classification = classifyAssistantFileLink(value, { workspaceRoot }); - if (classification && classification.kind !== "external") paths.add(value); + if ( + (classification && classification.kind !== "external") || + (value.startsWith("/") && commandNames?.has(value.slice(1))) + ) + paths.add(value); } return paths; } @@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string) export function prepareMarkdownSource( source: string, workspaceRoot?: string, - options: { supportsSvg?: boolean } = {}, + options: { supportsSvg?: boolean; commandNames?: ReadonlySet } = {}, ) { source = normalizeMarkdownHtml(source); const images: MarkdownImage[] = []; @@ -122,7 +130,9 @@ export function prepareMarkdownSource( extensions: [gfmTable()], mdastExtensions: [gfmTableFromMarkdown()], })); - const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot)); + const metadata = linkMetadata( + collectInlineCodePaths(source, workspaceRoot, options.commandNames), + ); const edits: { start: number; end: number; text: string }[] = []; if ( !source.includes("![") && diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 2c6165d305..0ec5cbfb44 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1199,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({ > { + it("appends commands to the current editor buffer and focuses it", () => { + const session = new ComposerEditingSession(); + let text = "draft typed since publication"; + let focuses = 0; + let selection = { start: 0, end: 0 }; + const detach = session.attachEditor({ + getText: () => text, + replaceText: (next, nextSelection) => { + text = next; + selection = nextSelection; + }, + focus: () => { + focuses += 1; + }, + }); + session.insertCommand("/review"); + expect(selection).toEqual({ start: text.length, end: text.length }); + session.insertCommand("/test"); + expect(text).toBe("draft typed since publication /review /test "); + expect(focuses).toBe(2); + expect(selection).toEqual({ start: text.length, end: text.length }); + detach(); + session.insertCommand("/ignored"); + expect(text).toBe("draft typed since publication /review /test "); + }); it("publishes fullscreen changes once per transition", () => { const session = new ComposerEditingSession(); const changes: boolean[] = []; diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 4776b5af3d..237b15251a 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -30,10 +30,29 @@ export interface ComposerRetainedEditor { */ export class ComposerEditingSession { private fullscreen = false; + private editor: { + getText(): string; + replaceText(text: string, selection: ComposerSelection): void; + focus(): void; + } | null = null; private retained: ComposerRetainedEditor | null = null; private listeners = new Set<() => void>(); constructor(private readonly onChange?: () => void) {} getFullscreen = () => this.fullscreen; + attachEditor(editor: NonNullable) { + this.editor = editor; + return () => { + if (this.editor === editor) this.editor = null; + }; + } + insertCommand(command: string) { + const editor = this.editor; + if (!editor) return; + const text = editor.getText(); + const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `; + editor.replaceText(nextText, { start: nextText.length, end: nextText.length }); + editor.focus(); + } /** Called by the unmounting editor; the next mount of this session takes it. */ retainEditor(retained: ComposerRetainedEditor) { this.retained = retained; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index b7088ddeef..d6b4bae63f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -71,7 +71,7 @@ import { } from "./input/input"; import type { ImageAttachment, MessagePayload, TextReplacement } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 16acede7e5..8cb8cd4c32 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1407,6 +1407,16 @@ export const MessageInput = forwardRef( [onChangeText, updateComposerHeightForText, updateLiveTextPresence], ); + useLayoutEffect( + () => + editingSession.attachEditor({ + getText: () => textInputRef.current?.getText() ?? valueRef.current, + replaceText, + focus: () => textInputRef.current?.focus(), + }), + [editingSession, replaceText], + ); + useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 68b389fe15..87c62f81ca 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -8,7 +8,7 @@ import { } from "./provider-snapshot-cache"; import { queryClient as singletonQueryClient } from "./query-client"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { agentCommandsQueryRoot } from "@/agent-commands/cache"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 75ac313169..cc32f8f8e3 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,11 +1,12 @@ import type { OwnedSubscription } from "@getpaseo/client"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, MutableDaemonConfig, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { invalidateCommandsForProviderChange } from "@/agent-commands/cache"; import { shareCheckoutDiff } from "@/git/diff-sharing"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; @@ -186,10 +187,10 @@ export async function applyProvidersSnapshotUpdate(input: { const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); const queryKey = providersSnapshotQueryKey(input.serverId, cwd); - const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); + const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey); let announcement: typeof snapshot | undefined = snapshot; void input.queryClient.cancelQueries({ queryKey, exact: true }); - await input.queryClient.fetchQuery({ + const current = await input.queryClient.fetchQuery({ queryKey, staleTime: 0, structuralSharing: false, @@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: { }); }, }); - if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - } + void invalidateCommandsForProviderChange({ + queryClient: input.queryClient, + serverId: input.serverId, + cwd, + previous: previous?.entries, + current: current.entries, + }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 2a645f8666..e2b25d7b29 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -27,7 +27,7 @@ type ReplicaQueryInput type FetchQueryInput = Omit< UseQueryOptions, - "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" + "initialData" | "placeholderData" | "queryFn" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; @@ -113,7 +113,7 @@ export function fetchQueryOptions< dataShape, }, }, - refetchOnMount: immutableWhen ? true : "always", + refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"), staleTime: immutableWhen ? (query) => query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 026ec9d43a..bbfeb6d758 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -16,7 +16,7 @@ import { resolveWorkingDiffComparison, selectWorkingDiffComparison, } from "@/git/working-diff-comparison"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey } from "@/agent-commands/cache"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index ca5cbb3a7a..4aaa650c70 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -8,7 +8,7 @@ import { invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; -import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; +import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index fe6f98c3f1..ef144477dc 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -4,11 +4,11 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { - useAgentCommandsQuery, + useAutocompleteCommands, type AgentSlashCommand, type DraftCommandConfig, type DraftCommandTarget, -} from "./use-agent-commands-query"; +} from "@/agent-commands"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; @@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut autocompleteCwd, }); - const commandsQuery = useAgentCommandsQuery({ + const commandsQuery = useAutocompleteCommands({ serverId, agentId, enabled: mode === "command" && canLoadCommands && canListCommands, diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 2f3c90b340..0c730cddbd 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -1,4 +1,4 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,7 +8,7 @@ import { expandProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache"; import { providerSnapshotIcons } from "@/data/provider-icons"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { @@ -363,6 +363,153 @@ describe("applyProvidersSnapshotUpdate", () => { }; } + it("refreshes active commands only when their provider changes after discovery", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + return [{ name: "review" }]; + }, + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(1); + const codex = codexEntry("ready", [readyCodexModel]); + const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("loading"), other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + { ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" }, + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + expect(requests).toBe(1); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-b"), + providersSnapshot([codex]), + ); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])], + "/repo-b", + ), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]), + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + unsubscribe(); + queryClient.clear(); + }); + + it("retries a failed command discovery when its provider first becomes ready", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let ready = false; + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + if (!ready) throw new Error("provider unavailable"); + return [{ name: "review" }]; + }, + retry: false, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().isError).toBe(true); + ready = true; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]); + unsubscribe(); + queryClient.clear(); + }); + + it("refreshes again when its provider changes during an active refresh", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + queryClient.setQueryData(key, [{ name: "old" }]); + let requests = 0; + const waiting = new Promise<{ name: string }[]>(() => {}); + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]), + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready", [readyCodexModel])]), + ); + const send = (label: string) => + applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"), + }); + await send("First change"); + expect(requests).toBe(1); + await send("Second change"); + expect(requests).toBe(2); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]); + unsubscribe(); + queryClient.clear(); + }); + it("routes updates to the home query cache when the message carries no cwd", async () => { await applyProvidersSnapshotUpdate({ client: createClient(), @@ -455,6 +602,10 @@ describe("applyProvidersSnapshotUpdate", () => { draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready")]), + ); await applyProvidersSnapshotUpdate({ client: createClient(), diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 712d9d65a1..147ab7b260 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -28,6 +28,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; +import { AgentCommandsProvider } from "@/agent-commands"; import { useComposerEditingSession, useComposerFullscreen, @@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); - return ( + const content = ( ); + return wrapAgentCommandProvider({ + enabled: showsComposer && hasActiveComposer, + serverId, + agentId, + editingSession: composerEditingSession, + children: content, + }); }); +function wrapAgentCommandProvider({ + enabled, + serverId, + agentId, + editingSession, + children, +}: { + enabled: boolean; + serverId: string; + agentId: string; + editingSession: ComposerEditingSession; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + function ChatSurface({ children, disabled, diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index e0313c6623..54d75f1da2 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -5,7 +5,7 @@ import type { ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { filterSelectableModels } from "./model-catalog";