@iloveagents/foundry-web-ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,27 @@
1
1
  /**
2
- * Global "Add to context" popover for text selection anywhere on the page.
2
+ * Global selection command bar for text selection anywhere on the page.
3
3
  *
4
4
  * Appears near selected text when the user makes a selection outside of
5
5
  * input elements and the tool panel (which has its own SelectionPopover).
6
6
  */
7
7
 
8
- import { useState, useEffect, useCallback, useRef } from "react";
9
- import { TextSelect } from "lucide-react";
8
+ import { type FormEvent, useState, useEffect, useCallback, useRef } from "react";
9
+ import { PencilLine, Send, TextSelect, X } from "lucide-react";
10
10
  import { cn } from "@iloveagents/foundry-web-primitives";
11
11
  import { useAppStore } from "../lib/app-store.ts";
12
+ import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
13
+ import { submitComposerText } from "../lib/composer-submit-store.ts";
14
+ import {
15
+ resolveSelectionContext,
16
+ type SelectionContextResult,
17
+ } from "../lib/selection-context.ts";
12
18
 
13
- const POPOVER_HEIGHT = 36;
14
- const POPOVER_WIDTH = 150;
19
+ const ACTION_POPOVER_HEIGHT = 38;
20
+ const SIMPLE_POPOVER_WIDTH = 126;
21
+ const ACTION_POPOVER_WIDTH = 236;
22
+ const MODIFY_POPOVER_WIDTH = 340;
15
23
  const OFFSET = 8;
24
+ const DEFAULT_MODIFY_PROMPT_PREFIX = "Modify the selected content:";
16
25
 
17
26
  /** Elements where selection should NOT trigger the popover */
18
27
  function isEditableTarget(node: Node): boolean {
@@ -27,15 +36,28 @@ function isEditableTarget(node: Node): boolean {
27
36
  export function GlobalSelectionPopover() {
28
37
  const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
29
38
  const [selectedText, setSelectedText] = useState("");
39
+ const [selectionContext, setSelectionContext] = useState<SelectionContextResult | null>(null);
40
+ const [mode, setMode] = useState<"actions" | "modify">("actions");
41
+ const [modifyPrompt, setModifyPrompt] = useState("");
30
42
  const addContextItem = useAppStore((s) => s.addContextItem);
31
- const buttonRef = useRef<HTMLButtonElement>(null);
43
+ const popoverRef = useRef<HTMLElement | null>(null);
44
+ const inputRef = useRef<HTMLInputElement>(null);
45
+
46
+ const setPopoverNode = useCallback((node: HTMLElement | null) => {
47
+ popoverRef.current = node;
48
+ }, []);
32
49
 
33
50
  const hidePopover = useCallback(() => {
34
51
  setPosition(null);
35
52
  setSelectedText("");
53
+ setSelectionContext(null);
54
+ setMode("actions");
55
+ setModifyPrompt("");
36
56
  }, []);
37
57
 
38
- const handleMouseUp = useCallback(() => {
58
+ const handleMouseUp = useCallback((event: MouseEvent) => {
59
+ if (popoverRef.current?.contains(event.target as Node)) return;
60
+
39
61
  requestAnimationFrame(() => {
40
62
  const selection = window.getSelection();
41
63
  if (!selection || selection.isCollapsed || !selection.toString().trim()) return;
@@ -55,10 +77,19 @@ export function GlobalSelectionPopover() {
55
77
 
56
78
  const range = selection.getRangeAt(0);
57
79
  const rect = range.getBoundingClientRect();
80
+ const sourcePage = useAppStore.getState().navContext.label;
81
+ const resolved = resolveSelectionContext({
82
+ text,
83
+ anchorNode,
84
+ focusNode: selection.focusNode,
85
+ range,
86
+ sourcePage,
87
+ });
88
+ const width = resolved.supportsModify ? ACTION_POPOVER_WIDTH : SIMPLE_POPOVER_WIDTH;
58
89
 
59
90
  // Position above selection, fixed to viewport
60
- let top = rect.top - POPOVER_HEIGHT - OFFSET;
61
- let left = rect.left + rect.width / 2 - POPOVER_WIDTH / 2;
91
+ let top = rect.top - ACTION_POPOVER_HEIGHT - OFFSET;
92
+ let left = rect.left + rect.width / 2 - width / 2;
62
93
 
63
94
  // If would go above viewport, place below
64
95
  if (top < OFFSET) {
@@ -66,33 +97,64 @@ export function GlobalSelectionPopover() {
66
97
  }
67
98
 
68
99
  // Clamp horizontal
69
- left = Math.max(OFFSET, Math.min(left, window.innerWidth - POPOVER_WIDTH - OFFSET));
100
+ left = Math.max(OFFSET, Math.min(left, window.innerWidth - width - OFFSET));
70
101
 
71
102
  setSelectedText(text);
103
+ setSelectionContext(resolved);
104
+ setMode("actions");
105
+ setModifyPrompt("");
72
106
  setPosition({ top, left });
73
107
  });
74
108
  }, []);
75
109
 
110
+ const addResolvedContext = useCallback(() => {
111
+ if (!selectionContext) return false;
112
+ addContextItem(selectionContext.item);
113
+ return true;
114
+ }, [addContextItem, selectionContext]);
115
+
76
116
  const handleAddToContext = useCallback(() => {
77
- if (!selectedText) return;
117
+ if (!selectedText || !addResolvedContext()) return;
78
118
 
79
- const navLabel = useAppStore.getState().navContext.label;
119
+ window.getSelection()?.removeAllRanges();
120
+ hidePopover();
121
+ }, [selectedText, addResolvedContext, hidePopover]);
80
122
 
81
- addContextItem({
82
- type: "selection",
83
- label: selectedText,
84
- payload: { kind: "text", text: selectedText },
85
- sourcePage: navLabel,
86
- persistence: "ephemeral",
123
+ const handleStartModify = useCallback(() => {
124
+ setMode("modify");
125
+ setPosition((current) => {
126
+ if (!current) return current;
127
+ const centeredLeft = current.left - (MODIFY_POPOVER_WIDTH - ACTION_POPOVER_WIDTH) / 2;
128
+ return {
129
+ ...current,
130
+ left: Math.max(
131
+ OFFSET,
132
+ Math.min(centeredLeft, window.innerWidth - MODIFY_POPOVER_WIDTH - OFFSET),
133
+ ),
134
+ };
87
135
  });
136
+ }, []);
88
137
 
89
- window.getSelection()?.removeAllRanges();
90
- hidePopover();
91
- }, [selectedText, addContextItem, hidePopover]);
138
+ const handleModifySubmit = useCallback(
139
+ (event: FormEvent<HTMLFormElement>) => {
140
+ event.preventDefault();
141
+ const prompt = modifyPrompt.trim();
142
+ if (!prompt || !selectionContext || !addResolvedContext()) return;
143
+
144
+ const text =
145
+ selectionContext.formatModifyPrompt?.(prompt) ??
146
+ `${DEFAULT_MODIFY_PROMPT_PREFIX} ${prompt}`;
147
+ useChatBubbleStore.getState().open();
148
+ submitComposerText(text);
149
+ window.getSelection()?.removeAllRanges();
150
+ hidePopover();
151
+ },
152
+ [addResolvedContext, hidePopover, modifyPrompt, selectionContext],
153
+ );
92
154
 
93
155
  useEffect(() => {
94
156
  const handleMouseDown = (e: MouseEvent) => {
95
- if (buttonRef.current?.contains(e.target as Node)) return;
157
+ if (popoverRef.current?.contains(e.target as Node)) return;
96
158
  hidePopover();
97
159
  };
98
160
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -110,26 +172,95 @@ export function GlobalSelectionPopover() {
110
172
  };
111
173
  }, [handleMouseUp, hidePopover]);
112
174
 
113
- if (!position) return null;
175
+ useEffect(() => {
176
+ if (mode === "modify") {
177
+ inputRef.current?.focus();
178
+ }
179
+ }, [mode]);
180
+
181
+ if (!position || !selectionContext) return null;
182
+
183
+ if (mode === "modify") {
184
+ return (
185
+ <form
186
+ ref={setPopoverNode}
187
+ className={cn(
188
+ "fixed z-50",
189
+ "flex items-center gap-1.5",
190
+ "rounded-2xl border border-border/70 bg-background/95 p-1.5 shadow-xl shadow-black/10 backdrop-blur-md",
191
+ "animate-in fade-in-0 zoom-in-95 duration-150",
192
+ )}
193
+ style={{ top: position.top, left: position.left, width: MODIFY_POPOVER_WIDTH }}
194
+ onSubmit={handleModifySubmit}
195
+ >
196
+ <PencilLine className="ml-1 size-3.5 shrink-0 text-primary" />
197
+ <input
198
+ ref={inputRef}
199
+ value={modifyPrompt}
200
+ onChange={(event) => setModifyPrompt(event.target.value)}
201
+ placeholder="Describe change..."
202
+ className={cn(
203
+ "min-w-0 flex-1 bg-transparent px-1 py-1.5 text-xs text-foreground outline-none",
204
+ "placeholder:text-muted-foreground",
205
+ )}
206
+ />
207
+ <button
208
+ type="button"
209
+ aria-label="Cancel modify"
210
+ onClick={hidePopover}
211
+ className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
212
+ >
213
+ <X className="size-3.5" />
214
+ </button>
215
+ <button
216
+ type="submit"
217
+ aria-label="Send modify prompt"
218
+ disabled={!modifyPrompt.trim()}
219
+ className={cn(
220
+ "rounded-full p-1.5 transition-colors",
221
+ modifyPrompt.trim()
222
+ ? "bg-primary text-primary-foreground hover:bg-primary/90"
223
+ : "text-muted-foreground opacity-50",
224
+ )}
225
+ >
226
+ <Send className="size-3.5" />
227
+ </button>
228
+ </form>
229
+ );
230
+ }
114
231
 
115
232
  return (
116
- <button
117
- ref={buttonRef}
118
- type="button"
233
+ <div
234
+ ref={setPopoverNode}
235
+ role="toolbar"
236
+ aria-label="Selection actions"
119
237
  className={cn(
120
238
  "fixed z-50",
121
- "flex items-center gap-1.5",
122
- "rounded-lg border border-border bg-background shadow-lg",
123
- "px-3 py-1.5 text-xs font-medium text-foreground",
124
- "hover:bg-muted transition-colors",
239
+ "inline-flex items-center gap-1",
240
+ "rounded-full border border-border/70 bg-background/95 p-1 shadow-xl shadow-black/10 backdrop-blur-md",
125
241
  "animate-in fade-in-0 zoom-in-95 duration-150",
126
242
  )}
127
243
  style={{ top: position.top, left: position.left }}
128
244
  onMouseDown={(e) => e.preventDefault()}
129
- onClick={handleAddToContext}
130
245
  >
131
- <TextSelect className="size-3.5 text-primary" />
132
- Add to context
133
- </button>
246
+ <button
247
+ type="button"
248
+ className="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
249
+ onClick={handleAddToContext}
250
+ >
251
+ <TextSelect className="size-3.5 text-primary" />
252
+ Add to chat
253
+ </button>
254
+ {selectionContext.supportsModify && (
255
+ <button
256
+ type="button"
257
+ className="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-primary/10 hover:text-primary"
258
+ onClick={handleStartModify}
259
+ >
260
+ <PencilLine className="size-3.5 text-primary" />
261
+ Modify
262
+ </button>
263
+ )}
264
+ </div>
134
265
  );
135
266
  }
@@ -0,0 +1,108 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { citationStore, linkStore } from "@iloveagents/foundry-agent";
5
+ import { injectCitations, markdownComponents } from "./markdown-text.tsx";
6
+
7
+ describe("injectCitations", () => {
8
+ let container: HTMLDivElement;
9
+ let root: Root;
10
+ let originalActEnvironment: boolean | undefined;
11
+
12
+ beforeEach(() => {
13
+ originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
14
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
15
+ citationStore.getState().clear();
16
+ linkStore.getState().clear();
17
+ citationStore.getState().setResults([
18
+ {
19
+ chunk_id: "chunk-1",
20
+ entity_id: "entity-1",
21
+ entity_name: "Document One",
22
+ content: "One",
23
+ page_number: 1,
24
+ bounding_regions: "[]",
25
+ score: 1,
26
+ },
27
+ {
28
+ chunk_id: "chunk-2",
29
+ entity_id: "entity-2",
30
+ entity_name: "Document Two",
31
+ content: "Two",
32
+ page_number: 2,
33
+ bounding_regions: "[]",
34
+ score: 1,
35
+ },
36
+ ]);
37
+ citationStore.getState().setHandler({ openCitation: vi.fn() });
38
+ container = document.createElement("div");
39
+ document.body.appendChild(container);
40
+ root = createRoot(container);
41
+ });
42
+
43
+ afterEach(() => {
44
+ act(() => {
45
+ root.unmount();
46
+ });
47
+ container.remove();
48
+ citationStore.getState().clear();
49
+ linkStore.getState().clear();
50
+ globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
51
+ });
52
+
53
+ it("turns citation markers into clickable refs inside plain and nested text", async () => {
54
+ await act(async () => {
55
+ root.render(
56
+ <p>
57
+ {injectCitations([
58
+ "Plain claim [1]. ",
59
+ <strong key="nested">Nested claim [2].</strong>,
60
+ ])}
61
+ </p>,
62
+ );
63
+ });
64
+
65
+ expect(container.querySelectorAll('[role="button"]')).toHaveLength(2);
66
+ expect(container.querySelector("strong sup")?.textContent).toBe("[2]");
67
+ });
68
+
69
+ it("turns handled raw deep links into clickable anchors", async () => {
70
+ const openLink = vi.fn();
71
+ linkStore.getState().setHandler({
72
+ normalizeHref: (href) => href,
73
+ canHandle: (href) => href.startsWith("/spaces/"),
74
+ openLink,
75
+ });
76
+
77
+ await act(async () => {
78
+ root.render(<p>{injectCitations("Created /spaces/entity-1#block-b1.")}</p>);
79
+ });
80
+
81
+ const link = container.querySelector("a");
82
+ expect(link?.getAttribute("href")).toBe("/spaces/entity-1#block-b1");
83
+ expect(link?.textContent).toBe("/spaces/entity-1#block-b1");
84
+
85
+ await act(async () => {
86
+ link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
87
+ });
88
+
89
+ expect(openLink).toHaveBeenCalledWith("/spaces/entity-1#block-b1");
90
+ });
91
+
92
+ it("renders handled inline-code deep links as links", async () => {
93
+ linkStore.getState().setHandler({
94
+ normalizeHref: (href) => href,
95
+ canHandle: (href) => href.startsWith("/spaces/"),
96
+ openLink: vi.fn(),
97
+ });
98
+ const Code = markdownComponents.code;
99
+
100
+ await act(async () => {
101
+ root.render(<p>{Code ? <Code>/spaces/entity-1</Code> : null}</p>);
102
+ });
103
+
104
+ const link = container.querySelector("a");
105
+ expect(link?.getAttribute("href")).toBe("/spaces/entity-1");
106
+ expect(link?.className).toContain("font-mono");
107
+ });
108
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState, type FC } from "react";
1
+ import { cloneElement, isValidElement, useCallback, useEffect, useState, type FC } from "react";
2
2
  import {
3
3
  MarkdownTextPrimitive,
4
4
  type SyntaxHighlighterProps,
@@ -7,11 +7,16 @@ import {
7
7
  import remarkGfm from "remark-gfm";
8
8
  import type { Components } from "react-markdown";
9
9
  import type { Highlighter } from "shiki/bundle/web";
10
- import { Children, type ReactNode } from "react";
10
+ import { Children, type ReactElement, type ReactNode } from "react";
11
11
  import { Check, Copy } from "lucide-react";
12
12
  import { cn } from "@iloveagents/foundry-web-primitives";
13
13
  import { useStore } from "zustand";
14
- import { citationStore, type CitationResult } from "@iloveagents/foundry-agent";
14
+ import {
15
+ citationStore,
16
+ linkStore,
17
+ resolveLinkHandler,
18
+ type CitationResult,
19
+ } from "@iloveagents/foundry-agent";
15
20
 
16
21
  /* ---------- Shiki syntax highlighter (lazy-loaded singleton) ---------- */
17
22
 
@@ -98,11 +103,133 @@ const CopyButton: FC<{ text: string; className?: string }> = ({ text, className
98
103
 
99
104
  /* ---------- Inline code ---------- */
100
105
 
101
- const InlineCode = ({ children, ...props }: React.HTMLAttributes<HTMLElement>) => (
102
- <code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono text-foreground" {...props}>
103
- {children}
104
- </code>
105
- );
106
+ function textFromChildren(children: ReactNode): string | null {
107
+ if (typeof children === "string") return children;
108
+ if (typeof children === "number") return String(children);
109
+ if (!Array.isArray(children)) return null;
110
+ let out = "";
111
+ for (const child of children) {
112
+ if (typeof child === "string" || typeof child === "number") {
113
+ out += String(child);
114
+ } else {
115
+ return null;
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function resolveHandledHref(rawHref: string): string | null {
122
+ return resolveLinkHandler(rawHref)?.href ?? null;
123
+ }
124
+
125
+ function openHandledHref(href: string) {
126
+ const resolved = resolveLinkHandler(href);
127
+ resolved?.handler.openLink(resolved.href);
128
+ }
129
+
130
+ const TRAILING_LINK_PUNCT_RE = /[.,;:!?)\]]+$/;
131
+
132
+ function trimLinkCandidate(value: string): { href: string; suffix: string } {
133
+ const match = value.match(TRAILING_LINK_PUNCT_RE);
134
+ if (!match) return { href: value, suffix: "" };
135
+ return {
136
+ href: value.slice(0, -match[0].length),
137
+ suffix: match[0],
138
+ };
139
+ }
140
+
141
+ function exactHandledHref(value: string): string | null {
142
+ const trimmed = value.trim();
143
+ if (!trimmed || /\s/.test(trimmed)) return null;
144
+ const { href, suffix } = trimLinkCandidate(trimmed);
145
+ if (suffix) return null;
146
+ return resolveHandledHref(href);
147
+ }
148
+
149
+ const HANDLED_LINK_RE = /(https?:\/\/[^\s<>"`]+|\/[A-Za-z0-9][^\s<>"`]*)/g;
150
+
151
+ const HandledLink: FC<
152
+ {
153
+ href: string;
154
+ children: ReactNode;
155
+ className?: string;
156
+ } & React.AnchorHTMLAttributes<HTMLAnchorElement>
157
+ > = ({ href, children, className, ...props }) => {
158
+ const handledHref = useStore(linkStore, () => resolveHandledHref(href));
159
+
160
+ if (!handledHref) {
161
+ return (
162
+ <a
163
+ href={href}
164
+ className={cn("text-primary hover:underline", className)}
165
+ target="_blank"
166
+ rel="noopener noreferrer"
167
+ {...props}
168
+ >
169
+ {children}
170
+ </a>
171
+ );
172
+ }
173
+
174
+ return (
175
+ <a
176
+ href={handledHref}
177
+ className={cn("text-primary hover:underline", className)}
178
+ onClick={(event) => {
179
+ event.preventDefault();
180
+ openHandledHref(handledHref);
181
+ }}
182
+ {...props}
183
+ >
184
+ {children}
185
+ </a>
186
+ );
187
+ };
188
+
189
+ const AutoLinkedText: FC<{ text: string }> = ({ text }) => {
190
+ useStore(linkStore, (s) => s.handlers);
191
+
192
+ const parts: ReactNode[] = [];
193
+ let lastIndex = 0;
194
+ for (const match of text.matchAll(HANDLED_LINK_RE)) {
195
+ const raw = match[0];
196
+ const start = match.index ?? 0;
197
+ const { href, suffix } = trimLinkCandidate(raw);
198
+ const handledHref = resolveHandledHref(href);
199
+ if (!handledHref) continue;
200
+ if (start > lastIndex) parts.push(text.slice(lastIndex, start));
201
+ parts.push(
202
+ <HandledLink key={`${start}-${href}`} href={handledHref}>
203
+ {href}
204
+ </HandledLink>,
205
+ );
206
+ if (suffix) parts.push(suffix);
207
+ lastIndex = start + raw.length;
208
+ }
209
+ if (lastIndex === 0) return <>{text}</>;
210
+ if (lastIndex < text.length) parts.push(text.slice(lastIndex));
211
+ return <>{parts}</>;
212
+ };
213
+
214
+ const InlineCode = ({ children, ...props }: React.HTMLAttributes<HTMLElement>) => {
215
+ const value = textFromChildren(children);
216
+ const handledHref = useStore(linkStore, () => (value ? exactHandledHref(value) : null));
217
+ const className = "rounded bg-muted px-1.5 py-0.5 text-sm font-mono text-foreground";
218
+
219
+ if (handledHref) {
220
+ return (
221
+ <HandledLink href={handledHref} className={className}>
222
+ {children}
223
+ </HandledLink>
224
+ );
225
+ }
226
+
227
+ return (
228
+ <code className={className} {...props}>
229
+ {children}
230
+ </code>
231
+ );
232
+ };
106
233
 
107
234
  /* ---------- Code block with header + highlighting (used by ReactMarkdown / tool panel) ---------- */
108
235
 
@@ -261,15 +388,9 @@ export const markdownComponents: Components = {
261
388
  </blockquote>
262
389
  ),
263
390
  a: ({ children, href, ...props }) => (
264
- <a
265
- href={href}
266
- className="text-primary hover:underline"
267
- target="_blank"
268
- rel="noopener noreferrer"
269
- {...props}
270
- >
391
+ <HandledLink href={href || ""} {...props}>
271
392
  {children}
272
- </a>
393
+ </HandledLink>
273
394
  ),
274
395
  hr: (props) => <hr className="my-6 border-border" {...props} />,
275
396
  table: ({ children, ...props }) => (
@@ -349,17 +470,25 @@ const CitationRef: FC<{ index: number }> = ({ index }) => {
349
470
  * Walk React children and replace [n] text fragments with CitationRef
350
471
  * components. Only processes string children — leaves elements untouched.
351
472
  */
352
- function injectCitations(children: ReactNode): ReactNode {
473
+ export function injectCitations(children: ReactNode): ReactNode {
353
474
  return Children.map(children, (child) => {
354
- if (typeof child !== "string") return child;
355
- if (!CITE_RE.test(child)) return child;
356
-
357
- const parts = child.split(CITE_RE);
358
- return parts.map((part, i) => {
359
- const m = /^\[(\d+)\]$/.exec(part);
360
- if (m) return <CitationRef key={i} index={parseInt(m[1], 10)} />;
361
- return part;
362
- });
475
+ if (typeof child === "string") {
476
+ if (!CITE_RE.test(child)) return <AutoLinkedText text={child} />;
477
+
478
+ const parts = child.split(CITE_RE);
479
+ return parts.map((part, i) => {
480
+ const m = /^\[(\d+)\]$/.exec(part);
481
+ if (m) return <CitationRef key={i} index={parseInt(m[1], 10)} />;
482
+ return <AutoLinkedText key={i} text={part} />;
483
+ });
484
+ }
485
+
486
+ if (!isValidElement(child)) return child;
487
+
488
+ const element = child as ReactElement<{ children?: ReactNode }>;
489
+ const nested = element.props.children;
490
+ if (nested == null) return child;
491
+ return cloneElement(element, undefined, injectCitations(nested));
363
492
  });
364
493
  }
365
494
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Floating "Add to context" button for text selection in the tool panel.
2
+ * Floating "Add to chat" button for text selection in the tool panel.
3
3
  *
4
4
  * Appears near the selected text when the user makes a selection within
5
5
  * the panel content area. Clicking adds the selection to the context store.
@@ -21,7 +21,7 @@ interface PopoverPosition {
21
21
  }
22
22
 
23
23
  const POPOVER_HEIGHT = 36;
24
- const POPOVER_WIDTH = 150;
24
+ const POPOVER_WIDTH = 126;
25
25
  const OFFSET = 8;
26
26
 
27
27
  export function SelectionPopover({ containerRef }: SelectionPopoverProps) {
@@ -150,7 +150,7 @@ export function SelectionPopover({ containerRef }: SelectionPopoverProps) {
150
150
  onClick={handleAddToContext}
151
151
  >
152
152
  <TextSelect className="size-3.5 text-primary" />
153
- Add to context
153
+ Add to chat
154
154
  </button>
155
155
  );
156
156
  }