Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 83 additions & 19 deletions apps/web/src/components/ComposerPromptEditor.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }) {
const [value, setValue] = useState("");
function EditorHarness(props: {
recognizedSlashCommands: ReadonlyArray<string>;
initialValue?: string;
skillAvailability?: ComposerSkillAvailability;
}) {
const [value, setValue] = useState(props.initialValue ?? "");
const [cursor, setCursor] = useState(0);
const editorRef = useRef<ComposerPromptEditorHandle | null>(null);

return (
<ComposerPromptEditor
value={value}
cursor={cursor}
terminalContexts={[]}
skills={[]}
recognizedSlashCommands={props.recognizedSlashCommands}
disabled={false}
placeholder="Type here"
onRemoveTerminalContext={vi.fn()}
onChange={(nextValue, nextCursor) => {
setValue(nextValue);
setCursor(nextCursor);
}}
onPaste={vi.fn()}
editorRef={editorRef}
/>
<>
<ComposerPromptEditor
value={value}
cursor={cursor}
terminalContexts={[]}
skills={[]}
recognizedSlashCommands={props.recognizedSlashCommands}
disabled={false}
placeholder="Type here"
onRemoveTerminalContext={vi.fn()}
onChange={(nextValue, nextCursor) => {
setValue(nextValue);
setCursor(nextCursor);
}}
onPaste={vi.fn()}
editorRef={editorRef}
{...(props.skillAvailability ? { skillAvailability: props.skillAvailability } : {})}
/>
<span data-testid="composer-prompt-value">{value}</span>
</>
);
}

Expand Down Expand Up @@ -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(
<EditorHarness recognizedSlashCommands={["plan", "default", "posthog:signals"]} />,
);
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<string>(),
authoritative,
staleReason: "Not available with the selected provider",
});
const screen = await render(
<EditorHarness
recognizedSlashCommands={[]}
initialValue="$review-diff please"
skillAvailability={availability(false)}
/>,
);

await expect
.poll(() => document.querySelectorAll("[data-composer-skill-chip]"))
.toHaveLength(1);
expect(document.querySelector("[data-composer-skill-stale]")).toBeNull();

await screen.rerender(
<EditorHarness
recognizedSlashCommands={[]}
initialValue="$review-diff please"
skillAvailability={availability(true)}
/>,
);

await expect
.poll(() => document.querySelectorAll("[data-composer-skill-stale]"))
.toHaveLength(1);
await screen.unmount();
});
});
96 changes: 72 additions & 24 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>;
authoritative: boolean;
/** Tooltip for chips this provider cannot resolve. */
staleReason: string;
};

const EMPTY_SKILL_AVAILABILITY: ComposerSkillAvailability = {
knownSkillNames: new Set<string>(),
authoritative: false,
staleReason: "Not available with the selected provider",
};

const ComposerSkillAvailabilityContext =
createContext<ComposerSkillAvailability>(EMPTY_SKILL_AVAILABILITY);

function ComposerMentionDecorator(props: { path: string }) {
const theme = resolvedThemeFromDocument();
const chip = (
Expand Down Expand Up @@ -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 = (
<span
className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
className={
isStale
? COMPOSER_INLINE_STALE_SKILL_CHIP_CLASS_NAME
: COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME
}
contentEditable={false}
spellCheck={false}
data-composer-skill-chip="true"
{...(isStale ? { "data-composer-skill-stale": "true" } : {})}
>
<span
aria-hidden="true"
Expand All @@ -265,15 +299,16 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s
</span>
);

if (!props.skillDescription) {
const tooltip = isStale ? availability.staleReason : props.skillDescription;
if (!tooltip) {
return chip;
}

return (
<Tooltip>
<TooltipTrigger render={chip} />
<TooltipPopup side="top" className="max-w-120 whitespace-normal leading-tight">
{props.skillDescription}
{tooltip}
</TooltipPopup>
</Tooltip>
);
Expand Down Expand Up @@ -350,6 +385,7 @@ class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
override decorate(): React.ReactElement {
return (
<ComposerSkillDecorator
skillName={this.__skillName}
skillLabel={this.__skillLabel}
skillDescription={this.__skillDescription}
/>
Expand Down Expand Up @@ -492,7 +528,10 @@ function $createComposerCommandTextNode(text: string): ComposerCommandTextNode {
return $applyNodeReplacement(new ComposerCommandTextNode(text));
}

const LEADING_COMMAND_TOKEN_REGEX = /^\/([a-z][a-z-]*)(?=\s|$)/i;
// Provider command names are not always plain words: Claude exposes plugin
// skills as `plugin:skill`, and both providers allow digits and underscores.
// The token still only styles when it matches a recognized name.
const LEADING_COMMAND_TOKEN_REGEX = /^\/([a-z][a-z0-9:_-]*)(?=\s|$)/i;

/** The token is only styleable while it is the very start of the prompt: the
* first text of the first paragraph, with nothing before it. */
Expand Down Expand Up @@ -1018,9 +1057,13 @@ interface ComposerPromptEditorProps {
cursor: number;
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
/** Slash commands that trigger on Enter in this thread; the leading token
* is tinted while it matches one of these. */
/** Command names this thread acts on — built-ins plus the selected
* provider's commands. The leading token is styled as a pill while it
* matches one of these; styling only, Enter handling lives elsewhere. */
recognizedSlashCommands?: ReadonlyArray<string>;
/** Skill names the selected provider can resolve. Omitted means "unknown",
* which renders every skill chip in its normal state. */
skillAvailability?: ComposerSkillAvailability;
disabled: boolean;
placeholder: string;
className?: string;
Expand Down Expand Up @@ -1850,6 +1893,7 @@ export function ComposerPromptEditor({
terminalContexts,
skills,
recognizedSlashCommands,
skillAvailability,
disabled,
placeholder,
className,
Expand Down Expand Up @@ -1886,23 +1930,27 @@ export function ComposerPromptEditor({
[],
);

// Decorator nodes portal into this subtree, so skill chips read availability
// straight from context and restyle on a provider switch alone.
return (
<LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
<ComposerPromptEditorInner
value={value}
cursor={cursor}
terminalContexts={terminalContexts}
skills={skills}
disabled={disabled}
placeholder={placeholder}
onRemoveTerminalContext={onRemoveTerminalContext}
onChange={onChange}
onPaste={onPaste}
editorRef={editorRef}
{...(recognizedSlashCommands ? { recognizedSlashCommands } : {})}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
/>
</LexicalComposer>
<ComposerSkillAvailabilityContext value={skillAvailability ?? EMPTY_SKILL_AVAILABILITY}>
<LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
<ComposerPromptEditorInner
value={value}
cursor={cursor}
terminalContexts={terminalContexts}
skills={skills}
disabled={disabled}
placeholder={placeholder}
onRemoveTerminalContext={onRemoveTerminalContext}
onChange={onChange}
onPaste={onPaste}
editorRef={editorRef}
{...(recognizedSlashCommands ? { recognizedSlashCommands } : {})}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
/>
</LexicalComposer>
</ComposerSkillAvailabilityContext>
);
}
Loading
Loading