1import type { ComposerContextClipboardFragment, ServerProviderSkill } from "@t3tools/contracts";
2import {
3 COMPOSER_CONTEXT_CLIPBOARD_MIME,
4 encodeComposerContextClipboardHtml,
5} from "@t3tools/shared/composerContextClipboard";
6import {
7 serializeAssistantCitation,
8 withAssistantCitationComment,
9} from "@t3tools/shared/assistantCitations";
10import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
11import { isolateHistory } from "@codemirror/commands";
12import { Transaction } from "@codemirror/state";
13import * as Schema from "effect/Schema";
14import { createPortal } from "react-dom";
15import { use, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
16
17import { expandCollapsedComposerCursor } from "~/composer-logic";
18import { collectInlineContextIds } from "~/lib/composerContextReferences";
19import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
20import { basenameOfPath } from "~/pierre-icons";
21import { cn } from "~/lib/utils";
22import { PREPARE_UI_REFRESH_EVENT } from "~/uiRefresh";
23import {
24 ComposerContextActionsContext,
25 ComposerContextRecordsContext,
26 ComposerContextReferenceChip,
27 type ComposerDraftContextRecords,
28} from "./composerContextPresentation";
29import {
30 createComposerMarkdownEditor,
31 composerSuggestionsSuppressed,
32 composerTokens,
33 type ComposerChip,
34 type ComposerToken,
35} from "./composerMarkdownEditor";
36import { ContextChip } from "./ContextChip";
37import { ContextChipPopover } from "./contextChipParts";
38import { AssistantCitationChip } from "./chat/AssistantCitationChip";
39import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
40import { FileTagChipContent } from "./chat/FileTagChip";
41import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon";
42import { ComposerCodeBlockLanguagePicker } from "./chat/ComposerCodeBlockLanguagePicker";
43import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages";
44import { SkillChipIcon } from "./chat/SkillInlineText";
45import { Button } from "./ui/button";
46import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
47
48export interface ComposerPromptEditorHandle {
49 focus: () => void;
50 focusAt: (cursor: number) => void;
51 focusAtEnd: () => void;
52 readSelectionRange: () => { start: number; end: number };
53 suggestionsSuppressed: () => boolean;
54 requestCitationComment: (request: ComposerCitationCommentRequest) => void;
55 readSnapshot: () => {
56 value: string;
57 cursor: number;
58 expandedCursor: number;
59 contextIds: string[];
60 };
61 /**
62 * True when a collapsed caret sits on the first ("start") or last ("end")
63 * visual line, counting soft wraps. Prompt history only claims ArrowUp and
64 * ArrowDown at these edges so arrows still move the caret inside multiline
65 * text.
66 */
67 isCaretOnVisualEdge: (edge: "start" | "end") => boolean;
68}
69
70export interface ComposerPromptEditorProps {
71 persistenceKey?: string | undefined;
72 value: string;
73 cursor: number;
74 /**
75 * Render Markdown styling (bold, italic, code, strike, task checkboxes).
76 * Off renders the same CodeMirror engine as plain text: every marker stays a
77 * literal character.
78 */
79 richTextEnabled?: boolean;
80 /** Preserve editor answers verbatim, without Markdown or context chips. */
81 literalText?: boolean;
82 /** Draft records behind the prompt's context references, keyed by context id. */
83 contextRecords: ComposerDraftContextRecords;
84 /** Structured clipboard payload for the given referenced ids, or null to skip. */
85 buildContextClipboardFragment?:
86 | ((contextIds: ReadonlyArray<string>) => string | null)
87 | undefined;
88 /** Imports a structured paste's records; returns ids that changed. */
89 importContextFragment?:
90 | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
91 | undefined;
92 skills: ReadonlyArray<ServerProviderSkill>;
93 disabled: boolean;
94 placeholder: string;
95 ariaLabel?: string | undefined;
96 /** Identifies an editor with suggestions, even while its list is closed. */
97 suggestionListId?: string | undefined;
98 /** References the highlighted option only while its list is rendered. */
99 activeSuggestionId?: string | undefined;
100 containerClassName?: string;
101 className?: string;
102 placeholderClassName?: string;
103 onChange: (
104 nextValue: string,
105 nextCursor: number,
106 expandedCursor: number,
107 suppressSuggestions: boolean,
108 contextIds: string[],
109 ) => void;
110 onVisibleSelectionChange?: () => void;
111 onCommandKeyDown?: (key: string, event: KeyboardEvent) => boolean;
112 onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
113 onPageScrollKeyUp?: (key: string) => void;
114 onPageScrollRelease?: () => void;
115 onCitationSubmitAndSend?: () => void;
116 onCitationDraftSave?: (replacedValue: string, savedValue: string) => void;
117 onPaste: React.ClipboardEventHandler<HTMLElement>;
118 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
119}
120
121export type ComposerCitationCommentRequest = {
122 value: string;
123 citationStart: number;
124 sourceAnchor: AssistantCitationSourceAnchor;
125 insertedSpaces: CitationInsertedSpaces;
126};
127
128type CitationInsertedSpaces = { before: boolean; after: boolean };
129type CitationComment = {
130 start: number;
131 sourceAnchor?: AssistantCitationSourceAnchor | undefined;
132 insertedSpaces?: CitationInsertedSpaces | undefined;
133};
134
135const RefreshSelection = Schema.fromJsonString(
136 Schema.Struct({
137 pageOrigin: Schema.Finite,
138 value: Schema.String,
139 anchor: Schema.Int,
140 head: Schema.Int,
141 scrollTop: Schema.Finite,
142 scrollLeft: Schema.Finite,
143 }),
144);
145const decodeRefreshSelection = Schema.decodeSync(RefreshSelection);
146const REFRESH_SELECTION_STORAGE_PREFIX = "t3code:ui-refresh-composer:";
147
148export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
149 const host = useRef<HTMLDivElement>(null);
150 const currentProps = useRef(props);
151 const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null);
152 const restoringRefreshSelection = useRef(false);
153 const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null);
154 const citationDraft = useRef<{ start: number; comment: string } | null>(null);
155 const [comment, setComment] = useState<CitationComment | null>(null);
156 const [chips, setChips] = useState<ComposerChip[]>([]);
157 const discardRefreshSelection = useCallback(() => {
158 restoringRefreshSelection.current = false;
159 const key = currentProps.current.persistenceKey;
160 if (!key) return;
161 try {
162 window.sessionStorage.removeItem(`${REFRESH_SELECTION_STORAGE_PREFIX}${key}`);
163 } catch {
164 // Storage availability must not prevent editing.
165 }
166 }, []);
167
168 useLayoutEffect(() => {
169 currentProps.current = props;
170 });
171 useLayoutEffect(() => {
172 let alive = true;
173 restoringRefreshSelection.current = false;
174 const instance = createComposerMarkdownEditor({
175 props: () => currentProps.current,
176 mountChip: (chip) => {
177 if (alive) setChips((chips) => [...chips, chip]);
178 },
179 unmountChip: (dom) => {
180 if (alive) setChips((chips) => chips.filter((chip) => chip.dom !== dom));
181 },
182 });
183 editor.current = instance;
184 host.current!.append(instance.dom);
185 instance.dom.addEventListener("pointerdown", discardRefreshSelection, true);
186 instance.dom.addEventListener("keydown", discardRefreshSelection, true);
187 instance.dom.addEventListener("beforeinput", discardRefreshSelection, true);
188 return () => {
189 alive = false;
190 editor.current = null;
191 instance.dom.removeEventListener("pointerdown", discardRefreshSelection, true);
192 instance.dom.removeEventListener("keydown", discardRefreshSelection, true);
193 instance.dom.removeEventListener("beforeinput", discardRefreshSelection, true);
194 instance.destroy();
195 };
196 }, [discardRefreshSelection]);
197
198 // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props.
199 useLayoutEffect(() => {
200 editor.current?.configure();
201 }, [
202 props.disabled,
203 props.richTextEnabled,
204 props.literalText,
205 props.className,
206 props.ariaLabel,
207 props.suggestionListId,
208 props.activeSuggestionId,
209 props.placeholder,
210 ]);
211 // oxlint-enable react/exhaustive-effect-dependencies
212 useLayoutEffect(() => {
213 const instance = editor.current;
214 if (!instance) return;
215 const snapshot = instance.snapshot();
216 if (snapshot.value !== props.value) {
217 if (restoringRefreshSelection.current) discardRefreshSelection();
218 const draft = citationDraft.current;
219 citationDraft.current = null;
220 const saveDraft = currentProps.current.onCitationDraftSave;
221 if (draft && saveDraft) {
222 const token = composerTokens(instance.view.state, false).find(
223 (token) => token.type === "citation" && token.start === draft.start,
224 );
225 if (token?.type === "citation") {
226 const dismissal = resolveAssistantCitationCommentDismissal({
227 reason: "none",
228 draft: draft.comment,
229 savedComment: token.citation.comment,
230 });
231 if (dismissal.kind === "commit") {
232 const source = serializeAssistantCitation(
233 withAssistantCitationComment(token.citation, dismissal.comment),
234 );
235 saveDraft(
236 snapshot.value,
237 snapshot.value.slice(0, token.start) + source + snapshot.value.slice(token.end),
238 );
239 }
240 }
241 }
242 setComment(null);
243 let from = 0;
244 while (
245 from < snapshot.value.length &&
246 from < props.value.length &&
247 snapshot.value[from] === props.value[from]
248 )
249 from += 1;
250 let oldEnd = snapshot.value.length;
251 let newEnd = props.value.length;
252 while (
253 oldEnd > from &&
254 newEnd > from &&
255 snapshot.value[oldEnd - 1] === props.value[newEnd - 1]
256 ) {
257 oldEnd -= 1;
258 newEnd -= 1;
259 }
260 instance.view.dispatch({
261 changes: { from, to: oldEnd, insert: props.value.slice(from, newEnd) },
262 selection: {
263 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),
264 },
265 annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")],
266 });
267 } else if (
268 snapshot.cursor !== props.cursor &&
269 !instance.view.compositionStarted &&
270 !restoringRefreshSelection.current
271 ) {
272 instance.view.dispatch({
273 selection: {
274 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),
275 },
276 annotations: Transaction.userEvent.of("t3.controlled"),
277 });
278 }
279 const request = pendingCitation.current;
280 if (
281 request?.value === props.value &&
282 composerTokens(instance.view.state, props.literalText ?? false).some(
283 (token) => token.type === "citation" && token.start === request.citationStart,
284 )
285 ) {
286 pendingCitation.current = null;
287 setComment({
288 start: request.citationStart,
289 sourceAnchor: request.sourceAnchor,
290 insertedSpaces: request.insertedSpaces,
291 });
292 }
293 }, [props.value, props.cursor, props.literalText, discardRefreshSelection]);
294
295 useLayoutEffect(() => {
296 const instance = editor.current;
297 if (!instance || !props.persistenceKey) return;
298 const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`;
299 const value = props.value;
300 try {
301 const saved = window.sessionStorage.getItem(storageKey);
302 if (saved) {
303 const selection = decodeRefreshSelection(saved);
304 if (
305 selection.pageOrigin !== performance.timeOrigin &&
306 selection.value === value &&
307 selection.anchor >= 0 &&
308 selection.anchor <= value.length &&
309 selection.head >= 0 &&
310 selection.head <= value.length
311 ) {
312 // Mount-time cursor resets and autofocus must not replace a restored selection.
313 restoringRefreshSelection.current = true;
314 instance.view.dispatch({ selection: { anchor: selection.anchor, head: selection.head } });
315 instance.view.scrollDOM.scrollTop = selection.scrollTop;
316 instance.view.scrollDOM.scrollLeft = selection.scrollLeft;
317 }
318 }
319 } catch {
320 // An unreadable snapshot leaves the editor at its ordinary initial caret.
321 }
322 }, [props.persistenceKey, props.value]);
323
324 useLayoutEffect(() => {
325 const instance = editor.current;
326 if (!instance || !props.persistenceKey) return;
327 const storageKey = `${REFRESH_SELECTION_STORAGE_PREFIX}${props.persistenceKey}`;
328 const save = (event: Event) => {
329 if (instance.view.compositionStarted || citationDraft.current || comment) {
330 event.preventDefault();
331 return;
332 }
333 try {
334 const selection = instance.view.state.selection.main;
335 window.sessionStorage.setItem(
336 storageKey,
337 JSON.stringify({
338 pageOrigin: performance.timeOrigin,
339 value: instance.view.state.doc.toString(),
340 anchor: selection.anchor,
341 head: selection.head,
342 scrollTop: instance.view.scrollDOM.scrollTop,
343 scrollLeft: instance.view.scrollDOM.scrollLeft,
344 }),
345 );
346 } catch {
347 event.preventDefault();
348 }
349 };
350 window.addEventListener(PREPARE_UI_REFRESH_EVENT, save);
351 return () => window.removeEventListener(PREPARE_UI_REFRESH_EVENT, save);
352 }, [props.persistenceKey, comment]);
353
354 useLayoutEffect(() => {
355 editor.current?.view.requestMeasure();
356 });
357
358 useImperativeHandle(
359 props.editorRef,
360 () => ({
361 focus: () => editor.current?.focus(),
362 focusAt: (cursor) => {
363 const instance = editor.current;
364 if (!instance) return;
365 discardRefreshSelection();
366 const snapshot = instance.snapshot();
367 instance.view.dispatch({
368 selection: {
369 anchor: expandCollapsedComposerCursor(
370 snapshot.value,
371 cursor,
372 currentProps.current.literalText,
373 ),
374 },
375 scrollIntoView: true,
376 });
377 instance.focus();
378 },
379 focusAtEnd: () => {
380 const instance = editor.current;
381 if (!instance) return;
382 if (restoringRefreshSelection.current) {
383 instance.focus();
384 return;
385 }
386 instance.view.dispatch({
387 selection: { anchor: instance.view.state.doc.length },
388 scrollIntoView: true,
389 });
390 instance.focus();
391 },
392 readSnapshot: () =>
393 editor.current?.snapshot() ?? {
394 value: props.value,
395 cursor: props.cursor,
396 expandedCursor: expandCollapsedComposerCursor(
397 props.value,
398 props.cursor,
399 props.literalText,
400 ),
401 contextIds: [],
402 },
403 readSelectionRange: () => {
404 const range = editor.current?.view.state.selection.main;
405 return { start: range?.from ?? 0, end: range?.to ?? 0 };
406 },
407 suggestionsSuppressed: () =>
408 !editor.current ||
409 composerSuggestionsSuppressed(
410 editor.current.view,
411 currentProps.current.literalText ?? false,
412 ),
413 requestCitationComment: (request) => {
414 pendingCitation.current = request;
415 if (editor.current?.snapshot().value === request.value) {
416 pendingCitation.current = null;
417 setComment({
418 start: request.citationStart,
419 sourceAnchor: request.sourceAnchor,
420 insertedSpaces: request.insertedSpaces,
421 });
422 }
423 },
424 isCaretOnVisualEdge: (edge) => {
425 const instance = editor.current;
426 if (!instance || !instance.view.state.selection.main.empty) return false;
427 const head = instance.view.state.selection.main.head;
428 if (instance.view.state.doc.length === 0) return true;
429 const line = instance.view.state.doc.lineAt(head);
430 if (edge === "start" ? line.number !== 1 : line.number !== instance.view.state.doc.lines)
431 return false;
432 const caret = instance.view.coordsAtPos(head);
433 const boundary = instance.view.coordsAtPos(
434 edge === "start" ? 0 : instance.view.state.doc.length,
435 );
436 return (
437 caret !== null &&
438 boundary !== null &&
439 Math.abs(caret.top - boundary.top) < Math.max(1, (caret.bottom - caret.top) / 2)
440 );
441 },
442 }),
443 [props.editorRef, props.value, props.cursor, props.literalText, discardRefreshSelection],
444 );
445
446 const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => {
447 const instance = editor.current;
448 if (!instance || (cut && props.disabled)) return;
449 const range = instance.view.state.selection.main;
450 if (range.empty) return;
451 const text = instance.view.state.sliceDoc(range.from, range.to);
452 const ids = props.literalText ? [] : collectInlineContextIds(text);
453 const fragment = ids.length > 0 ? props.buildContextClipboardFragment?.(ids) : null;
454 event.preventDefault();
455 event.stopPropagation();
456 event.clipboardData.setData("text/plain", text);
457 if (fragment) {
458 event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
459 event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment));
460 }
461 if (cut)
462 instance.view.dispatch({
463 ...instance.view.state.replaceSelection(""),
464 annotations: isolateHistory.of("full"),
465 userEvent: "delete.cut",
466 scrollIntoView: true,
467 });
468 };
469
470 return (
471 <ComposerContextRecordsContext value={props.contextRecords}>
472 <div
473 className={cn(
474 "relative flow-root font-(family-name:--font-composer,var(--font-sans)) text-(length:--font-size-prompt,var(--text-sm)) max-sm:pointer-coarse:text-(length:--font-size-prompt-touch)",
475 props.containerClassName,
476 )}
477 onPasteCapture={props.onPaste}
478 onCopyCapture={(event) => copy(event, false)}
479 onCutCapture={(event) => copy(event, true)}
480 onKeyDown={(event) => {
481 if (["Control", "Meta", "Alt", "Shift"].includes(event.key))
482 props.onPageScrollRelease?.();
483 }}
484 onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)}
485 onBlur={props.onPageScrollRelease}
486 >
487 <div ref={host} data-ui-refresh-composer={props.persistenceKey} />
488 {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? (
489 <div
490 className={cn(
491 "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
492 props.placeholderClassName,
493 )}
494 >
495 {props.placeholder}
496 </div>
497 ) : null}
498 </div>
499 {chips.map((chip) =>
500 chip.kind === "fence"
501 ? createPortal(
502 <ComposerCodeBlockLanguagePicker
503 language={languageOfInfoString(chip.block.info)}
504 theme={document.documentElement.classList.contains("dark") ? "dark" : "light"}
505 disabled={props.disabled}
506 onChange={(language) => {
507 const instance = editor.current;
508 if (
509 !instance ||
510 props.disabled ||
511 instance.view.state.sliceDoc(chip.block.infoFrom, chip.block.infoTo) !==
512 chip.block.info
513 )
514 return;
515 const info = withInfoStringLanguage(chip.block.info, language);
516 const offset = info.length - (chip.block.infoTo - chip.block.infoFrom);
517 instance.view.dispatch({
518 changes: { from: chip.block.infoFrom, to: chip.block.infoTo, insert: info },
519 selection: { anchor: chip.block.codeTo + offset },
520 annotations: isolateHistory.of("full"),
521 userEvent: "input.fence-language",
522 });
523 instance.focus();
524 }}
525 />,
526 chip.dom,
527 `fence:${chip.block.from}`,
528 )
529 : createPortal(
530 <ComposerTokenChip
531 token={chip.token}
532 skills={props.skills}
533 disabled={props.disabled}
534 comment={comment?.start === chip.token.start ? comment : null}
535 onCommentChange={(open) => {
536 citationDraft.current = null;
537 setComment(open ? { start: chip.token.start } : null);
538 }}
539 onDraftChange={(comment) => {
540 citationDraft.current = { start: chip.token.start, comment };
541 }}
542 onSaveComment={(text) => {
543 const instance = editor.current;
544 const token = chip.token;
545 if (
546 !instance ||
547 props.disabled ||
548 token.type !== "citation" ||
549 instance.view.state.sliceDoc(token.start, token.end) !== token.source
550 )
551 return false;
552 citationDraft.current = null;
553 const source = serializeAssistantCitation(
554 withAssistantCitationComment(token.citation, text),
555 );
556 instance.view.dispatch({
557 changes: { from: token.start, to: token.end, insert: source },
558 annotations: isolateHistory.of("full"),
559 userEvent: "input.citation",
560 });
561 return true;
562 }}
563 onCancel={() => {
564 const instance = editor.current;
565 if (
566 !instance ||
567 props.disabled ||
568 comment?.start !== chip.token.start ||
569 !comment.insertedSpaces
570 )
571 return;
572 const token = chip.token;
573 const from =
574 comment.insertedSpaces.before &&
575 instance.view.state.sliceDoc(token.start - 1, token.start) === " "
576 ? token.start - 1
577 : token.start;
578 const to =
579 comment.insertedSpaces.after &&
580 instance.view.state.sliceDoc(token.end, token.end + 1) === " "
581 ? token.end + 1
582 : token.end;
583 instance.view.dispatch({
584 changes: { from, to },
585 selection: { anchor: from },
586 annotations: isolateHistory.of("full"),
587 userEvent: "delete.citation",
588 });
589 instance.focus();
590 }}
591 onRestoreFocus={() => {
592 const instance = editor.current;
593 if (!instance || props.disabled) return;
594 const token = composerTokens(
595 instance.view.state,
596 props.literalText ?? false,
597 ).find((token) => token.start === chip.token.start);
598 instance.view.dispatch({
599 selection: {
600 anchor:
601 token?.end ?? Math.min(chip.token.start, instance.view.state.doc.length),
602 },
603 scrollIntoView: true,
604 });
605 instance.focus();
606 }}
607 onSubmitAndSend={props.onCitationSubmitAndSend}
608 />,
609 chip.dom,
610 `${chip.token.start}:${chip.token.type}`,
611 ),
612 )}
613 </ComposerContextRecordsContext>
614 );
615}
616
617function ComposerTokenChip(props: {
618 token: ComposerToken;
619 skills: readonly ServerProviderSkill[];
620 disabled: boolean;
621 comment: CitationComment | null;
622 onCommentChange: (open: boolean) => void;
623 onDraftChange: (comment: string) => void;
624 onSaveComment: (text: string) => boolean;
625 onCancel: () => void;
626 onRestoreFocus: () => void;
627 onSubmitAndSend: (() => void) | undefined;
628}) {
629 const actions = use(ComposerContextActionsContext);
630 const token = props.token;
631 if (token.type === "context-reference")
632 return (
633 <ComposerContextReferenceChip
634 kind={token.kind}
635 contextId={token.contextId}
636 label={token.label}
637 />
638 );
639 if (token.type === "citation")
640 return (
641 <span
642 data-composer-citation-chip="true"
643 onKeyDown={(event) => {
644 if (
645 event.key === "Tab" &&
646 !event.shiftKey &&
647 !event.altKey &&
648 !event.metaKey &&
649 !event.ctrlKey
650 ) {
651 event.preventDefault();
652 props.onRestoreFocus();
653 }
654 }}
655 >
656 <AssistantCitationChip
657 citation={token.citation}
658 composer
659 commentEditor={{
660 open: props.comment !== null,
661 sourceAnchor: props.comment?.sourceAnchor,
662 onOpenChange: (open) => {
663 if (!props.disabled || !open) props.onCommentChange(open);
664 },
665 ...(props.comment?.insertedSpaces ? { onCancel: props.onCancel } : {}),
666 onSave: props.onSaveComment,
667 onDraftChange: props.onDraftChange,
668 onSaveAndSend: (text) => {
669 if (!props.onSaveComment(text)) return false;
670 props.onSubmitAndSend?.();
671 return true;
672 },
673 onRestoreFocus: props.onRestoreFocus,
674 }}
675 />
676 </span>
677 );
678 if (token.type === "skill") {
679 const skill = props.skills.find((skill) => skill.name === token.value);
680 const label = formatProviderSkillDisplayName(skill ?? { name: token.value });
681 return (
682 <ContextChipPopover
683 kind="skill"
684 icon={<SkillChipIcon />}
685 label={label}
686 accessibleLabel={`Skill ${label}`}
687 >
688 <div className="space-y-3 p-2 text-sm">
689 <p className="font-medium">{label}</p>
690 <p>{skill?.description ?? "No description is available for this skill."}</p>
691 {skill?.path ? (
692 <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
693 View instructions
694 </Button>
695 ) : null}
696 </div>
697 </ContextChipPopover>
698 );
699 }
700 const path = token.value;
701 return (
702 <Tooltip>
703 <TooltipTrigger
704 render={
705 <ContextChip
706 kind="mention"
707 render={<button type="button" />}
708 onClick={() => actions.openMention(path)}
709 aria-label={`Preview ${path}`}
710 data-composer-mention-chip="true"
711 />
712 }
713 >
714 <FileTagChipContent
715 path={path}
716 environmentId={actions.environmentId}
717 cwd={actions.cwd}
718 label={basenameOfPath(path)}
719 theme={document.documentElement.classList.contains("dark") ? "dark" : "light"}
720 />
721 </TooltipTrigger>
722 <TooltipPopup side="top">
723 <WorkspaceEntryTooltip
724 path={path}
725 environmentId={actions.environmentId}
726 cwd={actions.cwd}
727 />
728 </TooltipPopup>
729 </Tooltip>
730 );
731}