diff --git a/apps/web/src/components/ComposerPromptEditor.browser.tsx b/apps/web/src/components/ComposerPromptEditor.browser.tsx index 84b80937..f7c72daf 100644 --- a/apps/web/src/components/ComposerPromptEditor.browser.tsx +++ b/apps/web/src/components/ComposerPromptEditor.browser.tsx @@ -5,30 +5,42 @@ import { page, userEvent } from "vite-plus/test/browser"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { + ComposerPromptEditor, + type ComposerPromptEditorHandle, + type ComposerSkillAvailability, +} from "./ComposerPromptEditor"; -function EditorHarness(props: { recognizedSlashCommands: ReadonlyArray }) { - const [value, setValue] = useState(""); +function EditorHarness(props: { + recognizedSlashCommands: ReadonlyArray; + initialValue?: string; + skillAvailability?: ComposerSkillAvailability; +}) { + const [value, setValue] = useState(props.initialValue ?? ""); const [cursor, setCursor] = useState(0); const editorRef = useRef(null); return ( - { - setValue(nextValue); - setCursor(nextCursor); - }} - onPaste={vi.fn()} - editorRef={editorRef} - /> + <> + { + setValue(nextValue); + setCursor(nextCursor); + }} + onPaste={vi.fn()} + editorRef={editorRef} + {...(props.skillAvailability ? { skillAvailability: props.skillAvailability } : {})} + /> + {value} + ); } @@ -93,4 +105,56 @@ describe("ComposerPromptEditor command token", () => { expect(commandTokenText()).toBeNull(); await screen.unmount(); }); + + it("tints a namespaced provider command and keeps the prompt plain text", async () => { + const screen = await render( + , + ); + await typeIntoEditor("/posthog:signals check the inbox"); + + await expect.poll(commandTokenText).toBe("/posthog:signals"); + await expect + .poll(() => page.getByTestId("composer-prompt-value").query()?.textContent) + .toBe("/posthog:signals check the inbox"); + await screen.unmount(); + }); +}); + +describe("ComposerPromptEditor skill chip", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("marks a skill chip stale once the provider's skill list is authoritative", async () => { + const availability = (authoritative: boolean): ComposerSkillAvailability => ({ + knownSkillNames: new Set(), + authoritative, + staleReason: "Not available with the selected provider", + }); + const screen = await render( + , + ); + + await expect + .poll(() => document.querySelectorAll("[data-composer-skill-chip]")) + .toHaveLength(1); + expect(document.querySelector("[data-composer-skill-stale]")).toBeNull(); + + await screen.rerender( + , + ); + + await expect + .poll(() => document.querySelectorAll("[data-composer-skill-stale]")) + .toHaveLength(1); + await screen.unmount(); + }); }); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 30def672..a4db2769 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -73,6 +73,7 @@ import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + COMPOSER_INLINE_STALE_SKILL_CHIP_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; @@ -131,6 +132,28 @@ const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: () => {}, }); +/** + * Which `$skill` names the thread's selected provider can actually resolve. + * Chips read this at render time, so switching provider restyles them without + * rebuilding editor state. `authoritative` is false while the list is still + * loading — chips stay normal rather than flashing a warning. + */ +export type ComposerSkillAvailability = { + knownSkillNames: ReadonlySet; + authoritative: boolean; + /** Tooltip for chips this provider cannot resolve. */ + staleReason: string; +}; + +const EMPTY_SKILL_AVAILABILITY: ComposerSkillAvailability = { + knownSkillNames: new Set(), + authoritative: false, + staleReason: "Not available with the selected provider", +}; + +const ComposerSkillAvailabilityContext = + createContext(EMPTY_SKILL_AVAILABILITY); + function ComposerMentionDecorator(props: { path: string }) { const theme = resolvedThemeFromDocument(); const chip = ( @@ -248,13 +271,24 @@ function skillMetadataByName( ); } -function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { +function ComposerSkillDecorator(props: { + skillName: string; + skillLabel: string; + skillDescription: string | null; +}) { + const availability = use(ComposerSkillAvailabilityContext); + const isStale = availability.authoritative && !availability.knownSkillNames.has(props.skillName); const chip = (