@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.
- package/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/__tests__/no-spaces-imports.test.ts +59 -0
- package/src/__tests__/open-core-guards.test.ts +307 -0
- package/src/__tests__/theme-runtime.test.ts +81 -0
- package/src/__tests__/tool-fallback.test.tsx +109 -0
- package/src/components/ag-ui-runtime-provider.tsx +59 -0
- package/src/components/app-brand.tsx +38 -0
- package/src/components/assistant-chat.tsx +423 -0
- package/src/components/chat-attachments.tsx +68 -0
- package/src/components/chat-bubble.tsx +339 -0
- package/src/components/chat-header.tsx +44 -0
- package/src/components/client-tool-executor.tsx +230 -0
- package/src/components/collection-empty-state.tsx +37 -0
- package/src/components/collection-filter-bar.tsx +168 -0
- package/src/components/collection-search-input.tsx +41 -0
- package/src/components/collection-skeleton.tsx +55 -0
- package/src/components/collection-sort-menu.tsx +62 -0
- package/src/components/collection-surface.tsx +46 -0
- package/src/components/collection-toolbar.tsx +33 -0
- package/src/components/collection-view-toggle.tsx +61 -0
- package/src/components/confirm-dialog.tsx +49 -0
- package/src/components/confirmation-card.tsx +32 -0
- package/src/components/context-badges.tsx +124 -0
- package/src/components/context-bar.tsx +202 -0
- package/src/components/form-dialog.tsx +56 -0
- package/src/components/global-selection-popover.tsx +135 -0
- package/src/components/infinite-scroll-sentinel.tsx +48 -0
- package/src/components/json-viewer.tsx +232 -0
- package/src/components/loading-indicator.tsx +40 -0
- package/src/components/markdown-text.tsx +379 -0
- package/src/components/name-dialog.tsx +87 -0
- package/src/components/selection-popover.tsx +156 -0
- package/src/components/show-document-tool-ui.tsx +90 -0
- package/src/components/sidebar.test.tsx +182 -0
- package/src/components/sidebar.tsx +1174 -0
- package/src/components/surface-card.tsx +45 -0
- package/src/components/theme-runtime-provider.tsx +93 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/tool-call-card.tsx +126 -0
- package/src/components/tool-fallback.tsx +169 -0
- package/src/components/tool-panel-layout.tsx +36 -0
- package/src/components/tool-panel.tsx +233 -0
- package/src/components/tooltip-icon-button.tsx +28 -0
- package/src/components/user-menu.tsx +60 -0
- package/src/index.ts +170 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
- package/src/lib/__tests__/app-store.test.ts +330 -0
- package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
- package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
- package/src/lib/__tests__/client-tools.test.ts +124 -0
- package/src/lib/__tests__/dev-store.test.ts +78 -0
- package/src/lib/__tests__/nav-config.test.ts +135 -0
- package/src/lib/__tests__/nav-dnd.test.ts +24 -0
- package/src/lib/__tests__/nav-store.test.ts +59 -0
- package/src/lib/__tests__/sidebar-store.test.ts +144 -0
- package/src/lib/__tests__/theme-store.test.ts +83 -0
- package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
- package/src/lib/__tests__/use-page-context.test.ts +58 -0
- package/src/lib/ag-ui-adapter.ts +362 -0
- package/src/lib/app-store.ts +294 -0
- package/src/lib/attachment-adapter.ts +92 -0
- package/src/lib/auth-provider.tsx +144 -0
- package/src/lib/chat-bubble-store.ts +46 -0
- package/src/lib/client-tools.ts +293 -0
- package/src/lib/dev-store.ts +69 -0
- package/src/lib/nav-config.ts +292 -0
- package/src/lib/nav-dnd.ts +14 -0
- package/src/lib/nav-store.ts +76 -0
- package/src/lib/sidebar-store.ts +113 -0
- package/src/lib/theme-runtime.ts +660 -0
- package/src/lib/theme-store.ts +67 -0
- package/src/lib/tool-panel-store.ts +121 -0
- package/src/lib/use-new-conversation.test.tsx +124 -0
- package/src/lib/use-new-conversation.ts +33 -0
- package/src/lib/use-page-tools.ts +40 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +109 -0
- package/src/ui/dropdown-menu.tsx +71 -0
- package/src/ui/popover.tsx +34 -0
- package/src/ui/select.tsx +15 -0
- package/src/ui/tooltip.tsx +30 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +16 -0
- 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
|
+
}
|