From cbcc84ee9511025b0f561142e284dc0b204e7446 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:33:26 -0400 Subject: [PATCH] feat(web): skills join the composer / menu and commands read as pills Typing / in a Codex thread now lists workspace skills in their own menu section; picking one inserts the same $skill token as the $ shortcut. Recognized leading commands (built-ins plus the provider's own) render as an editable pill instead of a bare color tint. Skill chips turn amber with an explanatory tooltip when the selected provider cannot resolve them, instead of silently sending literal text. Menu sections are now labeled by provider, split out plugin skills, and stay visible while filtering, with keyboard order matching the visible section order. --- .../ComposerPromptEditor.browser.tsx | 102 ++++++++++++--- .../src/components/ComposerPromptEditor.tsx | 96 ++++++++++---- apps/web/src/components/chat/ChatComposer.tsx | 117 ++++++++++++++---- .../components/chat/ComposerCommandMenu.tsx | 35 ++++-- .../chat/composerSlashCommandSearch.ts | 7 ++ apps/web/src/components/composerInlineChip.ts | 5 + apps/web/src/index.css | 8 ++ 7 files changed, 294 insertions(+), 76 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.browser.tsx b/apps/web/src/components/ComposerPromptEditor.browser.tsx index 84b809371..f7c72dafc 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 30def6729..a4db2769d 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 = (