From a70b5c1e1d8085ae2d39f501d279e7770d2a726b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 13:15:09 +0700 Subject: [PATCH] fix(app): reuse draft command cache across tabs --- .../src/hooks/agent-commands-query.test.ts | 75 +++++++++++++++++++ .../app/src/hooks/agent-commands-query.ts | 61 +++++++++++++++ .../app/src/hooks/use-agent-commands-query.ts | 36 ++------- .../src/hooks/use-providers-snapshot.test.ts | 35 +++++++++ .../app/src/hooks/use-providers-snapshot.ts | 9 +++ 5 files changed, 186 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/hooks/agent-commands-query.test.ts create mode 100644 packages/app/src/hooks/agent-commands-query.ts diff --git a/packages/app/src/hooks/agent-commands-query.test.ts b/packages/app/src/hooks/agent-commands-query.test.ts new file mode 100644 index 000000000..9d880b8da --- /dev/null +++ b/packages/app/src/hooks/agent-commands-query.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + agentCommandsQueryKey, + agentCommandsQueryRoot, + draftAgentCommandsQueryKey, + normalizeAgentCommandsCwd, + sessionAgentCommandsQueryKey, +} from "./agent-commands-query"; + +describe("agent command query keys", () => { + it("shares draft commands across draft tabs with the same provider setup", () => { + const draftConfig = { + provider: "codex" as const, + cwd: "/repo", + modeId: "auto", + model: "gpt-5.5", + thinkingOptionId: "medium", + }; + + expect( + agentCommandsQueryKey({ serverId: "server-1", agentId: "draft-a", draftConfig }), + ).toEqual(agentCommandsQueryKey({ serverId: "server-1", agentId: "draft-b", draftConfig })); + }); + + it("keeps draft commands separate from running agent session commands", () => { + expect(sessionAgentCommandsQueryKey({ serverId: "server-1", agentId: "agent-1" })).toEqual([ + "agentCommands", + "server-1", + "session", + "agent-1", + ]); + expect( + draftAgentCommandsQueryKey({ + serverId: "server-1", + draftConfig: { provider: "codex", cwd: "/repo" }, + }), + ).toEqual([ + "agentCommands", + "server-1", + "draft", + "codex", + "cwd", + "/repo", + "mode", + null, + "model", + null, + "thinking", + null, + "features", + null, + ]); + }); + + it("normalizes cwd values so equivalent workspace paths share one draft scope", () => { + expect(normalizeAgentCommandsCwd("C:\\Users\\Ezekiel Bulver\\project")).toBe( + "C:/Users/Ezekiel Bulver/project", + ); + expect( + draftAgentCommandsQueryKey({ + serverId: "server-1", + draftConfig: { provider: "codex", cwd: "C:\\Users\\Ezekiel Bulver\\project" }, + }), + ).toEqual( + draftAgentCommandsQueryKey({ + serverId: "server-1", + draftConfig: { provider: "codex", cwd: "C:/Users/Ezekiel Bulver/project" }, + }), + ); + }); + + it("exposes a server-level root for refresh invalidation", () => { + expect(agentCommandsQueryRoot("server-1")).toEqual(["agentCommands", "server-1"]); + }); +}); diff --git a/packages/app/src/hooks/agent-commands-query.ts b/packages/app/src/hooks/agent-commands-query.ts new file mode 100644 index 000000000..38cbd97a1 --- /dev/null +++ b/packages/app/src/hooks/agent-commands-query.ts @@ -0,0 +1,61 @@ +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; + +export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands"; + +export interface AgentCommandsDraftConfig { + provider: AgentProvider; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + featureValues?: Record; +} + +export function normalizeAgentCommandsCwd(cwd: string): string { + return normalizeWorkspacePath(cwd) ?? ""; +} + +export function agentCommandsQueryRoot(serverId: string) { + return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const; +} + +export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) { + return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const; +} + +export function draftAgentCommandsQueryKey(input: { + serverId: string; + draftConfig: AgentCommandsDraftConfig; +}) { + const { draftConfig } = input; + return [ + ...agentCommandsQueryRoot(input.serverId), + "draft", + draftConfig.provider, + "cwd", + normalizeAgentCommandsCwd(draftConfig.cwd), + "mode", + draftConfig.modeId ?? null, + "model", + draftConfig.model ?? null, + "thinking", + draftConfig.thinkingOptionId ?? null, + "features", + draftConfig.featureValues ?? null, + ] as const; +} + +export function agentCommandsQueryKey(input: { + serverId: string; + agentId: string; + draftConfig?: AgentCommandsDraftConfig; +}) { + if (input.draftConfig) { + return draftAgentCommandsQueryKey({ + serverId: input.serverId, + draftConfig: input.draftConfig, + }); + } + return sessionAgentCommandsQueryKey({ serverId: input.serverId, agentId: input.agentId }); +} diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index b6418d4c9..3df32043d 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -2,9 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query"; -const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute +const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY; +const SESSION_COMMANDS_STALE_TIME = 60_000; export interface AgentSlashCommand { name: string; @@ -13,14 +14,7 @@ export interface AgentSlashCommand { kind?: string; } -export interface DraftCommandConfig { - provider: AgentProvider; - cwd: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - featureValues?: Record; -} +export type DraftCommandConfig = AgentCommandsDraftConfig; export type AgentCommandsClient = Pick; @@ -35,24 +29,6 @@ export async function fetchAgentCommands(input: { return response.commands as AgentSlashCommand[]; } -function agentCommandsQueryKey( - serverId: string, - agentId: string, - draftConfig?: DraftCommandConfig, -) { - return [ - "agentCommands", - serverId, - agentId, - draftConfig?.provider ?? null, - draftConfig?.cwd ?? null, - draftConfig?.modeId ?? null, - draftConfig?.model ?? null, - draftConfig?.thinkingOptionId ?? null, - draftConfig?.featureValues ?? null, - ] as const; -} - interface UseAgentCommandsQueryOptions { serverId: string; agentId: string; @@ -71,7 +47,7 @@ export function useAgentCommandsQuery({ const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: agentCommandsQueryKey(serverId, agentId, draftConfig), + queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryFn: async () => { if (!client) { throw new Error(t("common.errors.daemonClientUnavailable")); @@ -79,7 +55,7 @@ export function useAgentCommandsQuery({ return fetchAgentCommands({ client, agentId, draftConfig }); }, enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig), - staleTime: COMMANDS_STALE_TIME, + staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), }); diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index d3653b487..a28ec6ce8 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; import { applyProvidersSnapshotUpdate, fetchProvidersSnapshot, @@ -195,6 +196,24 @@ describe("refreshAndApplyProvidersSnapshot", () => { queryClient.getQueryState(providersSnapshotQueryKey(serverId, "/repo-b"))?.isInvalidated, ).toBe(false); }); + + it("invalidates cached agent commands when refreshing providers", async () => { + const client = createClient({ snapshots: [providersSnapshot([])] }); + const commandsKey = draftAgentCommandsQueryKey({ + serverId, + draftConfig: { provider: "codex", cwd: "/repo-a" }, + }); + queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); + + await refreshAndApplyProvidersSnapshot({ + client, + queryClient, + serverId, + cwd: "/repo-a", + }); + + expect(queryClient.getQueryState(commandsKey)?.isInvalidated).toBe(true); + }); }); describe("applyProvidersSnapshotUpdate", () => { @@ -271,6 +290,22 @@ describe("applyProvidersSnapshotUpdate", () => { requestId: "providers_snapshot_update", }); }); + + it("invalidates cached agent commands when a provider snapshot update arrives", () => { + const commandsKey = draftAgentCommandsQueryKey({ + serverId, + draftConfig: { provider: "codex", cwd: "/repo-a" }, + }); + queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); + + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + + expect(queryClient.getQueryState(commandsKey)?.isInvalidated).toBe(true); + }); }); describe("selectorOpenRefetchDecision", () => { diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 6e036be8f..b8a25c878 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -6,6 +6,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { queryClient as singletonQueryClient } from "@/query/query-client"; +import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import { isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, @@ -52,6 +53,10 @@ export async function refreshAndApplyProvidersSnapshot(input: { ); const snapshot = await fetchProvidersSnapshot({ client: input.client, cwd: input.cwd }); input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, + }); if (isProvidersSnapshotHomeScope(input.cwd)) { void input.queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(input.serverId), @@ -75,6 +80,10 @@ export function applyProvidersSnapshotUpdate(input: { generatedAt: input.message.payload.generatedAt, requestId: "providers_snapshot_update", }); + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, + }); } export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always";