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
32 changes: 32 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import { type ReactElement } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import { act, render } from "../../test/render";
import "../index.css";
Expand Down Expand Up @@ -260,6 +261,37 @@ describe("ChatMarkdown workspace file opens", () => {
});
});

describe("ChatMarkdown skill chips", () => {
it("shows the skill's details and opens its instructions", async () => {
setup();
const threadRef = {
environmentId: EnvironmentId.make("workspace"),
threadId: ThreadId.make("thread"),
};
await using screen = await renderText("Run $review now", {
threadRef,
skills: [
{
name: "review",
displayName: "Review",
description: "Reviews the current branch.",
path: "/skills/review/SKILL.md",
enabled: true,
},
],
});
using openFile = vi
.spyOn(useRightPanelStore.getState(), "openFile")
.mockImplementation(() => {});

await userEvent.click(screen.getByRole("button", { name: "Skill Review. Show details" }));
await expect.element(page.getByText("Reviews the current branch.")).toBeVisible();
await userEvent.click(page.getByRole("button", { name: "View instructions" }));

expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/skills/review/SKILL.md");
});
});

describe("ChatMarkdown headings", () => {
it("never gives two headings the same id, even when a suffix matches another heading", async () => {
setup();
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ interface ChatMarkdownProps {
environmentId?: EnvironmentId | undefined;
onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
isStreaming?: boolean;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills?: ReadonlyArray<ServerProviderSkill>;
className?: string;
lineBreaks?: boolean;
parseRawHtml?: boolean;
Expand All @@ -250,7 +250,7 @@ export interface ChatMarkdownContextReference {
label: string;
}

const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<ServerProviderSkill> = [];
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
Expand Down Expand Up @@ -958,8 +958,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
return <div {...props}>{children}</div>;
},
p: function MarkdownParagraph({ node: _node, children, ...props }) {
const { skills } = use(ChatMarkdownRendererContext);
return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
const { skills, threadRef } = use(ChatMarkdownRendererContext);
return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills, threadRef)}</p>;
},
blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) {
const alert =
Expand Down Expand Up @@ -994,12 +994,12 @@ const CHAT_MARKDOWN_COMPONENTS = {
);
},
li: function MarkdownListItem({ node, children, ...props }) {
const { skills } = use(ChatMarkdownRendererContext);
const { skills, threadRef } = use(ChatMarkdownRendererContext);
// Resolve checkbox markers by offset to avoid rerendering list items on every streamed token.
const isTaskItem = String(props.className ?? "").includes("task-list-item");
return (
<li {...props} data-list-item-offset={isTaskItem ? node?.position?.start.offset : undefined}>
{renderSkillInlineMarkdownChildren(children, skills)}
{renderSkillInlineMarkdownChildren(children, skills, threadRef)}
</li>
);
},
Expand Down
28 changes: 7 additions & 21 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,9 @@ import { cn, isMacPlatform } from "~/lib/utils";
import { basenameOfPath } from "~/pierre-icons";
import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon";
import { FileTagChipContent } from "./chat/FileTagChip";
import { SkillChipIcon } from "./chat/SkillInlineText";
import { SkillChipPopover, skillPopoverDescription } from "./chat/SkillInlineText";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { getTimelinePageScrollKey } from "./chat/pageScrollController";
import { ContextChipPopover } from "./contextChipParts";
import { Button } from "./ui/button";
import { ContextChip } from "./ContextChip";
import {
ComposerContextActionsContext,
Expand Down Expand Up @@ -348,31 +346,19 @@ function ComposerSkillNodeView({ node }: NodeViewProps) {
const skill = skills.find(
(candidate) => candidate.name === skillName && isProviderSkillUserInvocable(candidate),
);
const description = skill
? skill.description?.trim() || skill.shortDescription?.trim()
: skillDescription;
// The node outlives a provider switch, so its stored label can be another provider's.
const skillLabel = skill
? formatProviderSkillDisplayName(skill)
: (node.attrs.skillLabel as string) || skillName;
return (
<NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
<ContextChipPopover
kind="skill"
icon={<SkillChipIcon />}
<SkillChipPopover
label={skillLabel}
accessibleLabel={`Skill ${skillLabel}`}
>
<div className="space-y-3 p-2 text-sm">
<p className="font-medium">{skillLabel}</p>
<p>{description || "No description is available for this skill."}</p>
{skill?.path && actions.environmentId ? (
<Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
View instructions
</Button>
) : null}
</div>
</ContextChipPopover>
description={skill ? skillPopoverDescription(skill) : skillDescription}
onViewInstructions={
skill?.path && actions.environmentId ? () => actions.openMention(skill.path) : undefined
}
/>
</NodeViewWrapper>
);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function TimelineListFooter({
);
}

const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_TIMELINE_SKILLS: ReadonlyArray<ServerProviderSkill> = [];
const TIMELINE_MAINTAIN_SCROLL_AT_END = {
animated: false,
on: {
Expand Down Expand Up @@ -249,7 +249,7 @@ interface MessagesTimelineProps {
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills?: ReadonlyArray<ServerProviderSkill>;
runs: ReadonlyArray<HandoffTimelineRun>;
anchorMessageId: MessageId | null;
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
Expand Down
74 changes: 61 additions & 13 deletions apps/web/src/components/chat/SkillInlineText.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";
import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
import { matchInlineSkills, type InlineSkill } from "@t3tools/shared/inlineSkills";
import { matchInlineSkills } from "@t3tools/shared/inlineSkills";

import { useRightPanelStore } from "~/rightPanelStore";
import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip";
import { ContextChip, ContextChipLabel } from "../ContextChip";
import { ContextChipPopover } from "../contextChipParts";
import { Button } from "../ui/button";

function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) {
function SkillInlineText(props: {
text: string;
skills: ReadonlyArray<ServerProviderSkill>;
threadRef: ScopedThreadRef | undefined;
}) {
const nodes: ReactNode[] = [];
let cursor = 0;

for (const { start, end, skill, rawText } of matchInlineSkills(props.text, props.skills)) {
if (start > cursor) {
nodes.push(props.text.slice(cursor, start));
}
nodes.push(<SkillChip key={`${start}:${skill.name}`} skill={skill} rawText={rawText} />);
nodes.push(
<SkillChipPopover
key={`${start}:${skill.name}`}
label={formatProviderSkillDisplayName(skill)}
description={skillPopoverDescription(skill)}
copyMarkdown={rawText}
onViewInstructions={skillInstructionsOpener(props.threadRef, skill)}
/>,
);
cursor = end;
}

Expand All @@ -28,11 +43,12 @@ function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkil

export function renderSkillInlineMarkdownChildren(
children: ReactNode,
skills: ReadonlyArray<InlineSkill>,
skills: ReadonlyArray<ServerProviderSkill>,
threadRef: ScopedThreadRef | undefined,
): ReactNode {
return Children.map(children, (child) => {
if (typeof child === "string") {
return <SkillInlineText text={child} skills={skills} />;
return <SkillInlineText text={child} skills={skills} threadRef={threadRef} />;
}
if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) {
return child;
Expand All @@ -48,21 +64,53 @@ export function renderSkillInlineMarkdownChildren(
return cloneElement(
child,
undefined,
renderSkillInlineMarkdownChildren(child.props.children, skills),
renderSkillInlineMarkdownChildren(child.props.children, skills, threadRef),
);
});
}

function SkillChip(props: { skill: InlineSkill; rawText: string }) {
export function skillPopoverDescription(
skill: Pick<ServerProviderSkill, "description" | "shortDescription">,
) {
return skill.description?.trim() || skill.shortDescription?.trim() || null;
}

export function skillInstructionsOpener(
threadRef: ScopedThreadRef | null | undefined,
skill: Pick<ServerProviderSkill, "path"> | undefined,
) {
if (!threadRef || !skill?.path) return undefined;
return () => useRightPanelStore.getState().openFile(threadRef, skill.path);
}

export function SkillChipPopover(props: {
label: string;
description: string | null | undefined;
copyMarkdown?: string | undefined;
onViewInstructions: (() => void) | undefined;
}) {
return (
<ContextChip kind="skill" data-markdown-copy={props.rawText}>
<SkillChipIcon />
<ContextChipLabel>{formatProviderSkillDisplayName(props.skill)}</ContextChipLabel>
</ContextChip>
<ContextChipPopover
kind="skill"
icon={<SkillChipIcon />}
label={props.label}
accessibleLabel={`Skill ${props.label}`}
copyMarkdown={props.copyMarkdown}
>
<div className="space-y-3 p-2 text-sm">
<p className="font-medium">{props.label}</p>
<p>{props.description || "No description is available for this skill."}</p>
{props.onViewInstructions ? (
<Button variant="outline" size="sm" onClick={props.onViewInstructions}>
View instructions
</Button>
) : null}
</div>
</ContextChipPopover>
);
}

export function SkillChipIcon() {
function SkillChipIcon() {
return (
<span
aria-hidden="true"
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/TimelineUserMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(
const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
text: string;
renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode;
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills: ReadonlyArray<ServerProviderSkill>;
markdownCwd: string | undefined;
collapsible?: boolean;
footer?: ReactNode;
Expand Down Expand Up @@ -811,7 +811,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
const UserMessageBody = memo(function UserMessageBody(props: {
text: string;
renderContextReference?: (reference: ChatMarkdownContextReference) => ReactNode;
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills: ReadonlyArray<ServerProviderSkill>;
markdownCwd: string | undefined;
}) {
const ctx = use(TimelineRowCtx);
Expand Down
39 changes: 16 additions & 23 deletions apps/web/src/components/chat/UserMessageContextChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
ContextChipPopover as UserMessageContextPopover,
ContextChipShell,
FileChip,
ImageChipButton,
PULL_REQUEST_CHIP_KINDS,
Expand All @@ -48,7 +47,11 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR
import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
import { createContextPresentationRegistry } from "../contextPresentationRegistry";
import type { ChatMarkdownContextReference } from "../ChatMarkdown";
import { SkillChipIcon } from "./SkillInlineText";
import {
SkillChipPopover,
skillInstructionsOpener,
skillPopoverDescription,
} from "./SkillInlineText";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";
import {
buildReviewCommentRenderablePatch,
Expand Down Expand Up @@ -99,22 +102,18 @@ function UserMessageMentionChip(props: {
);
}

function UserMessageContextChip(props: {
icon: ReactNode;
label: string;
kindLabel?: string;
function UserMessageSkillChip(props: {
record: Extract<KnownComposerContextRecord, { kind: "skill" }>;
copyMarkdown: string;
tooltip?: string;
kind: ContextChipKind;
}) {
const { skills, threadRef } = use(TimelineRowCtx);
const skill = skills.find((candidate) => candidate.name === props.record.name);
return (
<ContextChipShell
kind={props.kind}
icon={props.icon}
label={props.label}
aria-label={props.kindLabel ? `${props.kindLabel}, ${props.label}` : undefined}
data-markdown-copy={props.copyMarkdown}
tooltip={props.tooltip}
<SkillChipPopover
label={props.record.label || props.record.name}
description={skill ? skillPopoverDescription(skill) : null}
copyMarkdown={props.copyMarkdown}
onViewInstructions={skillInstructionsOpener(threadRef, skill)}
/>
);
}
Expand Down Expand Up @@ -315,14 +314,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
canRender: (record) => record.kind === "skill",
render: (record, context) =>
record.kind === "skill" ? (
<UserMessageContextChip
icon={<SkillChipIcon />}
label={record.label || record.name}
kindLabel="Skill"
tooltip={`$${record.name}`}
copyMarkdown={context.copyMarkdown}
kind="skill"
/>
<UserMessageSkillChip record={record} copyMarkdown={context.copyMarkdown} />
) : (
<UnavailableUserMessageContextChip {...context} />
),
Expand Down Expand Up @@ -562,6 +554,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
<ChatMarkdown
text={comment.text}
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
skills={ctx.skills}
parseRawHtml={false}
lineBreaks
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/timelineRowContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface TimelineRowSharedState {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
workspaceRoot: string | undefined;
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills: ReadonlyArray<ServerProviderSkill>;
runs: ReadonlyArray<HandoffTimelineRun>;
activeThreadEnvironmentId: EnvironmentId;
onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/contextChipParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function ContextChipPopover(props: {
kind: ContextChipKind;
icon: ReactNode;
label: string;
copyMarkdown?: string;
copyMarkdown?: string | undefined;
accessibleLabel: string;
children: ReactNode;
}) {
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/inlineSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export function formatProviderSkillDisplayName(
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;

export function* matchInlineSkills(text: string, skills: readonly InlineSkill[]) {
export function* matchInlineSkills<Skill extends InlineSkill>(
text: string,
skills: readonly Skill[],
) {
for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
const name = match[2] ?? "";
const skill = skills.find((candidate) => candidate.name === name);
Expand Down
Loading