@iloveagents/foundry-web-ui 0.1.0

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.
Files changed (88) hide show
  1. package/AGENTS.md +59 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +41 -0
  5. package/package.json +61 -0
  6. package/src/__tests__/no-spaces-imports.test.ts +59 -0
  7. package/src/__tests__/open-core-guards.test.ts +307 -0
  8. package/src/__tests__/theme-runtime.test.ts +81 -0
  9. package/src/__tests__/tool-fallback.test.tsx +109 -0
  10. package/src/components/ag-ui-runtime-provider.tsx +59 -0
  11. package/src/components/app-brand.tsx +38 -0
  12. package/src/components/assistant-chat.tsx +423 -0
  13. package/src/components/chat-attachments.tsx +68 -0
  14. package/src/components/chat-bubble.tsx +339 -0
  15. package/src/components/chat-header.tsx +44 -0
  16. package/src/components/client-tool-executor.tsx +230 -0
  17. package/src/components/collection-empty-state.tsx +37 -0
  18. package/src/components/collection-filter-bar.tsx +168 -0
  19. package/src/components/collection-search-input.tsx +41 -0
  20. package/src/components/collection-skeleton.tsx +55 -0
  21. package/src/components/collection-sort-menu.tsx +62 -0
  22. package/src/components/collection-surface.tsx +46 -0
  23. package/src/components/collection-toolbar.tsx +33 -0
  24. package/src/components/collection-view-toggle.tsx +61 -0
  25. package/src/components/confirm-dialog.tsx +49 -0
  26. package/src/components/confirmation-card.tsx +32 -0
  27. package/src/components/context-badges.tsx +124 -0
  28. package/src/components/context-bar.tsx +202 -0
  29. package/src/components/form-dialog.tsx +56 -0
  30. package/src/components/global-selection-popover.tsx +135 -0
  31. package/src/components/infinite-scroll-sentinel.tsx +48 -0
  32. package/src/components/json-viewer.tsx +232 -0
  33. package/src/components/loading-indicator.tsx +40 -0
  34. package/src/components/markdown-text.tsx +379 -0
  35. package/src/components/name-dialog.tsx +87 -0
  36. package/src/components/selection-popover.tsx +156 -0
  37. package/src/components/show-document-tool-ui.tsx +90 -0
  38. package/src/components/sidebar.test.tsx +182 -0
  39. package/src/components/sidebar.tsx +1174 -0
  40. package/src/components/surface-card.tsx +45 -0
  41. package/src/components/theme-runtime-provider.tsx +93 -0
  42. package/src/components/theme-toggle.tsx +27 -0
  43. package/src/components/tool-call-card.tsx +126 -0
  44. package/src/components/tool-fallback.tsx +169 -0
  45. package/src/components/tool-panel-layout.tsx +36 -0
  46. package/src/components/tool-panel.tsx +233 -0
  47. package/src/components/tooltip-icon-button.tsx +28 -0
  48. package/src/components/user-menu.tsx +60 -0
  49. package/src/index.ts +170 -0
  50. package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
  51. package/src/lib/__tests__/app-store.test.ts +330 -0
  52. package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
  53. package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
  54. package/src/lib/__tests__/client-tools.test.ts +124 -0
  55. package/src/lib/__tests__/dev-store.test.ts +78 -0
  56. package/src/lib/__tests__/nav-config.test.ts +135 -0
  57. package/src/lib/__tests__/nav-dnd.test.ts +24 -0
  58. package/src/lib/__tests__/nav-store.test.ts +59 -0
  59. package/src/lib/__tests__/sidebar-store.test.ts +144 -0
  60. package/src/lib/__tests__/theme-store.test.ts +83 -0
  61. package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
  62. package/src/lib/__tests__/use-page-context.test.ts +58 -0
  63. package/src/lib/ag-ui-adapter.ts +362 -0
  64. package/src/lib/app-store.ts +294 -0
  65. package/src/lib/attachment-adapter.ts +92 -0
  66. package/src/lib/auth-provider.tsx +144 -0
  67. package/src/lib/chat-bubble-store.ts +46 -0
  68. package/src/lib/client-tools.ts +293 -0
  69. package/src/lib/dev-store.ts +69 -0
  70. package/src/lib/nav-config.ts +292 -0
  71. package/src/lib/nav-dnd.ts +14 -0
  72. package/src/lib/nav-store.ts +76 -0
  73. package/src/lib/sidebar-store.ts +113 -0
  74. package/src/lib/theme-runtime.ts +660 -0
  75. package/src/lib/theme-store.ts +67 -0
  76. package/src/lib/tool-panel-store.ts +121 -0
  77. package/src/lib/use-new-conversation.test.tsx +124 -0
  78. package/src/lib/use-new-conversation.ts +33 -0
  79. package/src/lib/use-page-tools.ts +40 -0
  80. package/src/ui/collapsible.tsx +7 -0
  81. package/src/ui/dialog.tsx +109 -0
  82. package/src/ui/dropdown-menu.tsx +71 -0
  83. package/src/ui/popover.tsx +34 -0
  84. package/src/ui/select.tsx +15 -0
  85. package/src/ui/tooltip.tsx +30 -0
  86. package/src/vite-env.d.ts +1 -0
  87. package/tsconfig.json +16 -0
  88. package/vitest.config.ts +8 -0
@@ -0,0 +1,40 @@
1
+ import { useStore } from "zustand";
2
+ import { streamingStatusStore } from "@iloveagents/foundry-agent";
3
+
4
+ function formatToolName(name: string): string {
5
+ return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
6
+ }
7
+
8
+ /** Shimmer bar shown above the composer while the agent is running. */
9
+ export function LoadingBar() {
10
+ return (
11
+ <div className="w-full h-px bg-primary/30 overflow-hidden">
12
+ <div className="h-full w-1/4 bg-primary animate-[shimmer_1.5s_ease-in-out_infinite]" />
13
+ </div>
14
+ );
15
+ }
16
+
17
+ /** Inline status text shown in the message area while the agent is running. */
18
+ export function LoadingIndicator() {
19
+ const streaming = useStore(streamingStatusStore, (s) => s.streamingStatus);
20
+
21
+ let label = "Thinking";
22
+ if (streaming.status === "calling" && streaming.toolName) {
23
+ label = `Running ${formatToolName(streaming.toolName)}`;
24
+ } else if (streaming.status === "streaming") {
25
+ label = "Generating response";
26
+ }
27
+
28
+ return (
29
+ <div className="h-10 px-4">
30
+ <div className="flex items-center gap-2 py-2 text-foreground leading-relaxed animate-in fade-in duration-300">
31
+ <span className="text-sm text-muted-foreground">{label}</span>
32
+ <div className="flex gap-1">
33
+ <span className="size-1.5 rounded-full bg-primary/60 animate-[pulse_1.4s_ease-in-out_infinite]" />
34
+ <span className="size-1.5 rounded-full bg-primary/60 animate-[pulse_1.4s_ease-in-out_0.2s_infinite]" />
35
+ <span className="size-1.5 rounded-full bg-primary/60 animate-[pulse_1.4s_ease-in-out_0.4s_infinite]" />
36
+ </div>
37
+ </div>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,379 @@
1
+ import { useCallback, useEffect, useState, type FC } from "react";
2
+ import {
3
+ MarkdownTextPrimitive,
4
+ type SyntaxHighlighterProps,
5
+ type CodeHeaderProps,
6
+ } from "@assistant-ui/react-markdown";
7
+ import remarkGfm from "remark-gfm";
8
+ import type { Components } from "react-markdown";
9
+ import type { Highlighter } from "shiki/bundle/web";
10
+ import { Children, type ReactNode } from "react";
11
+ import { Check, Copy } from "lucide-react";
12
+ import { cn } from "@iloveagents/foundry-web-primitives";
13
+ import { useStore } from "zustand";
14
+ import { citationStore, type CitationResult } from "@iloveagents/foundry-agent";
15
+
16
+ /* ---------- Shiki syntax highlighter (lazy-loaded singleton) ---------- */
17
+
18
+ let highlighterPromise: Promise<Highlighter> | null = null;
19
+
20
+ function getHighlighter(): Promise<Highlighter> {
21
+ if (!highlighterPromise) {
22
+ highlighterPromise = import("shiki/bundle/web").then((mod) =>
23
+ mod.createHighlighter({
24
+ themes: ["github-dark"],
25
+ langs: [],
26
+ }),
27
+ );
28
+ }
29
+ return highlighterPromise;
30
+ }
31
+
32
+ /**
33
+ * Shared hook: returns highlighted HTML for a code string, or null while loading.
34
+ * Always uses github-dark because code blocks have a dark background (bg-code-block)
35
+ * regardless of the app's light/dark mode.
36
+ */
37
+ function useShikiHighlight(code: string, language: string | undefined): string | null {
38
+ const [html, setHtml] = useState<string | null>(null);
39
+
40
+ useEffect(() => {
41
+ if (!language) return;
42
+ let cancelled = false;
43
+
44
+ getHighlighter().then(async (hl) => {
45
+ if (cancelled) return;
46
+ const loaded = hl.getLoadedLanguages();
47
+ if (!loaded.includes(language as never)) {
48
+ try {
49
+ await hl.loadLanguage(language as never);
50
+ } catch {
51
+ // Language not supported -- keep html null (fallback rendering)
52
+ return;
53
+ }
54
+ }
55
+ if (cancelled) return;
56
+ setHtml(hl.codeToHtml(code, { lang: language, theme: "github-dark" }));
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [language, code]);
62
+
63
+ return html;
64
+ }
65
+
66
+ /* ---------- Shared copy-to-clipboard button ---------- */
67
+
68
+ const CopyButton: FC<{ text: string; className?: string }> = ({ text, className }) => {
69
+ const [copied, setCopied] = useState(false);
70
+
71
+ const handleCopy = useCallback(() => {
72
+ navigator.clipboard.writeText(text).then(() => {
73
+ setCopied(true);
74
+ setTimeout(() => setCopied(false), 2000);
75
+ });
76
+ }, [text]);
77
+
78
+ return (
79
+ <button
80
+ type="button"
81
+ onClick={handleCopy}
82
+ className={cn(
83
+ "flex items-center justify-center size-7 rounded-md",
84
+ "hover:bg-muted/50 transition-colors",
85
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
86
+ className,
87
+ )}
88
+ aria-label="Copy code"
89
+ >
90
+ {copied ? (
91
+ <Check className="size-3.5 text-success" />
92
+ ) : (
93
+ <Copy className="size-3.5 text-muted-foreground" />
94
+ )}
95
+ </button>
96
+ );
97
+ };
98
+
99
+ /* ---------- Inline code ---------- */
100
+
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
+
107
+ /* ---------- Code block with header + highlighting (used by ReactMarkdown / tool panel) ---------- */
108
+
109
+ const HighlightedCodeBlock: FC<{ language?: string; code: string }> = ({ language, code }) => {
110
+ const html = useShikiHighlight(code, language);
111
+
112
+ return (
113
+ <div className="relative group my-4">
114
+ <div
115
+ className={cn(
116
+ "flex items-center justify-between",
117
+ "rounded-t-lg bg-code-block px-4 py-2",
118
+ "border-b border-border/30",
119
+ )}
120
+ >
121
+ <span className="text-xs font-medium text-muted-foreground">{language || "text"}</span>
122
+ <CopyButton text={code} />
123
+ </div>
124
+ {html ? (
125
+ <div
126
+ dangerouslySetInnerHTML={{ __html: html }}
127
+ className="[&_pre]:bg-code-block! [&_pre]:rounded-b-lg [&_pre]:rounded-t-none [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono"
128
+ />
129
+ ) : (
130
+ <pre className="rounded-b-lg bg-code-block p-4 overflow-x-auto text-sm font-mono text-code-block-foreground">
131
+ <code>{code}</code>
132
+ </pre>
133
+ )}
134
+ </div>
135
+ );
136
+ };
137
+
138
+ /**
139
+ * Extracts language and raw code from the <code> child that ReactMarkdown
140
+ * renders inside <pre>. Returns { language, code } or null for non-code pre.
141
+ */
142
+ function extractCodeInfo(children: React.ReactNode): { language?: string; code: string } | null {
143
+ const child = Array.isArray(children) ? children[0] : children;
144
+ if (child && typeof child === "object" && "props" in child && child.props) {
145
+ const className: string | undefined = child.props.className;
146
+ const lang = className?.match(/language-(\S+)/)?.[1];
147
+ const raw = child.props.children;
148
+ const code =
149
+ typeof raw === "string" ? raw : Array.isArray(raw) ? raw.join("") : String(raw ?? "");
150
+ return { language: lang, code: code.replace(/\n$/, "") };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /* ---------- MarkdownTextPrimitive components (chat messages) ---------- */
156
+
157
+ const ShikiHighlighter: FC<SyntaxHighlighterProps> = ({ language, code, components: C }) => {
158
+ const html = useShikiHighlight(code, language);
159
+
160
+ if (html) {
161
+ return (
162
+ <div
163
+ dangerouslySetInnerHTML={{ __html: html }}
164
+ className="[&_pre]:bg-code-block! [&_pre]:rounded-b-lg [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono"
165
+ />
166
+ );
167
+ }
168
+
169
+ return (
170
+ <C.Pre className="rounded-b-lg bg-code-block p-4 overflow-x-auto text-sm font-mono text-code-block-foreground">
171
+ <C.Code>{code}</C.Code>
172
+ </C.Pre>
173
+ );
174
+ };
175
+
176
+ const CodeBlockHeader: FC<CodeHeaderProps> = ({ language, code }) => (
177
+ <div
178
+ className={cn(
179
+ "flex items-center justify-between",
180
+ "rounded-t-lg bg-code-block px-4 py-2 mt-4",
181
+ "border-b border-border/30",
182
+ )}
183
+ >
184
+ <span className="text-xs font-medium text-muted-foreground">{language || "text"}</span>
185
+ <CopyButton text={code} />
186
+ </div>
187
+ );
188
+
189
+ /* ---------- Shared markdown components (used by both chat + tool panel) ---------- */
190
+
191
+ /**
192
+ * Shared markdown component overrides used by both the chat messages
193
+ * (MarkdownTextPrimitive) and the tool panel (ReactMarkdown).
194
+ */
195
+ export const markdownComponents: Components = {
196
+ h1: ({ children, ...props }) => (
197
+ <h1 className="text-2xl font-bold mt-6 mb-4 text-foreground" {...props}>
198
+ {children}
199
+ </h1>
200
+ ),
201
+ h2: ({ children, ...props }) => (
202
+ <h2 className="text-xl font-bold mt-5 mb-3 text-foreground" {...props}>
203
+ {children}
204
+ </h2>
205
+ ),
206
+ h3: ({ children, ...props }) => (
207
+ <h3 className="text-lg font-semibold mt-4 mb-2 text-foreground" {...props}>
208
+ {children}
209
+ </h3>
210
+ ),
211
+ h4: ({ children, ...props }) => (
212
+ <h4 className="text-base font-semibold mt-3 mb-1.5 text-foreground" {...props}>
213
+ {children}
214
+ </h4>
215
+ ),
216
+ p: ({ children, ...props }) => (
217
+ <p className="mb-2 leading-relaxed text-foreground" {...props}>
218
+ {injectCitations(children)}
219
+ </p>
220
+ ),
221
+ ul: ({ children, ...props }) => (
222
+ <ul className="list-disc list-outside pl-6 mb-4 space-y-1 text-foreground" {...props}>
223
+ {children}
224
+ </ul>
225
+ ),
226
+ ol: ({ children, ...props }) => (
227
+ <ol className="list-decimal list-outside pl-6 mb-4 space-y-1 text-foreground" {...props}>
228
+ {children}
229
+ </ol>
230
+ ),
231
+ li: ({ children, ...props }) => (
232
+ <li className="text-foreground" {...props}>
233
+ {injectCitations(children)}
234
+ </li>
235
+ ),
236
+ pre: ({ children }) => {
237
+ const info = extractCodeInfo(children);
238
+ if (info) {
239
+ return <HighlightedCodeBlock language={info.language} code={info.code} />;
240
+ }
241
+ return (
242
+ <pre className="rounded-lg bg-code-block p-4 overflow-x-auto text-sm font-mono text-code-block-foreground my-4">
243
+ {children}
244
+ </pre>
245
+ );
246
+ },
247
+ code: ({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) => {
248
+ // Fenced code blocks are wrapped in <pre><code> — the pre handles styling.
249
+ // Only inline code (no language class, not inside pre) gets the InlineCode style.
250
+ if (className?.includes("language-")) {
251
+ return <code {...props}>{children}</code>;
252
+ }
253
+ return <InlineCode {...props}>{children}</InlineCode>;
254
+ },
255
+ blockquote: ({ children, ...props }) => (
256
+ <blockquote
257
+ className="border-l-4 border-primary pl-4 py-2 my-4 italic text-muted-foreground"
258
+ {...props}
259
+ >
260
+ {children}
261
+ </blockquote>
262
+ ),
263
+ 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
+ >
271
+ {children}
272
+ </a>
273
+ ),
274
+ hr: (props) => <hr className="my-6 border-border" {...props} />,
275
+ table: ({ children, ...props }) => (
276
+ <div className="overflow-x-auto my-4">
277
+ <table className="min-w-full divide-y divide-border" {...props}>
278
+ {children}
279
+ </table>
280
+ </div>
281
+ ),
282
+ thead: ({ children, ...props }) => (
283
+ <thead className="bg-muted" {...props}>
284
+ {children}
285
+ </thead>
286
+ ),
287
+ tbody: ({ children, ...props }) => (
288
+ <tbody className="divide-y divide-border" {...props}>
289
+ {children}
290
+ </tbody>
291
+ ),
292
+ tr: ({ children, ...props }) => <tr {...props}>{children}</tr>,
293
+ th: ({ children, ...props }) => (
294
+ <th className="px-4 py-2 text-left text-sm font-semibold text-foreground" {...props}>
295
+ {children}
296
+ </th>
297
+ ),
298
+ td: ({ children, ...props }) => (
299
+ <td className="px-4 py-2 text-sm text-foreground" {...props}>
300
+ {children}
301
+ </td>
302
+ ),
303
+ strong: ({ children, ...props }) => (
304
+ <strong className="font-bold" {...props}>
305
+ {children}
306
+ </strong>
307
+ ),
308
+ em: ({ children, ...props }) => (
309
+ <em className="italic" {...props}>
310
+ {children}
311
+ </em>
312
+ ),
313
+ };
314
+
315
+ /* ---------- Inline [n] citations (React-level, no remark plugin) ---------- */
316
+
317
+ const CITE_RE = /(\[\d+\])/;
318
+
319
+ /** Open a citation via the registered handler (provided by feature modules). */
320
+ function openCitation(result: CitationResult) {
321
+ const handler = citationStore.getState().handler;
322
+ if (handler) handler.openCitation(result);
323
+ }
324
+
325
+ /** Clickable superscript citation: [1] → opens via registered handler. */
326
+ const CitationRef: FC<{ index: number }> = ({ index }) => {
327
+ const result = useStore(citationStore, (s) => s.results[index - 1]);
328
+ const hasHandler = useStore(citationStore, (s) => !!s.handler);
329
+ if (!result) return <span className="text-muted-foreground">[{index}]</span>;
330
+ if (!hasHandler) return <span className="text-muted-foreground">[{index}]</span>;
331
+
332
+ return (
333
+ <span
334
+ role="button"
335
+ tabIndex={0}
336
+ onClick={() => openCitation(result)}
337
+ onKeyDown={(e) => {
338
+ if (e.key === "Enter") openCitation(result);
339
+ }}
340
+ title={`${result.entity_name}, p. ${result.page_number}`}
341
+ className="inline-flex items-center text-primary font-semibold cursor-pointer hover:underline"
342
+ >
343
+ <sup className="text-[0.7em] ml-0.5">[{index}]</sup>
344
+ </span>
345
+ );
346
+ };
347
+
348
+ /**
349
+ * Walk React children and replace [n] text fragments with CitationRef
350
+ * components. Only processes string children — leaves elements untouched.
351
+ */
352
+ function injectCitations(children: ReactNode): ReactNode {
353
+ 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
+ });
363
+ });
364
+ }
365
+
366
+ /* ---------- MarkdownText (chat messages) ---------- */
367
+
368
+ export const MarkdownText: FC = () => {
369
+ return (
370
+ <MarkdownTextPrimitive
371
+ remarkPlugins={[remarkGfm]}
372
+ components={{
373
+ ...markdownComponents,
374
+ SyntaxHighlighter: ShikiHighlighter,
375
+ CodeHeader: CodeBlockHeader,
376
+ }}
377
+ />
378
+ );
379
+ };
@@ -0,0 +1,87 @@
1
+ import { useEffect, useId, useState, type ReactNode } from "react";
2
+
3
+ import { Button } from "@iloveagents/foundry-web-primitives";
4
+ import { Input } from "@iloveagents/foundry-web-primitives";
5
+ import { FormDialog } from "./form-dialog.tsx";
6
+ import { Field } from "@iloveagents/foundry-web-primitives";
7
+
8
+ export function NameDialog({
9
+ open,
10
+ onOpenChange,
11
+ title,
12
+ description,
13
+ placeholder,
14
+ label = "Name",
15
+ submitLabel = "Create",
16
+ initialValue = "",
17
+ error,
18
+ pending = false,
19
+ extra,
20
+ onSubmit,
21
+ }: {
22
+ open: boolean;
23
+ onOpenChange: (open: boolean) => void;
24
+ title: ReactNode;
25
+ description?: ReactNode;
26
+ placeholder: string;
27
+ label?: ReactNode;
28
+ submitLabel?: string;
29
+ initialValue?: string;
30
+ error?: ReactNode;
31
+ pending?: boolean;
32
+ extra?: ReactNode;
33
+ onSubmit: (value: string) => void | Promise<void>;
34
+ }) {
35
+ const inputId = useId();
36
+ const [value, setValue] = useState(initialValue);
37
+
38
+ useEffect(() => {
39
+ if (open) setValue(initialValue);
40
+ }, [open, initialValue]);
41
+
42
+ return (
43
+ <FormDialog
44
+ open={open}
45
+ onOpenChange={onOpenChange}
46
+ title={title}
47
+ description={description}
48
+ size="sm"
49
+ footer={
50
+ <>
51
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
52
+ Cancel
53
+ </Button>
54
+ <Button
55
+ type="submit"
56
+ form={`${inputId}-form`}
57
+ disabled={!value.trim() || pending}
58
+ >
59
+ {pending ? `${submitLabel}...` : submitLabel}
60
+ </Button>
61
+ </>
62
+ }
63
+ >
64
+ <form
65
+ id={`${inputId}-form`}
66
+ className="space-y-4"
67
+ onSubmit={(event) => {
68
+ event.preventDefault();
69
+ const trimmed = value.trim();
70
+ if (!trimmed) return;
71
+ void onSubmit(trimmed);
72
+ }}
73
+ >
74
+ <Field label={label} htmlFor={inputId} error={error}>
75
+ <Input
76
+ id={inputId}
77
+ value={value}
78
+ onChange={(event) => setValue(event.target.value)}
79
+ placeholder={placeholder}
80
+ autoFocus
81
+ />
82
+ </Field>
83
+ {extra}
84
+ </form>
85
+ </FormDialog>
86
+ );
87
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Floating "Add to context" button for text selection in the tool panel.
3
+ *
4
+ * Appears near the selected text when the user makes a selection within
5
+ * the panel content area. Clicking adds the selection to the context store.
6
+ */
7
+
8
+ import { type RefObject, useState, useEffect, useCallback, useRef } from "react";
9
+ import { TextSelect } from "lucide-react";
10
+ import { cn } from "@iloveagents/foundry-web-primitives";
11
+ import { useAppStore } from "../lib/app-store.ts";
12
+ import { useToolPanelStore } from "../lib/tool-panel-store.ts";
13
+
14
+ interface SelectionPopoverProps {
15
+ containerRef: RefObject<HTMLDivElement | null>;
16
+ }
17
+
18
+ interface PopoverPosition {
19
+ top: number;
20
+ left: number;
21
+ }
22
+
23
+ const POPOVER_HEIGHT = 36;
24
+ const POPOVER_WIDTH = 150;
25
+ const OFFSET = 8;
26
+
27
+ export function SelectionPopover({ containerRef }: SelectionPopoverProps) {
28
+ const [position, setPosition] = useState<PopoverPosition | null>(null);
29
+ const [selectedText, setSelectedText] = useState("");
30
+ const addContextItem = useAppStore((s) => s.addContextItem);
31
+ const buttonRef = useRef<HTMLButtonElement>(null);
32
+
33
+ const hidePopover = useCallback(() => {
34
+ setPosition(null);
35
+ setSelectedText("");
36
+ }, []);
37
+
38
+ const handleMouseUp = useCallback(() => {
39
+ // Small delay to let the selection finalize
40
+ requestAnimationFrame(() => {
41
+ const selection = window.getSelection();
42
+ if (!selection || selection.isCollapsed || !selection.toString().trim()) {
43
+ return;
44
+ }
45
+
46
+ // Ensure selection is within the container
47
+ const container = containerRef.current;
48
+ if (!container) return;
49
+
50
+ const anchorNode = selection.anchorNode;
51
+ if (!anchorNode || !container.contains(anchorNode)) {
52
+ return;
53
+ }
54
+
55
+ const text = selection.toString().trim();
56
+ if (!text) return;
57
+
58
+ const range = selection.getRangeAt(0);
59
+ const rect = range.getBoundingClientRect();
60
+
61
+ // The tool panel uses CSS transforms (translate-x), which creates
62
+ // a new containing block -- `position: fixed` becomes relative to
63
+ // the nearest transformed ancestor, not the viewport. Convert
64
+ // viewport coords to container-relative coords.
65
+ const containerRect = container.getBoundingClientRect();
66
+
67
+ // Position above selection
68
+ let top = rect.top - containerRect.top + container.scrollTop - POPOVER_HEIGHT - OFFSET;
69
+ let left = rect.left - containerRect.left + rect.width / 2 - POPOVER_WIDTH / 2;
70
+
71
+ // If would go above visible area, place below instead
72
+ if (rect.top - containerRect.top - POPOVER_HEIGHT - OFFSET < 0) {
73
+ top = rect.bottom - containerRect.top + container.scrollTop + OFFSET;
74
+ }
75
+
76
+ // Clamp horizontal within container
77
+ const maxLeft = containerRect.width - POPOVER_WIDTH - OFFSET;
78
+ left = Math.max(OFFSET, Math.min(left, maxLeft));
79
+
80
+ setSelectedText(text);
81
+ setPosition({ top, left });
82
+ });
83
+ }, [containerRef]);
84
+
85
+ const handleAddToContext = useCallback(() => {
86
+ if (!selectedText) return;
87
+
88
+ const source = useToolPanelStore.getState().content?.title ?? "Document";
89
+
90
+ addContextItem({
91
+ type: "selection",
92
+ label: selectedText,
93
+ payload: { kind: "text", text: selectedText },
94
+ sourcePage: source,
95
+ persistence: "ephemeral",
96
+ });
97
+
98
+ // Clear selection and hide popover
99
+ window.getSelection()?.removeAllRanges();
100
+ hidePopover();
101
+ }, [selectedText, addContextItem, hidePopover]);
102
+
103
+ useEffect(() => {
104
+ const container = containerRef.current;
105
+ if (!container) return;
106
+
107
+ container.addEventListener("mouseup", handleMouseUp);
108
+
109
+ // Hide on mousedown (new selection starting), scroll, or Escape.
110
+ // Skip if the mousedown is on the popover button itself -- React's
111
+ // synthetic onMouseDown fires after native listeners, so we can't
112
+ // rely on stopPropagation there.
113
+ const handleMouseDown = (e: MouseEvent) => {
114
+ if (buttonRef.current?.contains(e.target as Node)) return;
115
+ hidePopover();
116
+ };
117
+ const handleScroll = () => hidePopover();
118
+ const handleKeyDown = (e: KeyboardEvent) => {
119
+ if (e.key === "Escape") hidePopover();
120
+ };
121
+
122
+ container.addEventListener("mousedown", handleMouseDown);
123
+ container.addEventListener("scroll", handleScroll, true);
124
+ window.addEventListener("keydown", handleKeyDown);
125
+
126
+ return () => {
127
+ container.removeEventListener("mouseup", handleMouseUp);
128
+ container.removeEventListener("mousedown", handleMouseDown);
129
+ container.removeEventListener("scroll", handleScroll, true);
130
+ window.removeEventListener("keydown", handleKeyDown);
131
+ };
132
+ }, [containerRef, handleMouseUp, hidePopover]);
133
+
134
+ if (!position) return null;
135
+
136
+ return (
137
+ <button
138
+ ref={buttonRef}
139
+ type="button"
140
+ className={cn(
141
+ "absolute z-50",
142
+ "flex items-center gap-1.5",
143
+ "rounded-lg border border-border bg-background shadow-lg",
144
+ "px-3 py-1.5 text-xs font-medium text-foreground",
145
+ "hover:bg-muted transition-colors",
146
+ "animate-in fade-in-0 zoom-in-95 duration-150",
147
+ )}
148
+ style={{ top: position.top, left: position.left }}
149
+ onMouseDown={(e) => e.preventDefault()}
150
+ onClick={handleAddToContext}
151
+ >
152
+ <TextSelect className="size-3.5 text-primary" />
153
+ Add to context
154
+ </button>
155
+ );
156
+ }