diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ebc30a4a1e70..d89ec8245abe 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -90,6 +90,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, + isPathSuggestionCurrent, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; @@ -203,7 +204,6 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../../lib/terminalContext"; -import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences, toKindScopedComposerContextId, @@ -279,7 +279,7 @@ import { type EnvironmentQueryTarget, } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { useDebouncedValue } from "~/state/queries"; +import { useComposerPathSearch, useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { @@ -2629,6 +2629,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) type: "path" as const, path: entry.path, pathKind: entry.kind, + searchTarget: workspaceEntries.searchedTarget, label: basenameOfPath(entry.path), description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))), })), @@ -3768,6 +3769,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; if (item.type === "path") { + if (!isPathSuggestionCurrent(item.searchTarget, trigger, environmentId, gitCwd)) return; const replacement = `${serializeComposerFileLink(item.path)} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index e89f4375ef85..4d556d48be70 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,4 +1,5 @@ import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; +import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; import { resolveProviderSkillSourceKind, type ProviderSkillSourceKind, @@ -36,6 +37,7 @@ export type ComposerCommandItem = type: "path"; path: string; pathKind: ProjectEntry["kind"]; + searchTarget: ComposerPathSearchTarget; label: string; description: string; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index be114ed78d99..1c4225971f36 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -22,6 +22,7 @@ import { expandCollapsedComposerCursor, formatAssistantCitationForComposer, isCollapsedCursorAdjacentToInlineToken, + isPathSuggestionCurrent, parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -48,6 +49,41 @@ const citation: AssistantCitation = { }; const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20"); +describe("isPathSuggestionCurrent", () => { + const searchedTarget = { + environmentId: EnvironmentId.make("local"), + cwd: "/repo", + query: "alpha", + }; + + it("allows a file suggestion matching the live search", () => { + expect( + isPathSuggestionCurrent( + searchedTarget, + detectComposerTrigger("@alpha", 6), + searchedTarget.environmentId, + searchedTarget.cwd, + ), + ).toBe(true); + }); + + it.each([ + ["query", { ...searchedTarget, query: "beta" }], + ["cwd", { ...searchedTarget, cwd: "/other-repo" }], + ["environment", { ...searchedTarget, environmentId: EnvironmentId.make("remote") }], + ] as const)("rejects a visible file suggestion after the live %s changes", (_change, current) => { + const prompt = `@${current.query}`; + expect( + isPathSuggestionCurrent( + searchedTarget, + detectComposerTrigger(prompt, prompt.length), + current.environmentId, + current.cwd, + ), + ).toBe(false); + }); +}); + describe("formatAssistantCitationForComposer", () => { it.each([undefined, "", " \n\t "])( "keeps citation-only insertion for a blank comment %j", diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 80478a7aae5b..3f37a170ca2b 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,5 +1,10 @@ import type { ClientSettings } from "@t3tools/contracts/settings"; -import type { AssistantCitation, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import type { + AssistantCitation, + EnvironmentId, + ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; import { serializeAssistantCitation, withAssistantCitationComment, @@ -23,6 +28,20 @@ export interface ComposerTrigger { rangeEnd: number; } +export function isPathSuggestionCurrent( + searchTarget: ComposerPathSearchTarget, + trigger: ComposerTrigger | null, + environmentId: EnvironmentId | null, + cwd: string | null, +) { + return ( + trigger?.kind === "path" && + searchTarget.environmentId === environmentId && + searchTarget.cwd === cwd && + searchTarget.query === trigger.query.trim() + ); +} + export function formatAssistantCitationForComposer(citation: AssistantCitation, comment = "") { return `${serializeAssistantCitation(withAssistantCitationComment(citation, comment))} `; } diff --git a/apps/web/src/lib/composerPathSearchState.ts b/apps/web/src/lib/composerPathSearchState.ts deleted file mode 100644 index 9ae590bcd7f3..000000000000 --- a/apps/web/src/lib/composerPathSearchState.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - type ComposerPathSearchState, - type ComposerPathSearchTarget, -} from "@t3tools/client-runtime/state/threads"; - -import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; - -export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const state = useComposerPathSearchQuery(target); - const entries = state.entries.map((entry) => ({ path: entry.path, kind: entry.kind })); - return { - entries, - error: state.error, - isPending: state.isPending, - }; -} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 3e2b00d269d3..b27e270aa756 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -272,6 +272,7 @@ export function useProjectPathSearch( isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || (hasTarget && (fileAccess.isPending || result.isPending)), + searchedTarget: debouncedTarget, searchedQuery: debouncedTarget.query ?? "", truncated: result.data?.truncated ?? false, refresh: result.refresh,