From 340360b51968605d649064ae2c1ccf86a07adc44 Mon Sep 17 00:00:00 2001 From: Mika Krul <167889775+MikaKrul@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:13:23 +0100 Subject: [PATCH 1/2] feat(web): style skill chips like panel tabs --- ...rPromptEditorTiptap.skill.browser.test.tsx | 58 ++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 15 ++- .../src/components/chat/SkillInlineText.tsx | 92 +++++++++++++++++-- 3 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/ComposerPromptEditorTiptap.skill.browser.test.tsx diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.skill.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.skill.browser.test.tsx new file mode 100644 index 0000000000000..17014fcd9e786 --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditorTiptap.skill.browser.test.tsx @@ -0,0 +1,58 @@ +import type { Editor } from "@tiptap/core"; +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { createRef } from "react"; +import { expect, it } from "vite-plus/test"; + +import { serializeEditorDoc } from "~/composer-rich-text-doc"; +import { render } from "../../test/render"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; + +const deslopSkill: ServerProviderSkill = { + name: "deslop", + path: "/repo/.claude/skills/deslop/SKILL.md", + enabled: true, + displayName: "Deslop", + description: "Remove AI writing patterns.", +}; + +function countSkillNodes(editor: Editor): number { + let count = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === "composer-skill") count += 1; + }); + return count; +} + +it("removes only the skill chip when its close button is clicked", async () => { + // The composer only builds a skill node when the token is followed by + // whitespace (SKILL_TOKEN_REGEX requires `(?=\s)`), so the trailing word + // is what turns "$deslop" into a chip. + const value = "Clean it up $deslop please"; + await using screen = await render( + ()} + value={value} + cursor={value.length} + richTextEnabled + contextRecords={new Map()} + skills={[deslopSkill]} + disabled={false} + placeholder="" + onChange={() => {}} + onPaste={() => {}} + />, + ); + const element = screen.container.querySelector(".tiptap"); + if (!element) throw new Error("Composer did not mount"); + const editor = element.editor; + expect(serializeEditorDoc(editor.state.doc).value).toBe("Clean it up $deslop please"); + expect(countSkillNodes(editor)).toBe(1); + + await screen.getByRole("button", { name: "Remove Deslop skill" }).click(); + + expect(countSkillNodes(editor)).toBe(0); + const nextValue = serializeEditorDoc(editor.state.doc).value; + expect(nextValue).not.toContain("$deslop"); + expect(nextValue).toContain("Clean it up"); + expect(nextValue).toContain("please"); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index f0a23d2ab4336..a79173ff20a42 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -338,7 +338,7 @@ function composerSkillMeta( }; } -function ComposerSkillNodeView({ node }: NodeViewProps) { +function ComposerSkillNodeView({ node, editor, getPos }: NodeViewProps) { const actions = use(ComposerContextActionsContext); const skills = use(RichComposerSkillsContext); const skillName = (node.attrs.skillName as string) ?? ""; @@ -350,6 +350,18 @@ function ComposerSkillNodeView({ node }: NodeViewProps) { const skillLabel = skill ? formatProviderSkillDisplayName(skill) : (node.attrs.skillLabel as string) || skillName; + const removeSkill = () => { + if (!editor.isEditable) return; + const pos = typeof getPos === "function" ? getPos() : null; + if (typeof pos !== "number") return; + const current = editor.state.doc.nodeAt(pos); + if (!current || current.type.name !== "composer-skill") return; + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + current.nodeSize }) + .run(); + }; return ( actions.openMention(skill.path) : undefined } + onRemove={removeSkill} /> ); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 59fb9d141e581..697c47fa7db89 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -4,9 +4,11 @@ import { Children, cloneElement, isValidElement, type ReactNode } from "react"; import { matchInlineSkills } from "@t3tools/shared/inlineSkills"; import { useRightPanelStore } from "~/rightPanelStore"; +import { ContextChip, ContextChipLabel } from "../ContextChip"; import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip"; -import { ContextChipPopover } from "../contextChipParts"; import { Button } from "../ui/button"; +import { PanelTabCloseButton } from "../ui/panel-tab-close-button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; function SkillInlineText(props: { text: string; @@ -83,20 +85,44 @@ export function skillInstructionsOpener( return () => useRightPanelStore.getState().openFile(threadRef, skill.path); } +/** + * Shared shell for the skill chip in the composer and in sent messages, so + * both surfaces keep the same RightPanelTab look; the composer adds pointer + * and hover classes via `className`. Border and tint colors reuse the + * ContextChip skill accent so the chip still reads as the old purple chip. + */ +function SkillTabChipShell(props: React.ComponentProps) { + return ( + + ); +} + +/** + * Resting tinted badge; PanelTabCloseButton swaps it for its X on hover + * (group/tab), sent messages keep it permanently. + */ +function SkillTabChipBadge() { + return ( + + + + ); +} + export function SkillChipPopover(props: { label: string; description: string | null | undefined; copyMarkdown?: string | undefined; onViewInstructions: (() => void) | undefined; + onRemove?: (() => void) | undefined; }) { - return ( - } - label={props.label} - accessibleLabel={`Skill ${props.label}`} - copyMarkdown={props.copyMarkdown} - > + const details = ( + <> + {`Skill ${props.label}`}

{props.label}

{props.description || "No description is available for this skill."}

@@ -106,7 +132,53 @@ export function SkillChipPopover(props: { ) : null}
-
+ + ); + if (props.onRemove) { + // The close button stays outside the popover trigger so close and + // details remain separate buttons instead of nesting. + return ( + + + + + + + } + > + {props.label} + + + {details} + + + + ); + } + return ( + + } + aria-label={`Skill ${props.label}. Show details`} + data-markdown-copy={props.copyMarkdown} + /> + } + > + + {props.label} + + + {details} + + ); } From a5b24efd1da4134bdd774a23d1a5f60bc1e3fc90 Mon Sep 17 00:00:00 2001 From: Mika Krul <167889775+MikaKrul@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:16:48 +0100 Subject: [PATCH 2/2] refactor(web): share skill chip popover shell --- .../src/components/chat/SkillInlineText.tsx | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 697c47fa7db89..5a9f9d2a0e0a1 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -134,11 +134,9 @@ export function SkillChipPopover(props: { ); - if (props.onRemove) { - // The close button stays outside the popover trigger so close and - // details remain separate buttons instead of nesting. - return ( - + return ( + + {props.onRemove ? ( @@ -154,27 +152,21 @@ export function SkillChipPopover(props: { > {props.label} - - {details} - - - ); - } - return ( - - } - aria-label={`Skill ${props.label}. Show details`} - data-markdown-copy={props.copyMarkdown} - /> - } - > - - {props.label} - + ) : ( + } + aria-label={`Skill ${props.label}. Show details`} + data-markdown-copy={props.copyMarkdown} + /> + } + > + + {props.label} + + )} {details}