From a7afdc8e19b2eaec3291499207ae8605ed38fde3 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Wed, 2 Sep 2026 11:39:01 +0200 Subject: [PATCH 1/2] fix(web): match skill chip icons to file chips and add sent-message tooltips The package glyph fills its viewBox edge-to-edge, unlike the file-type icons that carry intrinsic padding, so skill chips render their icon one step smaller to sit at the same optical size. Sent-message chips gain the description tooltip the composer already shows; the InlineSkill prop type carries the description fields through MessagesTimeline and ChatMarkdown, which previously narrowed them away. --- apps/web/src/components/ChatMarkdown.tsx | 7 +- .../web/src/components/ChatView.logic.test.ts | 12 +++- apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/ChatView.tsx | 23 +++++-- .../src/components/chat/MessagesTimeline.tsx | 13 ++-- .../components/chat/SkillInlineText.test.tsx | 65 +++++++++++++++++++ .../src/components/chat/SkillInlineText.tsx | 43 ++++++++++-- apps/web/src/components/composerInlineChip.ts | 4 +- 8 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/chat/SkillInlineText.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f48c573aa8bc..8c32aa9d3f53 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -30,7 +30,6 @@ import type { AssetResource, EnvironmentId, ScopedThreadRef, - ServerProviderSkill, ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; @@ -95,7 +94,7 @@ import { remarkCodexDirectives, renderCodexFileCitationsAsMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; -import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; +import { renderSkillInlineMarkdownChildren, type InlineSkill } from "./chat/SkillInlineText"; import { resolveMarkdownMediaPreview, type ExpandedImagePreview, @@ -207,7 +206,7 @@ interface ChatMarkdownProps { environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; - skills?: ReadonlyArray>; + skills?: ReadonlyArray; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; @@ -272,7 +271,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { ); } -const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_MARKDOWN_SKILLS: ReadonlyArray = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a2708de8bda3..b5b9526cc75c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -562,16 +562,26 @@ describe("resolveThreadSwitchTimeline", () => { ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); }); - it("keeps the held thread workspace cwd with the snapshot", () => { + it("keeps the held thread workspace cwd and skills with the snapshot", () => { + const skills: ServerProvider["skills"] = [ + { + name: "deploy", + displayName: "Deploy", + path: "/repo/a/.claude/skills/deploy", + enabled: true, + }, + ]; rememberReadyThreadTimeline({ ...held, markdownCwd: "/repo/a", workspaceRoot: "/repo/a", + skills, }); expect(peekHeldThreadTimeline()).toEqual({ ...held, markdownCwd: "/repo/a", workspaceRoot: "/repo/a", + skills, }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 528024de4393..01560a8023dc 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -283,6 +283,7 @@ export type HeldThreadTimeline = { entries: T; markdownCwd?: string | null; workspaceRoot?: string | null; + skills?: ServerProvider["skills"]; }; const MAX_REMEMBERED_THREAD_TIMELINES = 16; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9af410f5722e..61d4ace8c716 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3646,6 +3646,13 @@ export default function ChatView(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const timelineSkills = useMemo( + () => + activeProviderStatus + ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) + : EMPTY_PROVIDER_SKILLS, + [activeProviderStatus, gitCwd], + ); const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null @@ -3735,8 +3742,16 @@ export default function ChatView(props: ChatViewProps) { entries: timelineEntries, markdownCwd: gitCwd, workspaceRoot: activeWorkspaceRoot ?? null, + skills: timelineSkills, }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); + }, [ + activeThreadKey, + activeWorkspaceRoot, + gitCwd, + threadDetailLoading, + timelineEntries, + timelineSkills, + ]); const heldPaintContext = paintOnlyDisplayedTimeline ? peekHeldThreadTimeline() : null; @@ -9952,9 +9967,9 @@ export default function ChatView(props: ChatViewProps) { : activeWorkspaceRoot } skills={ - activeProviderStatus - ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) - : EMPTY_PROVIDER_SKILLS + paintOnlyDisplayedTimeline + ? (heldPaintContext?.skills ?? EMPTY_PROVIDER_SKILLS) + : timelineSkills } anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 20b8438fbc1c..4b57f8f371fd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -17,7 +17,6 @@ import { type EnvironmentId, type MessageId, type ScopedThreadRef, - type ServerProviderSkill, type ToolActivityIcon, type TurnId, type WorktreeSetupSnapshot, @@ -247,7 +246,7 @@ import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; -import { SkillChipIcon, SkillInlineText } from "./SkillInlineText"; +import { SkillChipIcon, SkillInlineText, type InlineSkill } from "./SkillInlineText"; import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { @@ -273,7 +272,7 @@ interface TimelineRowSharedState { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; - skills: ReadonlyArray>; + skills: ReadonlyArray; activeThreadEnvironmentId: EnvironmentId; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; @@ -366,7 +365,7 @@ function TimelineListFooter({ composerInset }: { readonly composerInset: number ); } -const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const EMPTY_TIMELINE_SKILLS: ReadonlyArray = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, on: { @@ -434,7 +433,7 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; - skills?: ReadonlyArray>; + skills?: ReadonlyArray; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; contentInsetEndAdjustment: number; @@ -3909,7 +3908,7 @@ function shouldCollapseUserMessage(text: string): boolean { const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; - skills: ReadonlyArray>; + skills: ReadonlyArray; markdownCwd: string | undefined; footer?: ReactNode; }) { @@ -3977,7 +3976,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop const UserMessageBody = memo(function UserMessageBody(props: { text: string; renderContextReference?: (reference: ChatMarkdownContextReference) => ReactNode; - skills: ReadonlyArray>; + skills: ReadonlyArray; markdownCwd: string | undefined; }) { const ctx = use(TimelineRowCtx); diff --git a/apps/web/src/components/chat/SkillInlineText.test.tsx b/apps/web/src/components/chat/SkillInlineText.test.tsx new file mode 100644 index 000000000000..8306e592ecd6 --- /dev/null +++ b/apps/web/src/components/chat/SkillInlineText.test.tsx @@ -0,0 +1,65 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { SkillInlineText } from "./SkillInlineText"; + +const handoffSkill = { + name: "handoff", + displayName: "Handoff", +}; + +describe("SkillInlineText", () => { + it("renders a known skill token as a labeled chip", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/data-markdown-copy="\$handoff"/g)).toHaveLength(1); + expect(markup).toContain(">Handoff"); + }); + + it("preserves a skill token as plain text when no skills are available", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toBe("run $handoff now"); + expect(markup).not.toContain("data-markdown-copy"); + }); + + it("preserves an unknown skill token when other skills are available", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe("$not-a-skill"); + expect(markup).not.toContain("data-markdown-copy"); + }); + + it("preserves a skill token adjacent to punctuation", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe("($handoff)"); + expect(markup).not.toContain("data-markdown-copy"); + }); + + it("wraps a described skill chip in a tooltip trigger", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-slot="tooltip-trigger"'); + expect(markup.match(/data-markdown-copy="\$handoff"/g)).toHaveLength(1); + }); + + it("renders a chip without a tooltip when the skill has no description", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain("tooltip-trigger"); + }); +}); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 2327577b3a80..6dd283c546c1 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -4,11 +4,15 @@ import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/provider import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip"; import { ContextChip, ContextChipLabel } from "../ContextChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const SKILL_TOKEN_REGEX = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; -type InlineSkill = Pick; +export type InlineSkill = Pick< + ServerProviderSkill, + "name" | "displayName" | "description" | "shortDescription" +>; export function SkillInlineText(props: { text: string; skills: ReadonlyArray }) { const nodes: ReactNode[] = []; @@ -68,21 +72,50 @@ export function renderSkillInlineMarkdownChildren( }); } +function resolveInlineSkillDescription(skill: InlineSkill): string | null { + const shortDescription = skill.shortDescription?.trim(); + if (shortDescription) { + return shortDescription; + } + const description = skill.description?.trim(); + return description || null; +} + function SkillChip(props: { skill: InlineSkill; rawText: string }) { - return ( - + const description = resolveInlineSkillDescription(props.skill); + const chip = ( + {formatProviderSkillDisplayName(props.skill)} ); + + if (!description) { + return chip; + } + + return ( + + + {description} + + ); } -/** The skill glyph; the surrounding chip sizes its svg. */ +/** + * The skill glyph. The package icon fills its viewBox edge-to-edge, unlike the + * file-type icons that carry intrinsic padding, so it renders one step smaller + * and lighter than the chip's default svg size to match the file chips' optical weight. + */ export function SkillChipIcon() { return (