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
19 changes: 18 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ServerProviderSkill } from "@t3tools/contracts";
import React, {
Children,
Suspense,
Expand All @@ -19,6 +20,7 @@ import ReactMarkdown from "react-markdown";
import { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { VscodeEntryIcon } from "./chat/VscodeEntryIcon";
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { openInPreferredEditor } from "../editorPreferences";
Expand Down Expand Up @@ -59,8 +61,11 @@ interface ChatMarkdownProps {
text: string;
cwd: string | undefined;
isStreaming?: boolean;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
}

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

const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
Expand Down Expand Up @@ -507,7 +512,12 @@ function areMarkdownFileLinkPropsEqual(
);
}

function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
function ChatMarkdown({
text,
cwd,
isStreaming = false,
skills = EMPTY_MARKDOWN_SKILLS,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownFileLinkMetaByHref = useMemo(() => {
Expand All @@ -534,6 +544,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
}, []);
const markdownComponents = useMemo<Components>(
() => ({
p({ node: _node, children, ...props }) {
return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>;
},
li({ node: _node, children, ...props }) {
return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>;
},
a({ node: _node, href, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
Expand Down Expand Up @@ -592,6 +608,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
isStreaming,
markdownFileLinkMetaByHref,
resolvedTheme,
skills,
],
);

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
type EnvironmentUnavailableState = {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -1779,7 +1780,7 @@
);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();

Check warning on line 1783 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
Expand All @@ -1787,7 +1788,7 @@
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
composerRef.current?.addTerminalContext(selection);

Check warning on line 1791 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
Expand Down Expand Up @@ -2466,7 +2467,7 @@
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(terminalState.terminalOpen),
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,

Check warning on line 2470 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useEffect has a missing dependency: 'composerRef.current'
};

const command = resolveShortcutCommand(event, keybindings, {
Expand Down Expand Up @@ -3018,7 +3019,7 @@
};
});
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });

Check warning on line 3022 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
},
[activePendingProgress?.activeQuestion, activePendingUserInput],
);
Expand All @@ -3045,7 +3046,7 @@
),
},
}));
const snapshot = composerRef.current?.readSnapshot();

Check warning on line 3049 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (
snapshot?.value !== value ||
snapshot.cursor !== nextCursor ||
Expand Down Expand Up @@ -3108,7 +3109,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3112 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3245,7 +3246,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3249 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3574,6 +3575,7 @@
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
workspaceRoot={activeWorkspaceRoot}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
onIsAtEndChange={onIsAtEndChange}
/>

Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
SKILL_CHIP_ICON_SVG,
} from "./composerInlineChip";
import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
Expand Down Expand Up @@ -216,8 +217,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {
return $applyNodeReplacement(new ComposerMentionNode(path));
}

const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" 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>`;

function resolveSkillDescription(
skill: Pick<ServerProviderSkill, "shortDescription" | "description">,
): string | null {
Expand Down
29 changes: 24 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts";
import {
type EnvironmentId,
type MessageId,
type ServerProviderSkill,
type TurnId,
} from "@t3tools/contracts";
import {
createContext,
memo,
Expand Down Expand Up @@ -60,6 +65,7 @@ import {
formatInlineTerminalContextLabel,
textContainsInlineTerminalContextLabels,
} from "./userMessageTerminalContexts";
import { SkillInlineText } from "./SkillInlineText";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";

// ---------------------------------------------------------------------------
Expand All @@ -75,6 +81,7 @@ interface TimelineRowSharedState {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
workspaceRoot: string | undefined;
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
activeThreadEnvironmentId: EnvironmentId;
onRevertUserMessage: (messageId: MessageId) => void;
onImageExpand: (preview: ExpandedImagePreview) => void;
Expand All @@ -93,6 +100,7 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];

// ---------------------------------------------------------------------------
// Props (public API)
Expand All @@ -119,6 +127,7 @@ interface MessagesTimelineProps {
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
onIsAtEndChange: (isAtEnd: boolean) => void;
}

Expand Down Expand Up @@ -147,6 +156,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
resolvedTheme,
timestampFormat,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
onIsAtEndChange,
}: MessagesTimelineProps) {
const rawRows = useMemo(
Expand Down Expand Up @@ -202,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
markdownCwd,
resolvedTheme,
workspaceRoot,
skills,
activeThreadEnvironmentId,
onRevertUserMessage,
onImageExpand,
Expand All @@ -213,6 +224,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
markdownCwd,
resolvedTheme,
workspaceRoot,
skills,
activeThreadEnvironmentId,
onRevertUserMessage,
onImageExpand,
Expand Down Expand Up @@ -357,6 +369,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
<UserMessageBody
text={displayedUserMessage.visibleText}
terminalContexts={terminalContexts}
skills={ctx.skills}
/>
)}
<div className="mt-1.5 flex items-center justify-end gap-2">
Expand Down Expand Up @@ -405,6 +418,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
text={messageText}
cwd={ctx.markdownCwd}
isStreaming={Boolean(row.message.streaming)}
skills={ctx.skills}
/>
<AssistantChangedFilesSection
turnSummary={row.assistantTurnDiffSummary}
Expand Down Expand Up @@ -723,6 +737,7 @@ const UserMessageTerminalContextInlineLabel = memo(
const UserMessageBody = memo(function UserMessageBody(props: {
text: string;
terminalContexts: ParsedTerminalContextEntry[];
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
}) {
if (props.terminalContexts.length > 0) {
const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(
Expand All @@ -745,7 +760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
if (matchIndex > cursor) {
inlineNodes.push(
<span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}>
{props.text.slice(cursor, matchIndex)}
<SkillInlineText text={props.text.slice(cursor, matchIndex)} skills={props.skills} />
</span>,
);
}
Expand All @@ -762,7 +777,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
if (cursor < props.text.length) {
inlineNodes.push(
<span key={`user-message-terminal-context-inline-rest:${cursor}`}>
{props.text.slice(cursor)}
<SkillInlineText text={props.text.slice(cursor)} skills={props.skills} />
</span>,
);
}
Expand Down Expand Up @@ -790,7 +805,11 @@ const UserMessageBody = memo(function UserMessageBody(props: {
}

if (props.text.length > 0) {
inlineNodes.push(<span key="user-message-terminal-context-inline-text">{props.text}</span>);
inlineNodes.push(
<span key="user-message-terminal-context-inline-text">
<SkillInlineText text={props.text} skills={props.skills} />
</span>,
);
} else if (inlinePrefix.length === 0) {
return null;
}
Expand All @@ -808,7 +827,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {

return (
<div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground">
{props.text}
<SkillInlineText text={props.text} skills={props.skills} />
</div>
);
});
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/chat/SkillInlineText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
import type { ServerProviderSkill } from "@t3tools/contracts";

import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
import {
COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
SKILL_CHIP_ICON_SVG,
} from "../composerInlineChip";

const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;

type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;

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

for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const start = (match.index ?? 0) + prefix.length;
const rawText = `$${name}`;
const skill = props.skills.find((candidate) => candidate.name === name);
if (!skill) {
continue;
}

if (start > cursor) {
nodes.push(props.text.slice(cursor, start));
}
nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);
cursor = start + rawText.length;
}

if (cursor === 0) {
return <>{props.text}</>;
}
if (cursor < props.text.length) {
nodes.push(props.text.slice(cursor));
}
return <>{nodes}</>;
}

export function renderSkillInlineMarkdownChildren(
children: ReactNode,
skills: ReadonlyArray<InlineSkill>,
): ReactNode {
return Children.map(children, (child) => {
if (typeof child === "string") {
return <SkillInlineText text={child} skills={skills} />;
}
if (!isValidElement<{ children?: ReactNode }>(child)) {
return child;
}
if (child.type === "code" || child.type === "a") {
return child;
}
if (!("children" in child.props)) {
return child;
}
return cloneElement(
child,
undefined,
renderSkillInlineMarkdownChildren(child.props.children, skills),
);
});
}

function SkillChip(props: { skill: InlineSkill; rawText: string }) {
return (
<span className="inline-flex align-middle leading-none">
<span className="sr-only">{props.rawText}</span>
<span aria-hidden="true" className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}>
<span
aria-hidden="true"
className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
/>
<span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>
{formatProviderSkillDisplayName(props.skill)}
</span>
</span>
</span>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/components/composerInlineChip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leadi
export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
"inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300";

export const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" 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>`;

export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
"ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
Loading