authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 21:33:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 21:33:01-07:00
log266d70cc4d887b7459ec6557c430ec7bb92bd3fc
tree8ca54b8437862c97047cd42b4599210d7d52729c
parent29931dd3a9a4996a5282f59564a64e6be496d5c6
signature Signed by PGP key B5690EEEBB952194

refactor(web): context chips render through one ContextChip component (#13192)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

14 files changed, 330 insertions(+), 576 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+17-35
...@@ -105,7 +105,7 @@ import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGa...@@ -105,7 +105,7 @@ import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGa
105import { MediaVideoPlayer } from "./media/MediaVideoPlayer";105import { MediaVideoPlayer } from "./media/MediaVideoPlayer";
106import { MediaActions, type MediaActionSource } from "./media/MediaActions";106import { MediaActions, type MediaActionSource } from "./media/MediaActions";
107import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";107import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
108import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";108import { FileTagChipContent } from "./chat/FileTagChip";
109import { PierreEntryIcon } from "./chat/PierreEntryIcon";109import { PierreEntryIcon } from "./chat/PierreEntryIcon";
110import {110import {
111 revealInFileExplorerLabelForKind,111 revealInFileExplorerLabelForKind,
...@@ -118,6 +118,7 @@ import {...@@ -118,6 +118,7 @@ import {
118import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";118import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";
119import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";119import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
120import { Button } from "./ui/button";120import { Button } from "./ui/button";
121import { ContextChip } from "./ContextChip";
121import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";122import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";
122import { ScrollArea } from "./ui/scroll-area";123import { ScrollArea } from "./ui/scroll-area";
123import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";124import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
...@@ -1156,11 +1157,9 @@ interface MarkdownFileLinkProps {...@@ -1156,11 +1157,9 @@ interface MarkdownFileLinkProps {
1156 /** Platform-specific menu label ("Reveal in Finder", ...); required for the1157 /** Platform-specific menu label ("Reveal in Finder", ...); required for the
1157 reveal item to show. */1158 reveal item to show. */
1158 revealLabel?: string | undefined;1159 revealLabel?: string | undefined;
1159 className?: string | undefined;
1160}1160}
11611161
1162const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link";1162const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link";
1163const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`;
11641163
1165function pathParentSegments(path: string): string[] {1164function pathParentSegments(path: string): string[] {
1166 const normalized = path.replaceAll("\\", "/");1165 const normalized = path.replaceAll("\\", "/");
...@@ -1895,7 +1894,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1895,7 +1894,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1895 onOpenMedia,1894 onOpenMedia,
1896 onReveal,1895 onReveal,
1897 revealLabel,1896 revealLabel,
1898 className,
1899}: MarkdownFileLinkProps) {1897}: MarkdownFileLinkProps) {
1900 const handleOpenInEditor = useCallback(() => {1898 const handleOpenInEditor = useCallback(() => {
1901 if (!onOpen) {1899 if (!onOpen) {
...@@ -2165,13 +2163,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -2165,13 +2163,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
2165 <TooltipTrigger2163 <TooltipTrigger
2166 render={2164 render={
2167 hasPrimaryAction ? (2165 hasPrimaryAction ? (
2168 <a2166 <ContextChip
2169 href={href}2167 kind="mention"
2170 className={cn(2168 render={<a href={href} />}
2171 CHAT_FILE_TAG_CHIP_CLASS_NAME,2169 className={MARKDOWN_FILE_LINK_CLASS_NAME}
2172 MARKDOWN_FILE_LINK_CLASS_NAME,
2173 className,
2174 )}
2175 data-markdown-copy={copyMarkdown}2170 data-markdown-copy={copyMarkdown}
2176 onClick={(event) => {2171 onClick={(event) => {
2177 event.preventDefault();2172 event.preventDefault();
...@@ -2188,25 +2183,21 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -2188,25 +2183,21 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
2188 }}2183 }}
2189 onContextMenu={handleContextMenu}2184 onContextMenu={handleContextMenu}
2190 >2185 >
2191 <FileTagChipContent path={iconPath} label={label} theme={theme} selectable />2186 <FileTagChipContent path={iconPath} label={label} theme={theme} />
2192 </a>2187 </ContextChip>
2193 ) : (2188 ) : (
2194 <button2189 <ContextChip
2195 type="button"2190 kind="mention"
2191 render={<button type="button" />}
2196 aria-label={`File options for ${label}`}2192 aria-label={`File options for ${label}`}
2197 aria-haspopup="menu"2193 aria-haspopup="menu"
2198 className={cn(2194 className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")}
2199 CHAT_FILE_TAG_CHIP_CLASS_NAME,
2200 MARKDOWN_FILE_LINK_CLASS_NAME,
2201 "select-text",
2202 className,
2203 )}
2204 data-markdown-copy={copyMarkdown}2195 data-markdown-copy={copyMarkdown}
2205 onClick={handleContextMenu}2196 onClick={handleContextMenu}
2206 onContextMenu={handleContextMenu}2197 onContextMenu={handleContextMenu}
2207 >2198 >
2208 <FileTagChipContent path={iconPath} label={label} theme={theme} selectable />2199 <FileTagChipContent path={iconPath} label={label} theme={theme} />
2209 </button>2200 </ContextChip>
2210 )2201 )
2211 }2202 }
2212 />2203 />
...@@ -2242,8 +2233,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -2242,8 +2233,7 @@ function areMarkdownFileLinkPropsEqual(
2242 previous.onOpenInBrowser === next.onOpenInBrowser &&2233 previous.onOpenInBrowser === next.onOpenInBrowser &&
2243 previous.onOpenMedia === next.onOpenMedia &&2234 previous.onOpenMedia === next.onOpenMedia &&
2244 previous.onReveal === next.onReveal &&2235 previous.onReveal === next.onReveal &&
2245 previous.revealLabel === next.revealLabel &&2236 previous.revealLabel === next.revealLabel
2246 previous.className === next.className
2247 );2237 );
2248}2238}
22492239
...@@ -2571,12 +2561,7 @@ function useChatMarkdownState({...@@ -2571,12 +2561,7 @@ function useChatMarkdownState({
2571 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],2561 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
2572 );2562 );
2573 const fileLinkChip = useCallback(2563 const fileLinkChip = useCallback(
2574 (2564 (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => {
2575 fileLinkMeta: MarkdownFileLinkMeta,
2576 copyMarkdown: string,
2577 className?: string,
2578 mediaSource?: string,
2579 ) => {
2580 const parentSuffix = fileLinkParentSuffixByPath.get(2565 const parentSuffix = fileLinkParentSuffixByPath.get(
2581 fileLinkMeta.filePath.replaceAll("\\", "/"),2566 fileLinkMeta.filePath.replaceAll("\\", "/"),
2582 );2567 );
...@@ -2633,7 +2618,6 @@ function useChatMarkdownState({...@@ -2633,7 +2618,6 @@ function useChatMarkdownState({
2633 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)2618 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
2634 : undefined2619 : undefined
2635 }2620 }
2636 className={className}
2637 />2621 />
2638 );2622 );
2639 },2623 },
...@@ -3068,7 +3052,6 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3068,7 +3052,6 @@ const CHAT_MARKDOWN_COMPONENTS = {
3068 return fileLinkChip(3052 return fileLinkChip(
3069 fileLinkMeta,3053 fileLinkMeta,
3070 `[${fileLinkMeta.basename}](${normalizedHref})`,3054 `[${fileLinkMeta.basename}](${normalizedHref})`,
3071 props.className,
3072 normalizedHref,3055 normalizedHref,
3073 );3056 );
3074 },3057 },
...@@ -3085,7 +3068,6 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3085,7 +3068,6 @@ const CHAT_MARKDOWN_COMPONENTS = {
3085 return fileLinkChip(3068 return fileLinkChip(
3086 fileLinkMeta,3069 fileLinkMeta,
3087 `\`${codeText}\``,3070 `\`${codeText}\``,
3088 undefined,
3089 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),3071 inlineCodeFilePathCandidate(codeText) ?? codeText.trim(),
3090 );3072 );
3091 }3073 }
apps/web/src/components/ComposerPromptEditorTiptap.tsx+21-27
...@@ -58,18 +58,13 @@ import {...@@ -58,18 +58,13 @@ import {
58import { collectInlineContextIds } from "~/lib/composerContextReferences";58import { collectInlineContextIds } from "~/lib/composerContextReferences";
59import { cn, isMacPlatform } from "~/lib/utils";59import { cn, isMacPlatform } from "~/lib/utils";
60import { basenameOfPath } from "~/pierre-icons";60import { basenameOfPath } from "~/pierre-icons";
61import {61import { FileTagChipContent } from "./chat/FileTagChip";
62 COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,62import { SkillChipIcon } from "./chat/SkillInlineText";
63 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
64 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
65 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
66 SKILL_CHIP_ICON_SVG,
67} from "./composerInlineChip";
68import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
69import { AssistantCitationChip } from "./chat/AssistantCitationChip";63import { AssistantCitationChip } from "./chat/AssistantCitationChip";
70import { getTimelinePageScrollKey } from "./chat/pageScrollController";64import { getTimelinePageScrollKey } from "./chat/pageScrollController";
71import { ContextChipPopover } from "./contextChipParts";65import { ContextChipPopover } from "./contextChipParts";
72import { Button } from "./ui/button";66import { Button } from "./ui/button";
67import { ContextChip } from "./ContextChip";
73import {68import {
74 ComposerContextActionsContext,69 ComposerContextActionsContext,
75 ComposerContextReferenceChip,70 ComposerContextReferenceChip,
...@@ -190,6 +185,13 @@ function resolvedThemeFromDocument(): "light" | "dark" {...@@ -190,6 +185,13 @@ function resolvedThemeFromDocument(): "light" | "dark" {
190185
191// ── Inline atom nodes (chips) ─────────────────────────────────────────────186// ── Inline atom nodes (chips) ─────────────────────────────────────────────
192187
188/**
189 * Wraps an inline chip node view: keeps the caret and text selection out of the chip and
190 * paints the editor's node selection over it.
191 */
192const CHIP_NODE_SELECTION_CLASS_NAME =
193 "relative inline-flex select-none items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']";
194
193const ComposerMentionExtension = Node.create({195const ComposerMentionExtension = Node.create({
194 name: "composer-mention",196 name: "composer-mention",
195 group: "inline",197 group: "inline",
...@@ -217,11 +219,11 @@ function ComposerMentionNodeView({ node }: NodeViewProps) {...@@ -217,11 +219,11 @@ function ComposerMentionNodeView({ node }: NodeViewProps) {
217 const actions = use(ComposerContextActionsContext);219 const actions = use(ComposerContextActionsContext);
218 const path = (node.attrs.path as string) ?? "";220 const path = (node.attrs.path as string) ?? "";
219 const chip = (221 const chip = (
220 <Button222 <ContextChip
221 variant="chip"223 kind="mention"
224 render={<button type="button" />}
222 onClick={() => actions.openMention(path)}225 onClick={() => actions.openMention(path)}
223 aria-label={`Preview ${path}`}226 aria-label={`Preview ${path}`}
224 className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`}
225 contentEditable={false}227 contentEditable={false}
226 spellCheck={false}228 spellCheck={false}
227 data-composer-mention-chip="true"229 data-composer-mention-chip="true"
...@@ -231,10 +233,10 @@ function ComposerMentionNodeView({ node }: NodeViewProps) {...@@ -231,10 +233,10 @@ function ComposerMentionNodeView({ node }: NodeViewProps) {
231 label={basenameOfPath(path)}233 label={basenameOfPath(path)}
232 theme={resolvedThemeFromDocument()}234 theme={resolvedThemeFromDocument()}
233 />235 />
234 </Button>236 </ContextChip>
235 );237 );
236 return (238 return (
237 <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>239 <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
238 <Tooltip>240 <Tooltip>
239 <TooltipTrigger render={chip} />241 <TooltipTrigger render={chip} />
240 <TooltipPopup side="top">{path}</TooltipPopup>242 <TooltipPopup side="top">{path}</TooltipPopup>
...@@ -275,20 +277,12 @@ function ComposerSkillNodeView({ node }: NodeViewProps) {...@@ -275,20 +277,12 @@ function ComposerSkillNodeView({ node }: NodeViewProps) {
275 const skillDescription = (node.attrs.skillDescription as string | null) ?? null;277 const skillDescription = (node.attrs.skillDescription as string | null) ?? null;
276 const skill = skills.find((candidate) => candidate.name === skillName);278 const skill = skills.find((candidate) => candidate.name === skillName);
277 return (279 return (
278 <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>280 <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
279 <ContextChipPopover281 <ContextChipPopover
282 kind="skill"
283 icon={<SkillChipIcon />}
284 label={skillLabel}
280 accessibleLabel={`Skill ${skillLabel}`}285 accessibleLabel={`Skill ${skillLabel}`}
281 triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}
282 chip={
283 <>
284 <span
285 aria-hidden="true"
286 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
287 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
288 />
289 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{skillLabel}</span>
290 </>
291 }
292 >286 >
293 <div className="space-y-3 p-2 text-sm">287 <div className="space-y-3 p-2 text-sm">
294 <p className="font-medium">{skillLabel}</p>288 <p className="font-medium">{skillLabel}</p>
...@@ -380,7 +374,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {...@@ -380,7 +374,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
380 return (374 return (
381 <NodeViewWrapper375 <NodeViewWrapper
382 as="span"376 as="span"
383 className="inline-flex min-w-0 max-w-full"377 className="inline-flex min-w-0 max-w-full select-none"
384 contentEditable={false}378 contentEditable={false}
385 spellCheck={false}379 spellCheck={false}
386 data-composer-citation-chip="true"380 data-composer-citation-chip="true"
...@@ -435,7 +429,7 @@ const ComposerContextReferenceExtension = Node.create({...@@ -435,7 +429,7 @@ const ComposerContextReferenceExtension = Node.create({
435429
436function ComposerContextReferenceNodeView({ node }: NodeViewProps) {430function ComposerContextReferenceNodeView({ node }: NodeViewProps) {
437 return (431 return (
438 <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}>432 <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
439 <ComposerContextReferenceChip433 <ComposerContextReferenceChip
440 kind={(node.attrs.kind as string) ?? ""}434 kind={(node.attrs.kind as string) ?? ""}
441 contextId={(node.attrs.contextId as string) ?? ""}435 contextId={(node.attrs.contextId as string) ?? ""}
apps/web/src/components/ContextChip.tsx created+131
...@@ -0,0 +1,131 @@
1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { cva, type VariantProps } from "class-variance-authority";
4import type * as React from "react";
5
6import { cn } from "~/lib/utils";
7
8/**
9 * The inline pill for one piece of chat context. It lives with the chat components rather than
10 * in components/ui because it is one feature's look, not a generic primitive.
11 *
12 * An inline pill for one piece of context (a file, mention, terminal excerpt, pull request,
13 * skill…) that sits in running text, both in the composer and in sent messages.
14 *
15 * Metrics are in em so the chip scales with the text around it (the composer honors the
16 * prompt font-size preference). Each kind keeps one restrained color identity: every accent
17 * shares a lightness so no kind reads heavier than another, and only hue carries identity.
18 * A consumer may override `--context-chip-accent` through `style` for a color that comes
19 * from content, such as an image's average color.
20 *
21 * Renders a span by default. Render it as a button, link or popover/tooltip trigger to make it
22 * interactive; the hover tint follows from that, not from a prop. Anything focusable (including a
23 * span with tabIndex for a tooltip) gets the focus outline.
24 */
25const contextChipVariants = cva(
26 "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--contrast-foreground)] disabled:cursor-default",
27 {
28 defaultVariants: { kind: "neutral" },
29 variants: {
30 kind: {
31 neutral: "border-border/70 bg-accent/40 text-foreground",
32 image: "[--context-chip-accent:oklch(0.62_0.16_16)]",
33 video: "[--context-chip-accent:oklch(0.62_0.16_48)]",
34 file: "[--context-chip-accent:oklch(0.62_0.136_237)]",
35 mention: "[--context-chip-accent:oklch(0.62_0.11_215)]",
36 terminal: "[--context-chip-accent:oklch(0.62_0.134_163)]",
37 element: "[--context-chip-accent:oklch(0.62_0.134_70)]",
38 "preview-annotation": "[--context-chip-accent:oklch(0.62_0.134_70)]",
39 "review-comment": "[--context-chip-accent:oklch(0.62_0.16_292)]",
40 "pull-request": "[--context-chip-accent:oklch(0.62_0.16_277)]",
41 "pr-open": "[--context-chip-accent:oklch(0.62_0.134_163)]",
42 "pr-draft": "[--context-chip-accent:oklch(0.62_0.02_259)]",
43 "pr-merged": "[--context-chip-accent:oklch(0.62_0.16_292)]",
44 "pr-closed": "[--context-chip-accent:oklch(0.62_0.16_16)]",
45 skill: "[--context-chip-accent:oklch(0.62_0.16_322)]",
46 citation: "[--context-chip-accent:oklch(0.62_0.16_259)]",
47 },
48 // Colors live in compoundVariants below so they come after the kind colors.
49 state: {
50 unresolved: "border-dashed",
51 invalid: "",
52 },
53 },
54 compoundVariants: [
55 {
56 kind: [
57 "image",
58 "video",
59 "file",
60 "mention",
61 "terminal",
62 "element",
63 "preview-annotation",
64 "review-comment",
65 "pull-request",
66 "pr-open",
67 "pr-draft",
68 "pr-merged",
69 "pr-closed",
70 "skill",
71 "citation",
72 ],
73 className:
74 "border-[color-mix(in_oklab,var(--context-chip-accent)_34%,var(--contrast-border))] bg-[color-mix(in_oklab,var(--context-chip-accent)_11%,transparent)] text-[color-mix(in_oklab,var(--context-chip-accent)_22%,var(--contrast-foreground))] [button:enabled&,a&]:hover:border-[color-mix(in_oklab,var(--context-chip-accent)_48%,var(--contrast-border))] [button:enabled&,a&]:hover:bg-[color-mix(in_oklab,var(--context-chip-accent)_17%,transparent)]",
75 },
76 // State colors win over any kind.
77 { state: "unresolved", className: "text-foreground" },
78 { state: "invalid", className: "border-destructive/35 bg-destructive/8 text-destructive" },
79 ],
80 },
81);
82
83type ContextChipKind = NonNullable<VariantProps<typeof contextChipVariants>["kind"]>;
84
85interface ContextChipProps extends useRender.ComponentProps<"span"> {
86 kind?: ContextChipKind;
87 state?: "unresolved" | "invalid";
88}
89
90function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) {
91 const defaultProps = {
92 className: cn(contextChipVariants({ kind, state }), className),
93 "data-slot": "context-chip",
94 "data-state": state,
95 };
96 return useRender({
97 defaultTagName: "span",
98 props: mergeProps<"span">(defaultProps, props),
99 render,
100 });
101}
102
103/** The chip's text. Truncates to the chip's width. */
104function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) {
105 return (
106 <span
107 className={cn("block min-w-0 self-center truncate leading-tight", className)}
108 data-slot="context-chip-label"
109 {...props}
110 />
111 );
112}
113
114/** An icon action inside a chip, such as editing a citation's comment. Tints with the chip's kind. */
115function ContextChipAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
116 const defaultProps = {
117 className: cn(
118 "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-[color-mix(in_oklab,var(--context-chip-accent,var(--color-foreground))_17%,transparent)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]",
119 className,
120 ),
121 "data-slot": "context-chip-action",
122 type: render ? undefined : ("button" as const),
123 };
124 return useRender({
125 defaultTagName: "button",
126 props: mergeProps<"button">(defaultProps, props),
127 render,
128 });
129}
130
131export { ContextChip, ContextChipAction, ContextChipLabel, type ContextChipKind };
apps/web/src/components/chat/AssistantCitationChip.tsx+10-26
...@@ -13,19 +13,11 @@ import {...@@ -13,19 +13,11 @@ import {
13 findAssistantCitationSourceAnchor,13 findAssistantCitationSourceAnchor,
14 type AssistantCitationSourceAnchor,14 type AssistantCitationSourceAnchor,
15} from "~/lib/assistantTextSelection";15} from "~/lib/assistantTextSelection";
16import { cn } from "~/lib/utils";
17import {16import {
18 assistantCitationHash,17 assistantCitationHash,
19 assistantCitationNavigation,18 assistantCitationNavigation,
20} from "../../lib/assistantCitationNavigation";19} from "../../lib/assistantCitationNavigation";
21import {20import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";
22 CHAT_INLINE_CHIP_CLASS_NAME,
23 COMPOSER_INLINE_CHIP_CLASS_NAME,
24 COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,
25 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
26 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
27 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
28} from "../composerInlineChip";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";22import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
31import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";23import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
...@@ -33,11 +25,6 @@ import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCom...@@ -33,11 +25,6 @@ import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCom
33import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";25import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
34import { composerFloatingLayerProps } from "./composerEventScope";26import { composerFloatingLayerProps } from "./composerEventScope";
3527
36const CITATION_ACTION_BUTTON_CLASS_NAME = cn(
37 COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,
38 "text-current hover:bg-[color-mix(in_oklab,var(--context-chip-accent)_17%,transparent)] hover:text-current",
39);
40
41export function AssistantCitationChip({28export function AssistantCitationChip({
42 citation,29 citation,
43 composer = false,30 composer = false,
...@@ -126,8 +113,8 @@ export function AssistantCitationChip({...@@ -126,8 +113,8 @@ export function AssistantCitationChip({
126 className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline focus-visible:outline-2 focus-visible:outline-[var(--contrast-foreground)]"113 className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline focus-visible:outline-2 focus-visible:outline-[var(--contrast-foreground)]"
127 aria-label={`View cited assistant text: ${label}`}114 aria-label={`View cited assistant text: ${label}`}
128 >115 >
129 <QuoteIcon aria-hidden="true" className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} />116 <QuoteIcon aria-hidden="true" />
130 <span className={cn(COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, "max-w-[16em]")}>{label}</span>117 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>
131 </Link>118 </Link>
132 );119 );
133 const chatSourceLink = (120 const chatSourceLink = (
...@@ -136,16 +123,13 @@ export function AssistantCitationChip({...@@ -136,16 +123,13 @@ export function AssistantCitationChip({
136 className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline hover:bg-[color-mix(in_oklab,var(--context-chip-accent)_17%,transparent)] focus-visible:outline-2 focus-visible:outline-[var(--contrast-foreground)]"123 className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline hover:bg-[color-mix(in_oklab,var(--context-chip-accent)_17%,transparent)] focus-visible:outline-2 focus-visible:outline-[var(--contrast-foreground)]"
137 aria-label={`View cited assistant text: ${label}`}124 aria-label={`View cited assistant text: ${label}`}
138 >125 >
139 <QuoteIcon aria-hidden="true" className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} />126 <QuoteIcon aria-hidden="true" />
140 <span className={cn(COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, "max-w-[16em]")}>{label}</span>127 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>
141 </Link>128 </Link>
142 );129 );
143 return (130 return (
144 <span131 <ContextChip
145 className={cn(132 kind="citation"
146 composer ? COMPOSER_INLINE_CHIP_CLASS_NAME : CHAT_INLINE_CHIP_CLASS_NAME,
147 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.citation,
148 )}
149 contentEditable={false}133 contentEditable={false}
150 data-assistant-citation-chip="true"134 data-assistant-citation-chip="true"
151 data-markdown-copy={serializeAssistantCitation(citation)}135 data-markdown-copy={serializeAssistantCitation(citation)}
...@@ -171,9 +155,9 @@ export function AssistantCitationChip({...@@ -171,9 +155,9 @@ export function AssistantCitationChip({
171 >155 >
172 <PopoverTrigger156 <PopoverTrigger
173 aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}157 aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}
174 className={CITATION_ACTION_BUTTON_CLASS_NAME}158 render={<ContextChipAction />}
175 >159 >
176 <PencilIcon aria-hidden="true" className="size-[0.85em]" />160 <PencilIcon aria-hidden="true" />
177 </PopoverTrigger>161 </PopoverTrigger>
178 {commentEditor.open ? (162 {commentEditor.open ? (
179 <PopoverPopup163 <PopoverPopup
...@@ -223,6 +207,6 @@ export function AssistantCitationChip({...@@ -223,6 +207,6 @@ export function AssistantCitationChip({
223 ) : null}207 ) : null}
224 </Popover>208 </Popover>
225 ) : null}209 ) : null}
226 </span>210 </ContextChip>
227 );211 );
228}212}
apps/web/src/components/chat/FileTagChip.tsx+3-22
...@@ -1,22 +1,12 @@...@@ -1,22 +1,12 @@
1import { inferEntryKindFromPath } from "../../pierre-icons";1import { inferEntryKindFromPath } from "../../pierre-icons";
2import {2import { ContextChipLabel } from "../ContextChip";
3 CHAT_INLINE_CHIP_CLASS_NAME,
4 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
5 COMPOSER_INLINE_CHIP_CLASS_NAME,
6 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
8 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
9} from "../composerInlineChip";
10import { PierreEntryIcon } from "./PierreEntryIcon";3import { PierreEntryIcon } from "./PierreEntryIcon";
114
12export const FILE_TAG_CHIP_CLASS_NAME = `${COMPOSER_INLINE_CHIP_CLASS_NAME} ${CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.mention}`;5/** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */
13export const CHAT_FILE_TAG_CHIP_CLASS_NAME = `${CHAT_INLINE_CHIP_CLASS_NAME} ${CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.mention}`;
14
15export function FileTagChipContent(props: {6export function FileTagChipContent(props: {
16 path: string;7 path: string;
17 label: string;8 label: string;
18 theme: "light" | "dark";9 theme: "light" | "dark";
19 selectable?: boolean;
20}) {10}) {
21 return (11 return (
22 <>12 <>
...@@ -24,17 +14,8 @@ export function FileTagChipContent(props: {...@@ -24,17 +14,8 @@ export function FileTagChipContent(props: {
24 pathValue={props.path}14 pathValue={props.path}
25 kind={inferEntryKindFromPath(props.path)}15 kind={inferEntryKindFromPath(props.path)}
26 theme={props.theme}16 theme={props.theme}
27 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
28 />17 />
29 <span18 <ContextChipLabel>{props.label}</ContextChipLabel>
30 className={
31 props.selectable
32 ? CHAT_INLINE_CHIP_LABEL_CLASS_NAME
33 : COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME
34 }
35 >
36 {props.label}
37 </span>
38 </>19 </>
39 );20 );
40}21}
apps/web/src/components/chat/MessagesTimeline.tsx+27-117
...@@ -214,6 +214,7 @@ import {...@@ -214,6 +214,7 @@ import {
214 ContextChipShell,214 ContextChipShell,
215 FileChip,215 FileChip,
216 ImageChipButton,216 ImageChipButton,
217 PULL_REQUEST_CHIP_KINDS,
217 PullRequestChip,218 PullRequestChip,
218 UnresolvedChip,219 UnresolvedChip,
219} from "../contextChipParts";220} from "../contextChipParts";
...@@ -236,15 +237,7 @@ import {...@@ -236,15 +237,7 @@ import {
236 encodeComposerContextFragment,237 encodeComposerContextFragment,
237} from "@t3tools/shared/composerContextClipboard";238} from "@t3tools/shared/composerContextClipboard";
238import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";239import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
239import {240import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
240 CHAT_INLINE_CHIP_CLASS_NAME,
241 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
242 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
243 SKILL_CHIP_ICON_SVG,
244 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES,
245 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
246 PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES,
247} from "../composerInlineChip";
248import { createContextPresentationRegistry } from "../contextPresentationRegistry";241import { createContextPresentationRegistry } from "../contextPresentationRegistry";
249import { useOpenPrLink } from "~/lib/openPullRequestLink";242import { useOpenPrLink } from "~/lib/openPullRequestLink";
250import type { ChatMarkdownContextReference } from "../ChatMarkdown";243import type { ChatMarkdownContextReference } from "../ChatMarkdown";
...@@ -254,7 +247,7 @@ import { useUiStateStore } from "~/uiStateStore";...@@ -254,7 +247,7 @@ import { useUiStateStore } from "~/uiStateStore";
254import { type TimestampFormat } from "@t3tools/contracts/settings";247import { type TimestampFormat } from "@t3tools/contracts/settings";
255import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";248import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
256249
257import { SkillInlineText } from "./SkillInlineText";250import { SkillChipIcon, SkillInlineText } from "./SkillInlineText";
258import { deriveAgentSpawnSummary } from "./agentSpawnSummary";251import { deriveAgentSpawnSummary } from "./agentSpawnSummary";
259import { formatWorkspaceRelativePath } from "../../filePathDisplay";252import { formatWorkspaceRelativePath } from "../../filePathDisplay";
260import {253import {
...@@ -2559,8 +2552,9 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap...@@ -2559,8 +2552,9 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap
2559 <PopoverTrigger2552 <PopoverTrigger
2560 render={2553 render={
2561 <Button2554 <Button
2562 variant="chip"2555 variant="ghost-muted"
2563 className="ml-auto inline-flex h-5 min-w-0 shrink-0 items-center gap-1 rounded-full border border-border/70 px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"2556 size="micro"
2557 className="ml-auto min-w-0 shrink-0"
2564 aria-label={`${scriptName} is still running. Show setup progress.`}2558 aria-label={`${scriptName} is still running. Show setup progress.`}
2565 />2559 />
2566 }2560 }
...@@ -3453,14 +3447,10 @@ function UserMessageMentionChip(props: {...@@ -3453,14 +3447,10 @@ function UserMessageMentionChip(props: {
3453 <Tooltip>3447 <Tooltip>
3454 <TooltipTrigger3448 <TooltipTrigger
3455 render={3449 render={
3456 <button3450 <ContextChip
3457 type="button"3451 kind="mention"
3452 render={<button type="button" />}
3458 aria-label={`Preview ${props.record.path}`}3453 aria-label={`Preview ${props.record.path}`}
3459 className={cn(
3460 CHAT_INLINE_CHIP_CLASS_NAME,
3461 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.mention,
3462 "cursor-pointer focus-visible:outline-2",
3463 )}
3464 data-markdown-copy={props.copyMarkdown}3454 data-markdown-copy={props.copyMarkdown}
3465 onClick={() => {3455 onClick={() => {
3466 if (ctx.threadRef)3456 if (ctx.threadRef)
...@@ -3471,10 +3461,9 @@ function UserMessageMentionChip(props: {...@@ -3471,10 +3461,9 @@ function UserMessageMentionChip(props: {
3471 pathValue={props.record.path}3461 pathValue={props.record.path}
3472 kind={inferEntryKindFromPath(props.record.path)}3462 kind={inferEntryKindFromPath(props.record.path)}
3473 theme={ctx.resolvedTheme}3463 theme={ctx.resolvedTheme}
3474 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
3475 />3464 />
3476 <span className={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}>{props.record.label}</span>3465 <ContextChipLabel>{props.record.label}</ContextChipLabel>
3477 </button>3466 </ContextChip>
3478 }3467 }
3479 />3468 />
3480 <TooltipPopup>{props.record.path}</TooltipPopup>3469 <TooltipPopup>{props.record.path}</TooltipPopup>
...@@ -3488,21 +3477,16 @@ function UserMessageContextChip(props: {...@@ -3488,21 +3477,16 @@ function UserMessageContextChip(props: {
3488 kindLabel?: string;3477 kindLabel?: string;
3489 copyMarkdown: string;3478 copyMarkdown: string;
3490 tooltip?: string;3479 tooltip?: string;
3491 toneClassName?: string;3480 kind: ContextChipKind;
3492 interactive?: boolean;
3493 unresolved?: boolean;
3494}) {3481}) {
3495 return (3482 return (
3496 <ContextChipShell3483 <ContextChipShell
3484 kind={props.kind}
3497 icon={props.icon}3485 icon={props.icon}
3498 label={props.label}3486 label={props.label}
3499 className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
3500 labelClassName={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}
3501 aria-label={props.kindLabel ? `${props.kindLabel}, ${props.label}` : undefined}3487 aria-label={props.kindLabel ? `${props.kindLabel}, ${props.label}` : undefined}
3502 data-markdown-copy={props.copyMarkdown}3488 data-markdown-copy={props.copyMarkdown}
3503 tooltip={props.tooltip}3489 tooltip={props.tooltip}
3504 interactive={props.interactive === true}
3505 unresolved={props.unresolved === true}
3506 />3490 />
3507 );3491 );
3508}3492}
...@@ -3510,7 +3494,7 @@ function UserMessageContextChip(props: {...@@ -3510,7 +3494,7 @@ function UserMessageContextChip(props: {
3510function UserMessagePullRequestContextChip(props: {3494function UserMessagePullRequestContextChip(props: {
3511 record: Extract<KnownComposerContextRecord, { kind: "review-comment" }>;3495 record: Extract<KnownComposerContextRecord, { kind: "review-comment" }>;
3512 copyMarkdown: string;3496 copyMarkdown: string;
3513 toneClassName: string;3497 kind: ContextChipKind;
3514}) {3498}) {
3515 const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);3499 const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);
3516 const metadata = props.record.pullRequest;3500 const metadata = props.record.pullRequest;
...@@ -3521,8 +3505,7 @@ function UserMessagePullRequestContextChip(props: {...@@ -3521,8 +3505,7 @@ function UserMessagePullRequestContextChip(props: {
3521 environmentId={activeThreadEnvironmentId}3505 environmentId={activeThreadEnvironmentId}
3522 label={reviewCommentContextLabel(props.record)}3506 label={reviewCommentContextLabel(props.record)}
3523 kindLabel={pullRequestContextKindLabel(props.record)}3507 kindLabel={pullRequestContextKindLabel(props.record)}
3524 className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)}3508 kind={props.kind}
3525 labelClassName={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}
3526 copyMarkdown={props.copyMarkdown}3509 copyMarkdown={props.copyMarkdown}
3527 onOpen={openPullRequest}3510 onOpen={openPullRequest}
3528 />3511 />
...@@ -3675,11 +3658,8 @@ function UnavailableUserMessageContextChip(props: UserMessageContextRenderContex...@@ -3675,11 +3658,8 @@ function UnavailableUserMessageContextChip(props: UserMessageContextRenderContex
3675 return (3658 return (
3676 <UnresolvedChip3659 <UnresolvedChip
3677 label={props.reference.label}3660 label={props.reference.label}
3678 className={CHAT_INLINE_CHIP_CLASS_NAME}
3679 labelClassName={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}
3680 copyMarkdown={props.copyMarkdown}3661 copyMarkdown={props.copyMarkdown}
3681 tooltip="This context is no longer available."3662 tooltip="This context is no longer available."
3682 tooltipClassName="max-w-96 whitespace-pre-wrap leading-tight"
3683 />3663 />
3684 );3664 );
3685}3665}
...@@ -3707,18 +3687,12 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3707,18 +3687,12 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3707 render: (record, context) =>3687 render: (record, context) =>
3708 record.kind === "skill" ? (3688 record.kind === "skill" ? (
3709 <UserMessageContextChip3689 <UserMessageContextChip
3710 icon={3690 icon={<SkillChipIcon />}
3711 <span
3712 aria-hidden="true"
3713 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
3714 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
3715 />
3716 }
3717 label={record.label || record.name}3691 label={record.label || record.name}
3718 kindLabel="Skill"3692 kindLabel="Skill"
3719 tooltip={`$${record.name}`}3693 tooltip={`$${record.name}`}
3720 copyMarkdown={context.copyMarkdown}3694 copyMarkdown={context.copyMarkdown}
3721 toneClassName={CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.skill}3695 kind="skill"
3722 />3696 />
3723 ) : (3697 ) : (
3724 <UnavailableUserMessageContextChip {...context} />3698 <UnavailableUserMessageContextChip {...context} />
...@@ -3743,8 +3717,6 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3743,8 +3717,6 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3743 <ImageChipButton3717 <ImageChipButton
3744 name={record.name}3718 name={record.name}
3745 previewUrl={attachment.previewUrl}3719 previewUrl={attachment.previewUrl}
3746 className={CHAT_INLINE_CHIP_CLASS_NAME}
3747 labelClassName={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}
3748 size={formatAttachmentSize(record.sizeBytes)}3720 size={formatAttachmentSize(record.sizeBytes)}
3749 data-markdown-copy={context.copyMarkdown}3721 data-markdown-copy={context.copyMarkdown}
3750 onClick={() => context.onExpandImage(attachment)}3722 onClick={() => context.onExpandImage(attachment)}
...@@ -3777,8 +3749,6 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3777,8 +3749,6 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3777 size={size}3749 size={size}
3778 isVideo={isVideo}3750 isVideo={isVideo}
3779 theme={context.resolvedTheme}3751 theme={context.resolvedTheme}
3780 className={CHAT_INLINE_CHIP_CLASS_NAME}
3781 labelClassName={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}
3782 disabled={disabled}3752 disabled={disabled}
3783 accessibleLabel={`${isVideo ? "Video" : "File"} attachment, ${record.name}, ${size}`}3753 accessibleLabel={`${isVideo ? "Video" : "File"} attachment, ${record.name}, ${size}`}
3784 copyMarkdown={context.copyMarkdown}3754 copyMarkdown={context.copyMarkdown}
...@@ -3797,7 +3767,6 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3797,7 +3767,6 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3797 record.kind === "terminal" ? (3767 record.kind === "terminal" ? (
3798 <span data-markdown-copy={context.copyMarkdown}>3768 <span data-markdown-copy={context.copyMarkdown}>
3799 <TerminalContextInlineChip3769 <TerminalContextInlineChip
3800 surface="transcript"
3801 label={record.label}3770 label={record.label}
3802 terminalLabel={record.terminalLabel}3771 terminalLabel={record.terminalLabel}
3803 lineStart={record.lineStart}3772 lineStart={record.lineStart}
...@@ -3818,24 +3787,9 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3818,24 +3787,9 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3818 <UserMessageContextPopover3787 <UserMessageContextPopover
3819 copyMarkdown={context.copyMarkdown}3788 copyMarkdown={context.copyMarkdown}
3820 accessibleLabel={`Browser element, ${record.label}`}3789 accessibleLabel={`Browser element, ${record.label}`}
3821 chip={3790 kind="element"
3822 <UserMessageContextChip3791 icon={<MousePointerClickIcon />}
3823 icon={3792 label={record.label}
3824 <MousePointerClickIcon
3825 className={cn(
3826 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
3827 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES.element,
3828 "size-3.5",
3829 )}
3830 />
3831 }
3832 label={record.label}
3833 kindLabel="Browser element"
3834 copyMarkdown={context.copyMarkdown}
3835 interactive
3836 toneClassName={CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.element}
3837 />
3838 }
3839 >3793 >
3840 <UserMessageElementDetails record={record} />3794 <UserMessageElementDetails record={record} />
3841 </UserMessageContextPopover>3795 </UserMessageContextPopover>
...@@ -3859,7 +3813,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3859,7 +3813,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3859 <UserMessagePullRequestContextChip3813 <UserMessagePullRequestContextChip
3860 record={record}3814 record={record}
3861 copyMarkdown={context.copyMarkdown}3815 copyMarkdown={context.copyMarkdown}
3862 toneClassName={PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES[pullRequestState]}3816 kind={PULL_REQUEST_CHIP_KINDS[pullRequestState]}
3863 />3817 />
3864 );3818 );
3865 }3819 }
...@@ -3867,38 +3821,9 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3867,38 +3821,9 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3867 <UserMessageContextPopover3821 <UserMessageContextPopover
3868 copyMarkdown={context.copyMarkdown}3822 copyMarkdown={context.copyMarkdown}
3869 accessibleLabel={`${kindLabel}, ${label}${record.pullRequest ? `, ${record.pullRequest.title}` : ""}`}3823 accessibleLabel={`${kindLabel}, ${label}${record.pullRequest ? `, ${record.pullRequest.title}` : ""}`}
3870 chip={3824 kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
3871 <UserMessageContextChip3825 icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
3872 icon={3826 label={label}
3873 isPullRequest ? (
3874 <PullRequestGlyph.pullRequest
3875 className={cn(
3876 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
3877 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],
3878 "size-3.5",
3879 )}
3880 />
3881 ) : (
3882 <MessageCircleIcon
3883 className={cn(
3884 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
3885 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["review-comment"],
3886 "size-3.5",
3887 )}
3888 />
3889 )
3890 }
3891 label={label}
3892 kindLabel={kindLabel}
3893 copyMarkdown={context.copyMarkdown}
3894 interactive
3895 toneClassName={
3896 isPullRequest
3897 ? PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES[pullRequestState]
3898 : CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES["review-comment"]
3899 }
3900 />
3901 }
3902 >3827 >
3903 <UserMessageReviewCommentCard3828 <UserMessageReviewCommentCard
3904 comment={{3829 comment={{
...@@ -3929,24 +3854,9 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3929,24 +3854,9 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3929 <UserMessageContextPopover3854 <UserMessageContextPopover
3930 copyMarkdown={context.copyMarkdown}3855 copyMarkdown={context.copyMarkdown}
3931 accessibleLabel={`Preview annotation, ${record.label}`}3856 accessibleLabel={`Preview annotation, ${record.label}`}
3932 chip={3857 kind="preview-annotation"
3933 <UserMessageContextChip3858 icon={<MousePointerClickIcon />}
3934 icon={3859 label={record.label}
3935 <MousePointerClickIcon
3936 className={cn(
3937 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
3938 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["preview-annotation"],
3939 "size-3.5",
3940 )}
3941 />
3942 }
3943 label={record.label}
3944 kindLabel="Preview annotation"
3945 copyMarkdown={context.copyMarkdown}
3946 interactive
3947 toneClassName={CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES["preview-annotation"]}
3948 />
3949 }
3950 >3860 >
3951 <UserMessagePreviewAnnotationDetails record={record} image={context.annotationImage} />3861 <UserMessagePreviewAnnotationDetails record={record} image={context.annotationImage} />
3952 </UserMessageContextPopover>3862 </UserMessageContextPopover>
apps/web/src/components/chat/SkillInlineText.tsx+17-20
...@@ -2,14 +2,8 @@ import { Children, cloneElement, isValidElement, type ReactNode } from "react";...@@ -2,14 +2,8 @@ import { Children, cloneElement, isValidElement, type ReactNode } from "react";
2import type { ServerProviderSkill } from "@t3tools/contracts";2import type { ServerProviderSkill } from "@t3tools/contracts";
3import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";3import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
44
5import {5import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip";
6 CHAT_INLINE_CHIP_CLASS_NAME,6import { ContextChip, ContextChipLabel } from "../ContextChip";
7 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
8 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
9 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
10 SKILL_CHIP_ICON_SVG,
11} from "../composerInlineChip";
12import { cn } from "~/lib/utils";
137
14const SKILL_TOKEN_REGEX =8const SKILL_TOKEN_REGEX =
15 /(^|\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;9 /(^|\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;
...@@ -76,17 +70,20 @@ export function renderSkillInlineMarkdownChildren(...@@ -76,17 +70,20 @@ export function renderSkillInlineMarkdownChildren(
7670
77function SkillChip(props: { skill: InlineSkill; rawText: string }) {71function SkillChip(props: { skill: InlineSkill; rawText: string }) {
78 return (72 return (
79 <span className="inline-flex align-middle leading-none" data-markdown-copy={props.rawText}>73 <ContextChip kind="skill" data-markdown-copy={props.rawText}>
80 <span className={cn(CHAT_INLINE_CHIP_CLASS_NAME, CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.skill)}>74 <SkillChipIcon />
81 <span75 <ContextChipLabel>{formatProviderSkillDisplayName(props.skill)}</ContextChipLabel>
82 aria-hidden="true"76 </ContextChip>
83 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}77 );
84 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}78}
85 />79
86 <span className={CHAT_INLINE_CHIP_LABEL_CLASS_NAME}>80/** The skill glyph; the surrounding chip sizes its svg. */
87 {formatProviderSkillDisplayName(props.skill)}81export function SkillChipIcon() {
88 </span>82 return (
89 </span>83 <span
90 </span>84 aria-hidden="true"
85 className="contents"
86 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
87 />
91 );88 );
92}89}
apps/web/src/components/chat/TerminalContextInlineChip.tsx+6-44
...@@ -1,16 +1,6 @@...@@ -1,16 +1,6 @@
1import { TerminalIcon } from "lucide-react";1import { TerminalIcon } from "lucide-react";
22
3import { cn } from "~/lib/utils";
4import type { ContextPresentationCapability } from "../contextPresentationRegistry";3import type { ContextPresentationCapability } from "../contextPresentationRegistry";
5import {
6 CHAT_INLINE_CHIP_CLASS_NAME,
7 COMPOSER_INLINE_CHIP_CLASS_NAME,
8 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
9 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
10 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES,
11 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
12 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
13} from "../composerInlineChip";
14import { ContextChipPopover, ContextChipShell } from "../contextChipParts";4import { ContextChipPopover, ContextChipShell } from "../contextChipParts";
155
16interface TerminalContextInlineChipProps {6interface TerminalContextInlineChipProps {
...@@ -21,41 +11,18 @@ interface TerminalContextInlineChipProps {...@@ -21,41 +11,18 @@ interface TerminalContextInlineChipProps {
21 text: string;11 text: string;
22 detailsMode: ContextPresentationCapability["details"];12 detailsMode: ContextPresentationCapability["details"];
23 expired?: boolean;13 expired?: boolean;
24 surface?: "composer" | "transcript";
25}14}
2615
27export function TerminalContextInlineChip(props: TerminalContextInlineChipProps) {16export function TerminalContextInlineChip(props: TerminalContextInlineChipProps) {
28 const { label, terminalLabel, lineStart, lineEnd, text, detailsMode, expired = false } = props;17 const { label, terminalLabel, lineStart, lineEnd, text, detailsMode, expired = false } = props;
29 const chipClassName =
30 props.surface === "transcript" ? CHAT_INLINE_CHIP_CLASS_NAME : COMPOSER_INLINE_CHIP_CLASS_NAME;
31
32 const icon = (
33 <TerminalIcon
34 className={cn(
35 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
36 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES.terminal,
37 "size-3.5",
38 expired && "opacity-100",
39 )}
40 />
41 );
4218
43 if (!expired && text.length > 0 && detailsMode === "popover") {19 if (!expired && text.length > 0 && detailsMode === "popover") {
44 return (20 return (
45 <ContextChipPopover21 <ContextChipPopover
22 kind="terminal"
23 icon={<TerminalIcon />}
24 label={label}
46 accessibleLabel={`Terminal excerpt, ${label}`}25 accessibleLabel={`Terminal excerpt, ${label}`}
47 chip={
48 <>
49 {icon}
50 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{label}</span>
51 </>
52 }
53 triggerClassName={cn(
54 chipClassName,
55 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.terminal,
56 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
57 "cursor-pointer",
58 )}
59 popupClassName="w-[min(40rem,calc(100vw-2rem))]"26 popupClassName="w-[min(40rem,calc(100vw-2rem))]"
60 viewportClassName="overflow-hidden p-2"27 viewportClassName="overflow-hidden p-2"
61 >28 >
...@@ -83,17 +50,12 @@ export function TerminalContextInlineChip(props: TerminalContextInlineChipProps)...@@ -83,17 +50,12 @@ export function TerminalContextInlineChip(props: TerminalContextInlineChipProps)
8350
84 return (51 return (
85 <ContextChipShell52 <ContextChipShell
86 icon={icon}53 kind="terminal"
54 {...(expired ? { state: "invalid" as const } : {})}
55 icon={<TerminalIcon />}
87 label={label}56 label={label}
88 className={cn(
89 chipClassName,
90 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.terminal,
91 expired && "border-destructive/35 bg-destructive/8 text-destructive",
92 )}
93 labelClassName={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}
94 aria-label={`Terminal excerpt, ${label}${expired ? ", expired" : ""}`}57 aria-label={`Terminal excerpt, ${label}${expired ? ", expired" : ""}`}
95 data-terminal-context-expired={expired ? "true" : undefined}58 data-terminal-context-expired={expired ? "true" : undefined}
96 tooltipClassName="max-w-80 whitespace-pre-wrap leading-tight"
97 tooltip={59 tooltip={
98 expired60 expired
99 ? `Terminal context expired. Remove and re-add ${label} to include it in your message.`61 ? `Terminal context expired. Remove and re-add ${label} to include it in your message.`
apps/web/src/components/composerContextPresentation.tsx+24-68
...@@ -14,7 +14,6 @@ import {...@@ -14,7 +14,6 @@ import {
14 formatAttachmentUploadProgress,14 formatAttachmentUploadProgress,
15 type AttachmentUploadState,15 type AttachmentUploadState,
16} from "~/lib/attachmentUploadState";16} from "~/lib/attachmentUploadState";
17import { cn } from "~/lib/utils";
18import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";17import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
19import {18import {
20 fileContextReference,19 fileContextReference,
...@@ -36,20 +35,14 @@ import {...@@ -36,20 +35,14 @@ import {
36 createContextPresentationRegistry,35 createContextPresentationRegistry,
37 type ContextPresentationCapability,36 type ContextPresentationCapability,
38} from "./contextPresentationRegistry";37} from "./contextPresentationRegistry";
39import {38import type { ContextChipKind } from "./ContextChip";
40 COMPOSER_INLINE_CHIP_CLASS_NAME,
41 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
42 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
43 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES,
44 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
45 PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES,
46} from "./composerInlineChip";
47import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";39import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
48import {40import {
49 ContextChipPopover,41 ContextChipPopover,
50 ContextChipShell,42 ContextChipShell,
51 FileChip,43 FileChip,
52 ImageChipButton,44 ImageChipButton,
45 PULL_REQUEST_CHIP_KINDS,
53 PullRequestChip,46 PullRequestChip,
54 UnresolvedChip,47 UnresolvedChip,
55} from "./contextChipParts";48} from "./contextChipParts";
...@@ -138,30 +131,31 @@ function ContextChip(props: {...@@ -138,30 +131,31 @@ function ContextChip(props: {
138 kindLabel: string;131 kindLabel: string;
139 details: ReactNode;132 details: ReactNode;
140 detailsMode: ContextPresentationCapability["details"];133 detailsMode: ContextPresentationCapability["details"];
141 toneClassName: string;134 kind: ContextChipKind;
142}) {135}) {
143 const content = (136 if (props.detailsMode === "popover") {
137 return (
138 <ContextChipPopover
139 kind={props.kind}
140 icon={props.icon}
141 label={props.label}
142 accessibleLabel={props.kindLabel + ", " + props.label}
143 >
144 {props.details}
145 </ContextChipPopover>
146 );
147 }
148 return (
144 <ContextChipShell149 <ContextChipShell
150 kind={props.kind}
145 icon={props.icon}151 icon={props.icon}
146 label={props.label}152 label={props.label}
147 className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
148 labelClassName={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}
149 interactive={props.detailsMode === "popover"}
150 aria-hidden={props.detailsMode === "popover" ? true : undefined}
151 aria-label={153 aria-label={
152 props.detailsMode === "tooltip" ? `${props.kindLabel}, ${props.label}` : undefined154 props.detailsMode === "tooltip" ? `${props.kindLabel}, ${props.label}` : undefined
153 }155 }
154 tooltip={props.detailsMode === "tooltip" ? props.details : undefined}156 tooltip={props.detailsMode === "tooltip" ? props.details : undefined}
155 />157 />
156 );158 );
157 if (props.detailsMode === "popover") {
158 return (
159 <ContextChipPopover accessibleLabel={props.kindLabel + ", " + props.label} chip={content}>
160 {props.details}
161 </ContextChipPopover>
162 );
163 }
164 return content;
165}159}
166160
167function uploadStatusSuffix(upload: AttachmentUploadState | undefined): string | null {161function uploadStatusSuffix(upload: AttachmentUploadState | undefined): string | null {
...@@ -191,8 +185,6 @@ function ImageContextChip(props: {...@@ -191,8 +185,6 @@ function ImageContextChip(props: {
191 <ImageChipButton185 <ImageChipButton
192 name={props.record.name}186 name={props.record.name}
193 previewUrl={props.record.previewUrl}187 previewUrl={props.record.previewUrl}
194 className={COMPOSER_INLINE_CHIP_CLASS_NAME}
195 labelClassName={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}
196 size={formatAttachmentSize(props.record.sizeBytes)}188 size={formatAttachmentSize(props.record.sizeBytes)}
197 suffix={uploadStatusSuffix(props.upload)}189 suffix={uploadStatusSuffix(props.upload)}
198 onClick={() => actions.expandImage(props.record.id)}190 onClick={() => actions.expandImage(props.record.id)}
...@@ -222,8 +214,6 @@ function FileContextChip(props: {...@@ -222,8 +214,6 @@ function FileContextChip(props: {
222 size={size}214 size={size}
223 isVideo={isVideo}215 isVideo={isVideo}
224 theme={resolvedTheme}216 theme={resolvedTheme}
225 className={COMPOSER_INLINE_CHIP_CLASS_NAME}
226 labelClassName={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}
227 error={props.upload?.status === "failed"}217 error={props.upload?.status === "failed"}
228 unresolved={needsReattach}218 unresolved={needsReattach}
229 suffix={suffix}219 suffix={suffix}
...@@ -243,7 +233,7 @@ function FileContextChip(props: {...@@ -243,7 +233,7 @@ function FileContextChip(props: {
243 );233 );
244}234}
245235
246function PullRequestContextChip(props: { record: ReviewCommentContext; toneClassName: string }) {236function PullRequestContextChip(props: { record: ReviewCommentContext; kind: ContextChipKind }) {
247 const actions = use(ComposerContextActionsContext);237 const actions = use(ComposerContextActionsContext);
248 const metadata = props.record.pullRequest;238 const metadata = props.record.pullRequest;
249 if (metadata === undefined) return null;239 if (metadata === undefined) return null;
...@@ -253,8 +243,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass...@@ -253,8 +243,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass
253 environmentId={actions.environmentId}243 environmentId={actions.environmentId}
254 label={reviewCommentContextLabel(props.record)}244 label={reviewCommentContextLabel(props.record)}
255 kindLabel={pullRequestContextKindLabel(props.record)}245 kindLabel={pullRequestContextKindLabel(props.record)}
256 className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)}246 kind={props.kind}
257 labelClassName={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}
258 onOpen={actions.openPullRequest}247 onOpen={actions.openPullRequest}
259 />248 />
260 );249 );
...@@ -324,10 +313,7 @@ function UnresolvedContextChip(props: { label: string }) {...@@ -324,10 +313,7 @@ function UnresolvedContextChip(props: { label: string }) {
324 return (313 return (
325 <UnresolvedChip314 <UnresolvedChip
326 label={props.label}315 label={props.label}
327 className={COMPOSER_INLINE_CHIP_CLASS_NAME}
328 labelClassName={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}
329 tooltip="This context is no longer available. Remove it or attach it again."316 tooltip="This context is no longer available. Remove it or attach it again."
330 tooltipClassName="max-w-80 leading-tight"
331 />317 />
332 );318 );
333}319}
...@@ -389,40 +375,18 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<...@@ -389,40 +375,18 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
389 return (375 return (
390 <PullRequestContextChip376 <PullRequestContextChip
391 record={entry.record}377 record={entry.record}
392 toneClassName={PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES[pullRequestState]}378 kind={PULL_REQUEST_CHIP_KINDS[pullRequestState]}
393 />379 />
394 );380 );
395 }381 }
396 return (382 return (
397 <ContextChip383 <ContextChip
398 icon={384 icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
399 isPullRequest ? (
400 <PullRequestGlyph.pullRequest
401 className={cn(
402 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
403 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],
404 "size-3.5",
405 )}
406 />
407 ) : (
408 <MessageCircleIcon
409 className={cn(
410 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
411 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["review-comment"],
412 "size-3.5",
413 )}
414 />
415 )
416 }
417 label={reviewCommentContextLabel(entry.record)}385 label={reviewCommentContextLabel(entry.record)}
418 kindLabel={isPullRequest ? pullRequestContextKindLabel(entry.record) : "Review comment"}386 kindLabel={isPullRequest ? pullRequestContextKindLabel(entry.record) : "Review comment"}
419 details={<ComposerReviewCommentDetails comment={entry.record} />}387 details={<ComposerReviewCommentDetails comment={entry.record} />}
420 detailsMode={definition.capabilities.details}388 detailsMode={definition.capabilities.details}
421 toneClassName={389 kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
422 isPullRequest
423 ? PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES[pullRequestState]
424 : CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES["review-comment"]
425 }
426 />390 />
427 );391 );
428 },392 },
...@@ -433,20 +397,12 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<...@@ -433,20 +397,12 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
433 render: (entry, context, definition) =>397 render: (entry, context, definition) =>
434 entry.kind === "preview-annotation" ? (398 entry.kind === "preview-annotation" ? (
435 <ContextChip399 <ContextChip
436 icon={400 icon={<MousePointerClickIcon />}
437 <MousePointerClickIcon
438 className={cn(
439 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
440 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["preview-annotation"],
441 "size-3.5",
442 )}
443 />
444 }
445 label={previewAnnotationContextLabel(entry.record)}401 label={previewAnnotationContextLabel(entry.record)}
446 kindLabel="Preview annotation"402 kindLabel="Preview annotation"
447 details={<ComposerPreviewAnnotationDetails annotation={entry.record} />}403 details={<ComposerPreviewAnnotationDetails annotation={entry.record} />}
448 detailsMode={definition.capabilities.details}404 detailsMode={definition.capabilities.details}
449 toneClassName={CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES["preview-annotation"]}405 kind="preview-annotation"
450 />406 />
451 ) : (407 ) : (
452 <UnresolvedContextChip label={context.label} />408 <UnresolvedContextChip label={context.label} />
apps/web/src/components/composerInlineChip.ts-78
...@@ -1,83 +1,5 @@...@@ -1,83 +1,5 @@
1// Chip metrics are in em so the pills scale with the text they sit in (the
2// composer honors the prompt font-size preference). The chat variant pins the
3// original 12px, where every em value resolves to the same pixels as before.
4const INLINE_CHIP_GEOMETRY_CLASS_NAME =
5 "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] px-[0.5em] font-medium leading-none align-middle";
6
7const INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_GEOMETRY_CLASS_NAME} border border-border/70 bg-accent/40 text-foreground`;
8
9const CONTEXT_INLINE_CHIP_TONE_CLASS_NAME =
10 "border-[color-mix(in_oklab,var(--context-chip-accent)_34%,var(--contrast-border))] bg-[color-mix(in_oklab,var(--context-chip-accent)_11%,transparent)] text-[color-mix(in_oklab,var(--context-chip-accent)_22%,var(--contrast-foreground))]";
11
12export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`;
13
14export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`;
15
16export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =
17 "relative inline-flex items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']";
18
19export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME =
20 "block size-[1.17em] shrink-0 self-center [&>svg]:block";
21
22export const CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME =
23 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--contrast-foreground)]";
24
25export const CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME = `transition-colors hover:border-[color-mix(in_oklab,var(--context-chip-accent)_48%,var(--contrast-border))] hover:bg-[color-mix(in_oklab,var(--context-chip-accent)_17%,transparent)] motion-reduce:transition-none ${CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME}`;
26
27export const CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES = {
28 image: "text-current",
29 video: "text-current",
30 file: "text-current",
31 mention: "text-current",
32 terminal: "text-current",
33 element: "text-current",
34 "preview-annotation": "text-current",
35 "review-comment": "text-current",
36 "pull-request": "text-current",
37 skill: "text-current",
38 citation: "text-current",
39} as const;
40
41/**
42 * Context kinds keep one restrained color identity in both composer and sent messages.
43 * Every accent shares a lightness so no kind reads heavier than another; only hue carries
44 * identity. Chroma is capped, then clamped to what each hue can hold in sRGB, which keeps
45 * the set inside the range the themes use for their own accents rather than above it.
46 */
47export const CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES = {
48 image: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_16)]`,
49 video: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_48)]`,
50 file: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.136_237)]`,
51 mention: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.11_215)]`,
52 terminal: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.134_163)]`,
53 element: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.134_70)]`,
54 "preview-annotation": `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.134_70)]`,
55 "review-comment": `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_292)]`,
56 "pull-request": `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_277)]`,
57 skill: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_322)]`,
58 citation: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_259)]`,
59} as const;
60
61export const PULL_REQUEST_INLINE_CHIP_TONE_CLASS_NAMES = {
62 open: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.134_163)]`,
63 draft: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.02_259)]`,
64 merged: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_292)]`,
65 closed: `${CONTEXT_INLINE_CHIP_TONE_CLASS_NAME} [--context-chip-accent:oklch(0.62_0.16_16)]`,
66 unknown: CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES["pull-request"],
67} as const;
68
69export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
70
71export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =
72 "block self-center truncate leading-tight select-none";
73
74export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = `${INLINE_CHIP_GEOMETRY_CLASS_NAME} select-none border text-[0.86em] ${CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.skill}`;
75
76export 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>`;1export 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>`;
772
78export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
79 "ml-[0.17em] inline-flex size-[1.17em] 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";
80
81/** Keeps both the recognizable beginning and the extension/end of a long attachment name. */3/** Keeps both the recognizable beginning and the extension/end of a long attachment name. */
82export function middleTruncateAttachmentName(name: string, maxCharacters = 36): string {4export function middleTruncateAttachmentName(name: string, maxCharacters = 36): string {
83 const characters = Array.from(name);5 const characters = Array.from(name);
apps/web/src/components/contextChipParts.tsx+67-123
...@@ -10,75 +10,66 @@ import {...@@ -10,75 +10,66 @@ import {
1010
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons";12import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons";
13import type { PullRequestContextDisplayState } from "~/lib/composerContextRecords";
13import { PierreEntryIcon } from "./chat/PierreEntryIcon";14import { PierreEntryIcon } from "./chat/PierreEntryIcon";
14import {15import { middleTruncateAttachmentName } from "./composerInlineChip";
15 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
16 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
17 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES,
18 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
19 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES,
20 middleTruncateAttachmentName,
21} from "./composerInlineChip";
22import { PullRequestContextDetails } from "./PullRequestContextDetails";16import { PullRequestContextDetails } from "./PullRequestContextDetails";
23import { Button } from "./ui/button";17import { ContextChip, ContextChipLabel, type ContextChipKind } from "./ContextChip";
24import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";18import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";19import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
26import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";20import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
27import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";21import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";
2822
29/** Shared visual slots; each surface keeps ownership of payload lookup and actions. */23/** ContextChip kind for a pull request context in each display state. */
24export const PULL_REQUEST_CHIP_KINDS = {
25 open: "pr-open",
26 draft: "pr-draft",
27 merged: "pr-merged",
28 closed: "pr-closed",
29 unknown: "pull-request",
30} as const satisfies Record<PullRequestContextDisplayState | "unknown", ContextChipKind>;
31
32/** A static chip with an optional tooltip; each surface keeps ownership of payload lookup. */
30export function ContextChipShell({33export function ContextChipShell({
31 icon,34 icon,
32 label,35 label,
33 labelClassName,
34 tooltip,36 tooltip,
35 tooltipClassName = "max-w-96 whitespace-pre-wrap leading-tight",
36 interactive,
37 unresolved,
38 className,
39 ...props37 ...props
40}: ComponentProps<"span"> & {38}: Omit<ComponentProps<typeof ContextChip>, "className" | "render"> & {
41 icon: ReactNode;39 icon: ReactNode;
42 label: string;40 label: string;
43 labelClassName: string;41 /** Newlines in the tooltip are kept. */
44 tooltip?: ReactNode;42 tooltip?: ReactNode;
45 tooltipClassName?: string;
46 interactive?: boolean;
47 unresolved?: boolean;
48}) {43}) {
49 const chip = (44 const chip = (
50 <span45 <ContextChip
51 className={cn(46 data-context-unresolved={props.state === "unresolved" ? "true" : undefined}
52 className,
53 interactive && CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
54 tooltip && CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
55 unresolved && "border-dashed text-foreground",
56 )}
57 data-context-unresolved={unresolved ? "true" : undefined}
58 tabIndex={tooltip ? 0 : undefined}47 tabIndex={tooltip ? 0 : undefined}
59 {...props}48 {...props}
60 >49 >
61 {icon}50 {icon}
62 <span className={labelClassName}>{label}</span>51 <ContextChipLabel>{label}</ContextChipLabel>
63 </span>52 </ContextChip>
64 );53 );
65 if (!tooltip) return chip;54 if (!tooltip) return chip;
66 return (55 return (
67 <Tooltip>56 <Tooltip>
68 <TooltipTrigger render={chip} />57 <TooltipTrigger render={chip} />
69 <TooltipPopup side="top" className={tooltipClassName}>58 <TooltipPopup side="top" className="whitespace-pre-wrap">
70 {tooltip}59 {tooltip}
71 </TooltipPopup>60 </TooltipPopup>
72 </Tooltip>61 </Tooltip>
73 );62 );
74}63}
7564
65/** A chip that opens its details in a popover; the chip itself is the trigger. */
76export function ContextChipPopover(props: {66export function ContextChipPopover(props: {
67 kind: ContextChipKind;
68 icon: ReactNode;
69 label: string;
77 copyMarkdown?: string;70 copyMarkdown?: string;
78 accessibleLabel: string;71 accessibleLabel: string;
79 chip: ReactNode;
80 children: ReactNode;72 children: ReactNode;
81 triggerClassName?: string;
82 popupClassName?: string;73 popupClassName?: string;
83 viewportClassName?: string;74 viewportClassName?: string;
84}) {75}) {
...@@ -86,19 +77,16 @@ export function ContextChipPopover(props: {...@@ -86,19 +77,16 @@ export function ContextChipPopover(props: {
86 <Popover>77 <Popover>
87 <PopoverTrigger78 <PopoverTrigger
88 render={79 render={
89 <Button80 <ContextChip
90 variant="chip"81 kind={props.kind}
91 className={cn(82 render={<button type="button" />}
92 "inline-flex max-w-full cursor-pointer items-center rounded-[0.5em] align-middle",
93 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
94 props.triggerClassName,
95 )}
96 aria-label={`${props.accessibleLabel}. Show details`}83 aria-label={`${props.accessibleLabel}. Show details`}
97 data-markdown-copy={props.copyMarkdown}84 data-markdown-copy={props.copyMarkdown}
98 />85 />
99 }86 }
100 >87 >
101 {props.chip}88 {props.icon}
89 <ContextChipLabel>{props.label}</ContextChipLabel>
102 </PopoverTrigger>90 </PopoverTrigger>
103 <PopoverPopup91 <PopoverPopup
104 side="top"92 side="top"
...@@ -118,8 +106,7 @@ export function PullRequestChip(props: {...@@ -118,8 +106,7 @@ export function PullRequestChip(props: {
118 environmentId: EnvironmentId | null;106 environmentId: EnvironmentId | null;
119 label: string;107 label: string;
120 kindLabel: string;108 kindLabel: string;
121 className: string;109 kind: ContextChipKind;
122 labelClassName: string;
123 copyMarkdown?: string;110 copyMarkdown?: string;
124 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;111 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
125}) {112}) {
...@@ -128,21 +115,16 @@ export function PullRequestChip(props: {...@@ -128,21 +115,16 @@ export function PullRequestChip(props: {
128 props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state;115 props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state;
129 const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon;116 const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon;
130 const button = (117 const button = (
131 <Button118 <ContextChip
132 variant="chip"119 kind={props.kind}
133 className={cn(120 render={<button type="button" />}
134 props.className,
135 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
136 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
137 "cursor-pointer",
138 )}
139 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}121 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
140 data-markdown-copy={props.copyMarkdown}122 data-markdown-copy={props.copyMarkdown}
141 onClick={(event) => props.onOpen(event, props.metadata.url)}123 onClick={(event) => props.onOpen(event, props.metadata.url)}
142 >124 >
143 <StateIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />125 <StateIcon />
144 <span className={props.labelClassName}>{props.label}</span>126 <ContextChipLabel>{props.label}</ContextChipLabel>
145 </Button>127 </ContextChip>
146 );128 );
147 if (previewTarget !== null) {129 if (previewTarget !== null) {
148 return (130 return (
...@@ -196,16 +178,13 @@ function averageImageColor(image: HTMLImageElement): string | undefined {...@@ -196,16 +178,13 @@ function averageImageColor(image: HTMLImageElement): string | undefined {
196export function ImageChipButton({178export function ImageChipButton({
197 name,179 name,
198 previewUrl,180 previewUrl,
199 className,
200 labelClassName,
201 size,181 size,
202 suffix,182 suffix,
203 style,183 style,
204 ...props184 ...props
205}: ComponentProps<"button"> & {185}: Omit<ComponentProps<typeof ContextChip>, "kind" | "render"> & {
206 name: string;186 name: string;
207 previewUrl: string | undefined;187 previewUrl: string | undefined;
208 labelClassName: string;
209 /** Every attachment chip reports its size; images are no exception. */188 /** Every attachment chip reports its size; images are no exception. */
210 size: string;189 size: string;
211 suffix?: string | null;190 suffix?: string | null;
...@@ -214,14 +193,9 @@ export function ImageChipButton({...@@ -214,14 +193,9 @@ export function ImageChipButton({
214 const [corsFailedUrl, setCorsFailedUrl] = useState<string>();193 const [corsFailedUrl, setCorsFailedUrl] = useState<string>();
215 const accent = sample?.url === previewUrl ? sample?.color : undefined;194 const accent = sample?.url === previewUrl ? sample?.color : undefined;
216 return (195 return (
217 <Button196 <ContextChip
218 variant="chip"197 kind="image"
219 className={cn(198 render={<button type="button" />}
220 className,
221 CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.image,
222 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
223 "cursor-zoom-in",
224 )}
225 aria-label={`Image attachment, ${name}, ${size}`}199 aria-label={`Image attachment, ${name}, ${size}`}
226 style={{ ...style, ...(accent ? { "--context-chip-accent": accent } : {}) } as CSSProperties}200 style={{ ...style, ...(accent ? { "--context-chip-accent": accent } : {}) } as CSSProperties}
227 {...props}201 {...props}
...@@ -232,25 +206,19 @@ export function ImageChipButton({...@@ -232,25 +206,19 @@ export function ImageChipButton({
232 crossOrigin={corsFailedUrl === previewUrl ? undefined : "anonymous"}206 crossOrigin={corsFailedUrl === previewUrl ? undefined : "anonymous"}
233 src={previewUrl}207 src={previewUrl}
234 alt=""208 alt=""
235 className="size-3.5 shrink-0 rounded-sm object-cover"209 className="size-[1.17em] shrink-0 rounded-sm object-cover"
236 onError={() => setCorsFailedUrl(previewUrl)}210 onError={() => setCorsFailedUrl(previewUrl)}
237 onLoad={(event) =>211 onLoad={(event) =>
238 setSample({ url: previewUrl, color: averageImageColor(event.currentTarget) })212 setSample({ url: previewUrl, color: averageImageColor(event.currentTarget) })
239 }213 }
240 />214 />
241 ) : (215 ) : (
242 <ImageIcon216 <ImageIcon />
243 className={cn(
244 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
245 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES.image,
246 "size-3.5",
247 )}
248 />
249 )}217 )}
250 <span className={cn(labelClassName, "max-w-72")}>{middleTruncateAttachmentName(name)}</span>218 <ContextChipLabel className="max-w-72">{middleTruncateAttachmentName(name)}</ContextChipLabel>
251 <span className="shrink-0 text-[10px] text-current">{size}</span>219 <span className="shrink-0 text-[10px] text-current">{size}</span>
252 {suffix ? <span className="text-[10px] text-current">{suffix}</span> : null}220 {suffix ? <span className="text-[10px] text-current">{suffix}</span> : null}
253 </Button>221 </ContextChip>
254 );222 );
255}223}
256224
...@@ -259,8 +227,6 @@ export function FileChip(props: {...@@ -259,8 +227,6 @@ export function FileChip(props: {
259 size: string;227 size: string;
260 isVideo: boolean;228 isVideo: boolean;
261 theme: "light" | "dark";229 theme: "light" | "dark";
262 className: string;
263 labelClassName: string;
264 accessibleLabel: string;230 accessibleLabel: string;
265 tooltip: string;231 tooltip: string;
266 suffix?: string | null;232 suffix?: string | null;
...@@ -270,35 +236,35 @@ export function FileChip(props: {...@@ -270,35 +236,35 @@ export function FileChip(props: {
270 unresolved?: boolean;236 unresolved?: boolean;
271 onOpen?: (() => void) | undefined;237 onOpen?: (() => void) | undefined;
272}) {238}) {
273 const className = cn(
274 props.className,
275 props.isVideo
276 ? CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.video
277 : CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.file,
278 props.onOpen && !props.disabled && CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
279 props.onOpen && !props.disabled && (props.isVideo ? "cursor-zoom-in" : "cursor-pointer"),
280 props.unresolved && "border-dashed text-foreground",
281 props.error && "border-destructive/35 bg-destructive/8 text-destructive",
282 );
283 const content = <FileChipContent {...props} />;239 const content = <FileChipContent {...props} />;
240 const state = props.unresolved
241 ? ("unresolved" as const)
242 : props.error
243 ? ("invalid" as const)
244 : null;
284 const attributes = {245 const attributes = {
285 className,246 kind: props.isVideo ? "video" : "file",
247 ...(state ? { state } : {}),
286 "aria-label": [props.accessibleLabel, props.suffix].filter(Boolean).join(", "),248 "aria-label": [props.accessibleLabel, props.suffix].filter(Boolean).join(", "),
287 "data-markdown-copy": props.copyMarkdown,249 "data-markdown-copy": props.copyMarkdown,
288 "data-context-unresolved": props.unresolved ? "true" : undefined,250 "data-context-unresolved": props.unresolved ? "true" : undefined,
289 };251 } as const;
290 return (252 return (
291 <Tooltip>253 <Tooltip>
292 <TooltipTrigger254 <TooltipTrigger
293 render={255 render={
294 props.onOpen ? (256 props.onOpen ? (
295 <Button variant="chip" disabled={props.disabled} onClick={props.onOpen} {...attributes}>257 <ContextChip
258 render={<button type="button" disabled={props.disabled} />}
259 onClick={props.onOpen}
260 {...attributes}
261 >
296 {content}262 {content}
297 </Button>263 </ContextChip>
298 ) : (264 ) : (
299 <span tabIndex={0} {...attributes}>265 <ContextChip tabIndex={0} {...attributes}>
300 {content}266 {content}
301 </span>267 </ContextChip>
302 )268 )
303 }269 }
304 />270 />
...@@ -314,55 +280,33 @@ function FileChipContent(props: {...@@ -314,55 +280,33 @@ function FileChipContent(props: {
314 size: string;280 size: string;
315 isVideo: boolean;281 isVideo: boolean;
316 theme: "light" | "dark";282 theme: "light" | "dark";
317 labelClassName: string;
318 suffix?: string | null;283 suffix?: string | null;
319}) {284}) {
320 return (285 return (
321 <>286 <>
322 {props.isVideo ? (287 {props.isVideo ? (
323 <FilmIcon288 <FilmIcon />
324 className={cn(
325 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
326 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES.video,
327 "size-3.5",
328 )}
329 />
330 ) : (289 ) : (
331 <PierreEntryIcon290 <PierreEntryIcon pathValue={props.name} kind="file" theme={props.theme} />
332 pathValue={props.name}
333 kind="file"
334 theme={props.theme}
335 className="size-3.5"
336 />
337 )}291 )}
338 <span className={cn(props.labelClassName, "max-w-72")}>292 <ContextChipLabel className="max-w-72">
339 {middleTruncateAttachmentName(props.name)}293 {middleTruncateAttachmentName(props.name)}
340 </span>294 </ContextChipLabel>
341 <span className="shrink-0 text-[10px] text-current">{props.size}</span>295 <span className="shrink-0 text-[10px] text-current">{props.size}</span>
342 {props.suffix ? <span className="text-[10px] text-current">{props.suffix}</span> : null}296 {props.suffix ? <span className="text-[10px] text-current">{props.suffix}</span> : null}
343 </>297 </>
344 );298 );
345}299}
346300
347export function UnresolvedChip(props: {301export function UnresolvedChip(props: { label: string; tooltip: string; copyMarkdown?: string }) {
348 label: string;
349 className: string;
350 labelClassName: string;
351 tooltip: string;
352 tooltipClassName: string;
353 copyMarkdown?: string;
354}) {
355 return (302 return (
356 <ContextChipShell303 <ContextChipShell
357 icon={<CircleDashedIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />}304 icon={<CircleDashedIcon />}
358 label={props.label}305 label={props.label}
359 className={props.className}306 state="unresolved"
360 labelClassName={props.labelClassName}
361 aria-label={`Unavailable context, ${props.label}`}307 aria-label={`Unavailable context, ${props.label}`}
362 data-markdown-copy={props.copyMarkdown}308 data-markdown-copy={props.copyMarkdown}
363 tooltip={props.tooltip}309 tooltip={props.tooltip}
364 tooltipClassName={props.tooltipClassName}
365 unresolved
366 />310 />
367 );311 );
368}312}
apps/web/src/components/ui/button.tsx+1-3
...@@ -39,7 +39,6 @@ const buttonVariants = cva(...@@ -39,7 +39,6 @@ const buttonVariants = cva(
39 xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",39 xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",
40 },40 },
41 variant: {41 variant: {
42 chip: "",
43 default:42 default:
44 "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-primary/90",43 "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-primary bg-primary text-primary-foreground shadow-primary/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-primary/90",
45 destructive:44 destructive:
...@@ -85,8 +84,7 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {...@@ -85,8 +84,7 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
85 : "button";84 : "button";
8685
87 const defaultProps = {86 const defaultProps = {
88 className:87 className: cn(buttonVariants({ className, size, variant })),
89 variant === "chip" ? cn(className) : cn(buttonVariants({ className, size, variant })),
90 "data-slot": "button",88 "data-slot": "button",
91 type: typeValue,89 type: typeValue,
92 };90 };
apps/web/src/index.css+5-12
...@@ -1770,10 +1770,14 @@ code {...@@ -1770,10 +1770,14 @@ code {
1770}1770}
17711771
1772.chat-markdown a {1772.chat-markdown a {
1773 color: var(--info-foreground);
1774 text-decoration: none;1773 text-decoration: none;
1775}1774}
17761775
1776/* File links are Chips and own their color and focus ring. */
1777.chat-markdown a:not(.chat-markdown-file-link) {
1778 color: var(--info-foreground);
1779}
1780
1777.chat-markdown a:not(.chat-markdown-file-link):hover,1781.chat-markdown a:not(.chat-markdown-file-link):hover,
1778.chat-markdown a:not(.chat-markdown-file-link):focus-visible {1782.chat-markdown a:not(.chat-markdown-file-link):focus-visible {
1779 background-image: radial-gradient(circle, currentcolor 0.75px, transparent 1px);1783 background-image: radial-gradient(circle, currentcolor 0.75px, transparent 1px);
...@@ -1825,17 +1829,6 @@ code {...@@ -1825,17 +1829,6 @@ code {
1825 font-size: 0.75rem;1829 font-size: 0.75rem;
1826}1830}
18271831
1828.chat-markdown .chat-markdown-file-link,
1829.chat-markdown .chat-markdown-file-link:hover {
1830 color: var(--contrast-foreground);
1831 text-decoration: none;
1832}
1833
1834.chat-markdown .chat-markdown-file-link:focus-visible {
1835 outline: none;
1836 box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent);
1837}
1838
1839.chat-markdown pre {1832.chat-markdown pre {
1840 max-width: 100%;1833 max-width: 100%;
1841 overflow-x: auto;1834 overflow-x: auto;
scripts/lint-restyle-ceiling.ts+1-1
...@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";...@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";
8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the
9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you
10// migrate a file, and delete this script when the rule becomes an error.10// migrate a file, and delete this script when the rule becomes an error.
11export const RESTYLE_CEILING = 623;11export const RESTYLE_CEILING = 603;
1212
13const RULE = "shadcn(no-restyle)";13const RULE = "shadcn(no-restyle)";
1414