Skip to content
Closed
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
7 changes: 3 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import type {
AssetResource,
EnvironmentId,
ScopedThreadRef,
ServerProviderSkill,
ThreadPullRequestKey,
} from "@t3tools/contracts";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -207,7 +206,7 @@ interface ChatMarkdownProps {
environmentId?: EnvironmentId | undefined;
onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
isStreaming?: boolean;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills?: ReadonlyArray<InlineSkill>;
className?: string;
/** Treat single newlines as hard breaks — chat-style user input. */
lineBreaks?: boolean;
Expand Down Expand Up @@ -272,7 +271,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
);
}

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

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>()).toEqual({
...held,
markdownCwd: "/repo/a",
workspaceRoot: "/repo/a",
skills,
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export type HeldThreadTimeline<T extends readonly unknown[]> = {
entries: T;
markdownCwd?: string | null;
workspaceRoot?: string | null;
skills?: ServerProvider["skills"];
};

const MAX_REMEMBERED_THREAD_TIMELINES = 16;
Expand Down
23 changes: 19 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof timelineEntries>()
: null;
Expand Down Expand Up @@ -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}
Expand Down
13 changes: 6 additions & 7 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
type EnvironmentId,
type MessageId,
type ScopedThreadRef,
type ServerProviderSkill,
type ToolActivityIcon,
type TurnId,
type WorktreeSetupSnapshot,
Expand Down Expand Up @@ -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 {
Expand All @@ -273,7 +272,7 @@ interface TimelineRowSharedState {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
workspaceRoot: string | undefined;
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills: ReadonlyArray<InlineSkill>;
activeThreadEnvironmentId: EnvironmentId;
onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
Expand Down Expand Up @@ -366,7 +365,7 @@ function TimelineListFooter({ composerInset }: { readonly composerInset: number
</div>
);
}
const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_TIMELINE_SKILLS: ReadonlyArray<InlineSkill> = [];
const TIMELINE_MAINTAIN_SCROLL_AT_END = {
animated: false,
on: {
Expand Down Expand Up @@ -434,7 +433,7 @@ interface MessagesTimelineProps {
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills?: ReadonlyArray<InlineSkill>;
anchorMessageId: MessageId | null;
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
contentInsetEndAdjustment: number;
Expand Down Expand Up @@ -3909,7 +3908,7 @@ function shouldCollapseUserMessage(text: string): boolean {
const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
text: string;
renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode;
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
skills: ReadonlyArray<InlineSkill>;
markdownCwd: string | undefined;
footer?: ReactNode;
}) {
Expand Down Expand Up @@ -3977,7 +3976,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<InlineSkill>;
markdownCwd: string | undefined;
}) {
const ctx = use(TimelineRowCtx);
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/components/chat/SkillInlineText.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SkillInlineText text="run $handoff now" skills={[handoffSkill]} />,
);

expect(markup.match(/data-markdown-copy="\$handoff"/g)).toHaveLength(1);
expect(markup).toContain(">Handoff</span>");
});

it("preserves a skill token as plain text when no skills are available", () => {
const markup = renderToStaticMarkup(<SkillInlineText text="run $handoff now" skills={[]} />);

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(
<SkillInlineText text="$not-a-skill" skills={[handoffSkill]} />,
);

expect(markup).toBe("$not-a-skill");
expect(markup).not.toContain("data-markdown-copy");
});

it("preserves a skill token adjacent to punctuation", () => {
const markup = renderToStaticMarkup(
<SkillInlineText text="($handoff)" skills={[handoffSkill]} />,
);

expect(markup).toBe("($handoff)");
expect(markup).not.toContain("data-markdown-copy");
});

it("wraps a described skill chip in a tooltip trigger", () => {
const markup = renderToStaticMarkup(
<SkillInlineText
text="$handoff"
skills={[{ ...handoffSkill, description: "Prepare a handoff document" }]}
/>,
);

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(
<SkillInlineText text="$handoff" skills={[handoffSkill]} />,
);

expect(markup).not.toContain("tooltip-trigger");
});
});
43 changes: 38 additions & 5 deletions apps/web/src/components/chat/SkillInlineText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProviderSkill, "name" | "displayName">;
export type InlineSkill = Pick<
ServerProviderSkill,
"name" | "displayName" | "description" | "shortDescription"
>;

export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) {
const nodes: ReactNode[] = [];
Expand Down Expand Up @@ -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 (
<ContextChip kind="skill" data-markdown-copy={props.rawText}>
const description = resolveInlineSkillDescription(props.skill);
const chip = (
<ContextChip
kind="skill"
data-markdown-copy={props.rawText}
tabIndex={description ? 0 : undefined}
>
<SkillChipIcon />
<ContextChipLabel>{formatProviderSkillDisplayName(props.skill)}</ContextChipLabel>
</ContextChip>
);

if (!description) {
return chip;
}

return (
<Tooltip>
<TooltipTrigger render={chip} />
<TooltipPopup side="top">{description}</TooltipPopup>
</Tooltip>
);
}

/** 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 (
<span
aria-hidden="true"
className="contents"
className="block size-[1em] shrink-0 self-center opacity-85"
dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
/>
);
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/composerInlineChip.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const SKILL_CHIP_ICON_SVG = `<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
// The inline style pins the glyph to its wrapper so `SkillChipIcon` owns the
// size; without it the chip's descendant `svg` sizing rule would win.
export const SKILL_CHIP_ICON_SVG = `<svg style="display:block;width:100%;height:100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;

/** Keeps both the recognizable beginning and the extension/end of a long attachment name. */
export function middleTruncateAttachmentName(name: string, maxCharacters = 36): string {
Expand Down
5 changes: 4 additions & 1 deletion docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ provider. On mobile, both are also available before starting a thread on
**New task**.

The slash menu also includes skills unless you turn off **Settings → General →
Show skills in slash menu**. Only skills enabled for the provider are listed.
Show skills in slash menu**. Only skills enabled for the provider are listed. On web
and desktop, Codex, Claude, and OpenCode list skills for the workspace you are working
in: the active thread's worktree when it has one, otherwise the project folder.
Personal and system skills are always included.

Provider commands must start the message to run. T3 Code commands such as
`/model` and `/plan`, and skill mentions, work on any line.
Expand Down
Loading