authorgravatar for 46866023+GuilhermeVieiraDev@users.noreply.github.comGuilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> 2026-04-15 21:29:13+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-15 13:29:13-07:00
log28cb9db20bc78fdb5a767ff9661e500076938fbe
tree1dcd8d62bcf10d4c05f50f728b7b91a69fe6ad68
parent7968f278ee54202cc3ae63f8e52d224b13e0265f
signature Signed by PGP key B5690EEEBB952194

feat(web): add tooltip to composer file mention pill (#1944)


1 files changed, 48 insertions(+), 62 deletions(-)

apps/web/src/components/ComposerPromptEditor.tsx+48-62
......@@ -32,11 +32,8 @@ import {
3232 type ElementNode,
3333 type LexicalNode,
3434 type SerializedLexicalNode,
35 TextNode,
36 type EditorConfig,
3735 type EditorState,
3836 type NodeKey,
39 type SerializedTextNode,
4037 type Spread,
4138} from "lexical";
4239import {
......@@ -103,7 +100,7 @@ type SerializedComposerMentionNode = Spread<
103100 type: "composer-mention";
104101 version: 1;
105102 },
106 SerializedTextNode
103 SerializedLexicalNode
107104>;
108105
109106type SerializedComposerSkillNode = Spread<
......@@ -132,7 +129,40 @@ const ComposerTerminalContextActionsContext = createContext<{
132129 onRemoveTerminalContext: () => {},
133130});
134131
135class ComposerMentionNode extends TextNode {
132function ComposerMentionDecorator(props: { path: string }) {
133 const theme = resolvedThemeFromDocument();
134 const chip = (
135 <span
136 className={COMPOSER_INLINE_CHIP_CLASS_NAME}
137 contentEditable={false}
138 spellCheck={false}
139 data-composer-mention-chip="true"
140 >
141 <img
142 alt=""
143 aria-hidden="true"
144 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
145 loading="lazy"
146 src={getVscodeIconUrlForEntry(props.path, inferEntryKindFromPath(props.path), theme)}
147 />
148 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{basenameOfPath(props.path)}</span>
149 </span>
150 );
151
152 return (
153 <Tooltip>
154 <TooltipTrigger render={chip} />
155 <TooltipPopup
156 side="top"
157 className="max-w-[30rem] whitespace-normal leading-tight wrap-anywhere"
158 >
159 {props.path}
160 </TooltipPopup>
161 </Tooltip>
162 );
163}
164
165class ComposerMentionNode extends DecoratorNode<ReactElement> {
136166 __path: string;
137167
138168 static override getType(): string {
......@@ -144,12 +174,12 @@ class ComposerMentionNode extends TextNode {
144174 }
145175
146176 static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode {
147 return $createComposerMentionNode(serializedNode.path);
177 return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode);
148178 }
149179
150180 constructor(path: string, key?: NodeKey) {
181 super(key);
151182 const normalizedPath = path.startsWith("@") ? path.slice(1) : path;
152 super(`@${normalizedPath}`, key);
153183 this.__path = normalizedPath;
154184 }
155185
......@@ -162,41 +192,26 @@ class ComposerMentionNode extends TextNode {
162192 };
163193 }
164194
165 override createDOM(_config: EditorConfig): HTMLElement {
195 override createDOM(): HTMLElement {
166196 const dom = document.createElement("span");
167 dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME;
168 dom.contentEditable = "false";
169 dom.setAttribute("spellcheck", "false");
170 renderMentionChipDom(dom, this.__path);
197 dom.className = "inline-flex align-middle leading-none";
171198 return dom;
172199 }
173200
174 override updateDOM(
175 prevNode: ComposerMentionNode,
176 dom: HTMLElement,
177 _config: EditorConfig,
178 ): boolean {
179 dom.contentEditable = "false";
180 if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {
181 renderMentionChipDom(dom, this.__path);
182 }
183 return false;
184 }
185
186 override canInsertTextBefore(): false {
201 override updateDOM(): false {
187202 return false;
188203 }
189204
190 override canInsertTextAfter(): false {
191 return false;
205 override getTextContent(): string {
206 return `@${this.__path}`;
192207 }
193208
194 override isTextEntity(): true {
209 override isInline(): true {
195210 return true;
196211 }
197212
198 override isToken(): true {
199 return true;
213 override decorate(): ReactElement {
214 return <ComposerMentionDecorator path={this.__path} />;
200215 }
201216}
202217
......@@ -434,26 +449,6 @@ function resolvedThemeFromDocument(): "light" | "dark" {
434449 return document.documentElement.classList.contains("dark") ? "dark" : "light";
435450}
436451
437function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
438 container.textContent = "";
439 container.style.setProperty("user-select", "none");
440 container.style.setProperty("-webkit-user-select", "none");
441
442 const theme = resolvedThemeFromDocument();
443 const icon = document.createElement("img");
444 icon.alt = "";
445 icon.ariaHidden = "true";
446 icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME;
447 icon.loading = "lazy";
448 icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme);
449
450 const label = document.createElement("span");
451 label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
452 label.textContent = basenameOfPath(pathValue);
453
454 container.append(icon, label);
455}
456
457452function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>): string {
458453 return contexts
459454 .map((context) =>
......@@ -595,12 +590,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
595590 }
596591
597592 if ($isTextNode(node)) {
598 if (node instanceof ComposerMentionNode) {
599 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
600 }
601593 return offset + Math.min(pointOffset, node.getTextContentSize());
602594 }
603 if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
595 if (isComposerInlineTokenNode(node)) {
604596 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
605597 }
606598
......@@ -642,12 +634,9 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe
642634 }
643635
644636 if ($isTextNode(node)) {
645 if (node instanceof ComposerMentionNode) {
646 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
647 }
648637 return offset + Math.min(pointOffset, node.getTextContentSize());
649638 }
650 if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
639 if (isComposerInlineTokenNode(node)) {
651640 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
652641 }
653642
......@@ -673,10 +662,7 @@ function findSelectionPointAtOffset(
673662 node: LexicalNode,
674663 remainingRef: { value: number },
675664): { key: string; offset: number; type: "text" | "element" } | null {
676 if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) {
677 return findSelectionPointForInlineToken(node, remainingRef);
678 }
679 if (node instanceof ComposerTerminalContextNode) {
665 if (isComposerInlineTokenNode(node)) {
680666 return findSelectionPointForInlineToken(node, remainingRef);
681667 }
682668