@iloveagents/foundry-web-ui 0.1.0 → 0.1.1
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/CHANGELOG.md +28 -0
- package/package.json +3 -3
- package/src/components/__tests__/context-bar.test.ts +112 -0
- package/src/components/assistant-chat.tsx +27 -7
- package/src/components/chat-bubble.tsx +40 -16
- package/src/components/composer-submit-bridge.tsx +19 -0
- package/src/components/context-badges.tsx +25 -13
- package/src/components/context-bar.tsx +38 -2
- package/src/components/global-selection-popover.tsx +165 -34
- package/src/components/markdown-text-citations.test.tsx +108 -0
- package/src/components/markdown-text.tsx +155 -26
- package/src/components/selection-popover.tsx +3 -3
- package/src/components/sidebar.test.tsx +144 -0
- package/src/components/sidebar.tsx +207 -41
- package/src/components/tool-panel.tsx +3 -3
- package/src/components/user-menu.tsx +1 -1
- package/src/index.ts +9 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +42 -2
- package/src/lib/__tests__/app-store.test.ts +42 -0
- package/src/lib/__tests__/selection-context.test.ts +114 -0
- package/src/lib/ag-ui-adapter.ts +44 -2
- package/src/lib/app-store.ts +19 -1
- package/src/lib/composer-submit-store.ts +32 -0
- package/src/lib/nav-config.ts +5 -0
- package/src/lib/selection-context.ts +65 -0
- package/src/ui/dropdown-menu.tsx +5 -1
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Global
|
|
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
|
|
14
|
-
const
|
|
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
|
|
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 -
|
|
61
|
-
let left = rect.left + rect.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 -
|
|
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
|
-
|
|
119
|
+
window.getSelection()?.removeAllRanges();
|
|
120
|
+
hidePopover();
|
|
121
|
+
}, [selectedText, addResolvedContext, hidePopover]);
|
|
80
122
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
<
|
|
117
|
-
ref={
|
|
118
|
-
|
|
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
|
|
122
|
-
"rounded-
|
|
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
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
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 {
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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 =
|
|
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
|
|
153
|
+
Add to chat
|
|
154
154
|
</button>
|
|
155
155
|
);
|
|
156
156
|
}
|