@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @iloveagents/foundry-web-ui
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 689d3e9: feat(sidebar): distinct file-drop affordance for nav containers
|
|
8
|
+
|
|
9
|
+
`useNavItemDnd` now exposes `isFileDragOver` separately from `isDragOver`
|
|
10
|
+
so consuming nav items can render a prominent file-drop visual (dashed
|
|
11
|
+
primary outline + soft primary background + Upload icon) when the user
|
|
12
|
+
is dragging native files over the container. Previously file drags
|
|
13
|
+
shared the same subtle ring as entity-move drags, so users couldn't
|
|
14
|
+
tell that a folder accepted external files. Applies to all five nav-
|
|
15
|
+
item shapes (action-row leaf, button leaf, `NestedFolderItem`,
|
|
16
|
+
`CollapsibleNavItem` with children/actions, `NavLink` fallthrough) and
|
|
17
|
+
updates `ContainerDropZone` for visual parity.
|
|
18
|
+
|
|
19
|
+
Also fixes a related regression: the capture-phase
|
|
20
|
+
`onDragEnterCapture` / `onDragOverCapture` handlers were calling
|
|
21
|
+
`e.stopPropagation()`, which short-circuits React's synthetic dispatch
|
|
22
|
+
and prevented the bubble-phase `onDragEnter` (where state actually
|
|
23
|
+
mutates) from running. Removed — `preventDefault()` alone is enough to
|
|
24
|
+
mark the element as droppable.
|
|
25
|
+
|
|
26
|
+
- Updated dependencies [689d3e9]
|
|
27
|
+
- @iloveagents/foundry-agent@0.1.1
|
|
28
|
+
- @iloveagents/foundry-web-primitives@0.1.1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iloveagents/foundry-web-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"lucide-react": ">=0.400.0",
|
|
24
24
|
"@azure/msal-browser": "^5.0.0",
|
|
25
25
|
"@azure/msal-react": "^5.0.0",
|
|
26
|
-
"@iloveagents/foundry-agent": "0.1.
|
|
27
|
-
"@iloveagents/foundry-web-primitives": "0.1.
|
|
26
|
+
"@iloveagents/foundry-agent": "0.1.1",
|
|
27
|
+
"@iloveagents/foundry-web-primitives": "0.1.1"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"@azure/msal-browser": {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for ``getNavigablePath`` — the function powering
|
|
3
|
+
* click-to-navigate on pinned context chips. Two shapes the framework
|
|
4
|
+
* supports: page (uses payload.path) and ref (uses sourcePage as the
|
|
5
|
+
* deep-link URL set by the source module).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { getNavigablePath } from "../context-bar.tsx";
|
|
10
|
+
import type { ContextItem } from "../../lib/app-store.ts";
|
|
11
|
+
|
|
12
|
+
function pageItem(path: string): ContextItem {
|
|
13
|
+
return {
|
|
14
|
+
id: "i-1",
|
|
15
|
+
type: "page",
|
|
16
|
+
label: "Workspace",
|
|
17
|
+
payload: { kind: "page", path, label: "Workspace" },
|
|
18
|
+
sourcePage: path,
|
|
19
|
+
persistence: "persistent",
|
|
20
|
+
createdAt: 0,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function refItem(refId: string, sourcePage: string): ContextItem {
|
|
25
|
+
return {
|
|
26
|
+
id: "i-2",
|
|
27
|
+
type: "ref",
|
|
28
|
+
label: "Field · cell",
|
|
29
|
+
payload: { kind: "ref", refType: "block", refId },
|
|
30
|
+
sourcePage,
|
|
31
|
+
persistence: "persistent",
|
|
32
|
+
createdAt: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("getNavigablePath", () => {
|
|
37
|
+
it("returns the page payload path for page items", () => {
|
|
38
|
+
expect(getNavigablePath(pageItem("/spaces/abc"))).toBe("/spaces/abc");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns the ref's sourcePage URL (incl. hash) for ref items", () => {
|
|
42
|
+
// The source module places the deep-link in sourcePage when it
|
|
43
|
+
// creates the ref; the framework surfaces it without knowing
|
|
44
|
+
// anything about the ref shape.
|
|
45
|
+
expect(
|
|
46
|
+
getNavigablePath(refItem("abc:b1", "/spaces/abc#block-b1")),
|
|
47
|
+
).toBe("/spaces/abc#block-b1");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns null when a ref has no sourcePage", () => {
|
|
51
|
+
const item = refItem("abc:b1", "");
|
|
52
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns null for selection items (excerpts are not anchors)", () => {
|
|
56
|
+
const item: ContextItem = {
|
|
57
|
+
id: "i-3",
|
|
58
|
+
type: "selection",
|
|
59
|
+
label: "x",
|
|
60
|
+
payload: { kind: "text", text: "x" },
|
|
61
|
+
sourcePage: "/spaces/abc",
|
|
62
|
+
persistence: "ephemeral",
|
|
63
|
+
createdAt: 0,
|
|
64
|
+
};
|
|
65
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns null for note items (use text payload — note shares it)", () => {
|
|
69
|
+
// ``ContextPayload`` is a closed union of ``text | page | ref``.
|
|
70
|
+
// Note items reuse the ``text`` payload (the ``type`` field is
|
|
71
|
+
// what distinguishes them from selections).
|
|
72
|
+
const item: ContextItem = {
|
|
73
|
+
id: "i-4",
|
|
74
|
+
type: "note",
|
|
75
|
+
label: "n",
|
|
76
|
+
payload: { kind: "text", text: "n" },
|
|
77
|
+
sourcePage: "/spaces/abc",
|
|
78
|
+
persistence: "ephemeral",
|
|
79
|
+
createdAt: 0,
|
|
80
|
+
};
|
|
81
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns null for ref items whose sourcePage isn't a route-like path", () => {
|
|
85
|
+
// Some callers populate ``sourcePage`` with a free-form
|
|
86
|
+
// breadcrumb label rather than a URL. Guard against navigate()
|
|
87
|
+
// pushing a relative/invalid route.
|
|
88
|
+
const item: ContextItem = {
|
|
89
|
+
id: "i-5",
|
|
90
|
+
type: "ref",
|
|
91
|
+
label: "Field · x",
|
|
92
|
+
payload: { kind: "ref", refType: "block", refId: "abc:b1" },
|
|
93
|
+
sourcePage: "Workspace breadcrumb",
|
|
94
|
+
persistence: "persistent",
|
|
95
|
+
createdAt: 0,
|
|
96
|
+
};
|
|
97
|
+
expect(getNavigablePath(item)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts hash-only sourcePages (same-page deep-link)", () => {
|
|
101
|
+
const item: ContextItem = {
|
|
102
|
+
id: "i-6",
|
|
103
|
+
type: "ref",
|
|
104
|
+
label: "Field · x",
|
|
105
|
+
payload: { kind: "ref", refType: "block", refId: "abc:b1" },
|
|
106
|
+
sourcePage: "#block-b1",
|
|
107
|
+
persistence: "persistent",
|
|
108
|
+
createdAt: 0,
|
|
109
|
+
};
|
|
110
|
+
expect(getNavigablePath(item)).toBe("#block-b1");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ErrorPrimitive,
|
|
9
9
|
useAuiState,
|
|
10
10
|
useComposerRuntime,
|
|
11
|
+
useThreadViewport,
|
|
11
12
|
} from "@assistant-ui/react";
|
|
12
13
|
import {
|
|
13
14
|
ArrowUp,
|
|
@@ -31,6 +32,7 @@ import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
|
31
32
|
import { userAttachmentComponents, composerAttachmentComponents } from "./chat-attachments.tsx";
|
|
32
33
|
import { SentContextBadges, ComposerContextBadges } from "./context-badges.tsx";
|
|
33
34
|
import { ContextPins } from "./context-bar.tsx";
|
|
35
|
+
import { ComposerSubmitBridge } from "./composer-submit-bridge.tsx";
|
|
34
36
|
import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
|
|
35
37
|
import { MarkdownText } from "./markdown-text.tsx";
|
|
36
38
|
import { ToolFallback } from "./tool-fallback.tsx";
|
|
@@ -106,7 +108,9 @@ const AssistantMessage: FC = () => (
|
|
|
106
108
|
<MessageError />
|
|
107
109
|
|
|
108
110
|
<div className="flex items-center gap-2 mt-1">
|
|
109
|
-
<
|
|
111
|
+
<div className="flex h-8 w-[4.25rem] items-center">
|
|
112
|
+
<AssistantActionBar />
|
|
113
|
+
</div>
|
|
110
114
|
<BranchPicker />
|
|
111
115
|
</div>
|
|
112
116
|
</div>
|
|
@@ -114,7 +118,14 @@ const AssistantMessage: FC = () => (
|
|
|
114
118
|
);
|
|
115
119
|
|
|
116
120
|
const AssistantActionBar: FC = () => (
|
|
117
|
-
<ActionBarPrimitive.Root
|
|
121
|
+
<ActionBarPrimitive.Root
|
|
122
|
+
hideWhenRunning
|
|
123
|
+
className={cn(
|
|
124
|
+
"pointer-events-none flex items-center gap-1 -ml-2 opacity-0 transition-opacity",
|
|
125
|
+
"group-hover:pointer-events-auto group-hover:opacity-100",
|
|
126
|
+
"group-focus-within:pointer-events-auto group-focus-within:opacity-100",
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
118
129
|
<ActionBarPrimitive.Copy asChild>
|
|
119
130
|
<TooltipIconButton tooltip="Copy" size="icon">
|
|
120
131
|
<MessagePrimitive.If copied={false}>
|
|
@@ -219,17 +230,23 @@ const STARTER_SUGGESTIONS = [
|
|
|
219
230
|
"Draft a concise release note from bullet points",
|
|
220
231
|
];
|
|
221
232
|
|
|
222
|
-
const ScrollToBottomButton: FC = () =>
|
|
223
|
-
|
|
233
|
+
const ScrollToBottomButton: FC = () => {
|
|
234
|
+
const isAtBottom = useThreadViewport((s) => s.isAtBottom);
|
|
235
|
+
const scrollToBottom = useThreadViewport((s) => s.scrollToBottom);
|
|
236
|
+
|
|
237
|
+
if (isAtBottom) return null;
|
|
238
|
+
|
|
239
|
+
return (
|
|
224
240
|
<Button
|
|
225
241
|
variant="outline"
|
|
226
242
|
size="icon"
|
|
227
243
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md"
|
|
244
|
+
onClick={() => scrollToBottom({ behavior: "smooth" })}
|
|
228
245
|
>
|
|
229
246
|
<ChevronDown className="size-4" />
|
|
230
247
|
</Button>
|
|
231
|
-
|
|
232
|
-
|
|
248
|
+
);
|
|
249
|
+
};
|
|
233
250
|
|
|
234
251
|
const DropZone: FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
235
252
|
const [isDragging, setIsDragging] = useState(false);
|
|
@@ -366,8 +383,10 @@ export function ChatContent() {
|
|
|
366
383
|
</ThreadPrimitive.If>
|
|
367
384
|
</div>
|
|
368
385
|
</ThreadPrimitive.If>
|
|
386
|
+
<ThreadPrimitive.If empty={false}>
|
|
387
|
+
<ScrollToBottomButton />
|
|
388
|
+
</ThreadPrimitive.If>
|
|
369
389
|
</ThreadPrimitive.Viewport>
|
|
370
|
-
<ScrollToBottomButton />
|
|
371
390
|
</div>
|
|
372
391
|
|
|
373
392
|
<div className="shrink-0 bg-background">
|
|
@@ -389,6 +408,7 @@ export function ChatContent() {
|
|
|
389
408
|
"transition-all duration-300",
|
|
390
409
|
)}
|
|
391
410
|
>
|
|
411
|
+
<ComposerSubmitBridge />
|
|
392
412
|
<ComposerContextBadges />
|
|
393
413
|
<ComposerPrimitive.Attachments components={composerAttachmentComponents} />
|
|
394
414
|
<div className="flex items-center gap-2">
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
ComposerPrimitive,
|
|
5
5
|
MessagePrimitive,
|
|
6
6
|
ActionBarPrimitive,
|
|
7
|
+
useAuiState,
|
|
8
|
+
useThreadViewport,
|
|
7
9
|
} from "@assistant-ui/react";
|
|
8
10
|
import {
|
|
9
11
|
MessageSquare,
|
|
@@ -20,7 +22,8 @@ import { cn } from "@iloveagents/foundry-web-primitives";
|
|
|
20
22
|
import { Button } from "@iloveagents/foundry-web-primitives";
|
|
21
23
|
import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
22
24
|
import { ContextPins } from "./context-bar.tsx";
|
|
23
|
-
import { ComposerContextBadges } from "./context-badges.tsx";
|
|
25
|
+
import { ComposerContextBadges, SentContextBadges } from "./context-badges.tsx";
|
|
26
|
+
import { ComposerSubmitBridge } from "./composer-submit-bridge.tsx";
|
|
24
27
|
import { MarkdownText } from "./markdown-text.tsx";
|
|
25
28
|
import { LoadingIndicator } from "./loading-indicator.tsx";
|
|
26
29
|
import { useAppStore } from "../lib/app-store.ts";
|
|
@@ -39,8 +42,15 @@ function BubbleAssistantMessage() {
|
|
|
39
42
|
}}
|
|
40
43
|
/>
|
|
41
44
|
</div>
|
|
42
|
-
<div className="flex items-center
|
|
43
|
-
<ActionBarPrimitive.Root
|
|
45
|
+
<div className="flex h-7 w-8 items-center mt-1">
|
|
46
|
+
<ActionBarPrimitive.Root
|
|
47
|
+
hideWhenRunning
|
|
48
|
+
className={cn(
|
|
49
|
+
"pointer-events-none flex items-center gap-1 opacity-0 transition-opacity",
|
|
50
|
+
"group-hover:pointer-events-auto group-hover:opacity-100",
|
|
51
|
+
"group-focus-within:pointer-events-auto group-focus-within:opacity-100",
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
44
54
|
<ActionBarPrimitive.Copy asChild>
|
|
45
55
|
<TooltipIconButton tooltip="Copy" size="icon" className="size-7">
|
|
46
56
|
<MessagePrimitive.If copied={false}>
|
|
@@ -58,9 +68,12 @@ function BubbleAssistantMessage() {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function BubbleUserMessage() {
|
|
71
|
+
const messageId = useAuiState((s) => s.message.id);
|
|
72
|
+
|
|
61
73
|
return (
|
|
62
74
|
<MessagePrimitive.Root className="w-full mb-2">
|
|
63
|
-
<div className="flex
|
|
75
|
+
<div className="flex flex-col items-end gap-1">
|
|
76
|
+
<SentContextBadges messageId={messageId} />
|
|
64
77
|
<div className="max-w-[85%] bg-muted rounded-2xl px-3 py-2 text-sm text-foreground">
|
|
65
78
|
<MessagePrimitive.Content />
|
|
66
79
|
</div>
|
|
@@ -69,6 +82,24 @@ function BubbleUserMessage() {
|
|
|
69
82
|
);
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
function BubbleScrollToBottomButton() {
|
|
86
|
+
const isAtBottom = useThreadViewport((s) => s.isAtBottom);
|
|
87
|
+
const scrollToBottom = useThreadViewport((s) => s.scrollToBottom);
|
|
88
|
+
|
|
89
|
+
if (isAtBottom) return null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Button
|
|
93
|
+
variant="outline"
|
|
94
|
+
size="icon"
|
|
95
|
+
className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7"
|
|
96
|
+
onClick={() => scrollToBottom({ behavior: "smooth" })}
|
|
97
|
+
>
|
|
98
|
+
<ChevronDown className="size-3" />
|
|
99
|
+
</Button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
export function ChatBubble() {
|
|
73
104
|
const isOpen = useChatBubbleStore((s) => s.isOpen);
|
|
74
105
|
const isExpanded = useChatBubbleStore((s) => s.isExpanded);
|
|
@@ -252,19 +283,11 @@ export function ChatBubble() {
|
|
|
252
283
|
</ThreadPrimitive.If>
|
|
253
284
|
</div>
|
|
254
285
|
</ThreadPrimitive.If>
|
|
255
|
-
</ThreadPrimitive.Viewport>
|
|
256
286
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
size="icon"
|
|
262
|
-
className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7"
|
|
263
|
-
>
|
|
264
|
-
<ChevronDown className="size-3" />
|
|
265
|
-
</Button>
|
|
266
|
-
</ThreadPrimitive.ScrollToBottom>
|
|
267
|
-
</ThreadPrimitive.If>
|
|
287
|
+
<ThreadPrimitive.If empty={false}>
|
|
288
|
+
<BubbleScrollToBottomButton />
|
|
289
|
+
</ThreadPrimitive.If>
|
|
290
|
+
</ThreadPrimitive.Viewport>
|
|
268
291
|
</div>
|
|
269
292
|
|
|
270
293
|
{/* Composer — same pattern as full chat: clip + pin + input + send */}
|
|
@@ -279,6 +302,7 @@ export function ChatBubble() {
|
|
|
279
302
|
"transition-all duration-200",
|
|
280
303
|
)}
|
|
281
304
|
>
|
|
305
|
+
<ComposerSubmitBridge />
|
|
282
306
|
<ComposerContextBadges />
|
|
283
307
|
<div className="flex items-center gap-2">
|
|
284
308
|
<div className="flex items-center -space-x-1 shrink-0">
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useComposerRuntime } from "@assistant-ui/react";
|
|
3
|
+
import { useComposerSubmitStore } from "../lib/composer-submit-store.ts";
|
|
4
|
+
|
|
5
|
+
export function ComposerSubmitBridge() {
|
|
6
|
+
const composerRuntime = useComposerRuntime();
|
|
7
|
+
const pending = useComposerSubmitStore((s) => s.pending);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!pending) return;
|
|
11
|
+
|
|
12
|
+
composerRuntime.setText(pending.text);
|
|
13
|
+
composerRuntime.send({ startRun: true });
|
|
14
|
+
useComposerSubmitStore.getState().consume(pending.id);
|
|
15
|
+
}, [composerRuntime, pending]);
|
|
16
|
+
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -78,19 +78,31 @@ const ComposerBadge: FC<{ item: ContextItem }> = ({ item }) => {
|
|
|
78
78
|
);
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
/** Read-only badge on a sent user message
|
|
82
|
-
const SentBadge: FC<{ item: ContextItem }> = ({ item }) =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
/** Read-only badge on a sent user message. Mirrors composer context chips. */
|
|
82
|
+
const SentBadge: FC<{ item: ContextItem }> = ({ item }) => {
|
|
83
|
+
const isEphemeral = item.persistence === "ephemeral";
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Tooltip>
|
|
87
|
+
<TooltipTrigger asChild>
|
|
88
|
+
<div
|
|
89
|
+
className={cn(
|
|
90
|
+
"inline-flex items-center gap-1.5 max-w-70",
|
|
91
|
+
"rounded-lg bg-primary/5",
|
|
92
|
+
"px-2 py-1",
|
|
93
|
+
isEphemeral ? "border border-dashed border-primary/40" : "border border-primary/20",
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
<ContextIcon type={item.type} className="size-3 text-primary shrink-0" />
|
|
97
|
+
<span className="text-[11px] leading-tight text-foreground truncate">{item.label}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</TooltipTrigger>
|
|
100
|
+
<TooltipContent side="top" className="whitespace-pre-line">
|
|
101
|
+
{getBadgeTooltip(item)}
|
|
102
|
+
</TooltipContent>
|
|
103
|
+
</Tooltip>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
94
106
|
|
|
95
107
|
/** Renders ephemeral context badges in the composer (persistent items shown in ContextPins). */
|
|
96
108
|
export const ComposerContextBadges: FC = () => {
|
|
@@ -37,9 +37,45 @@ interface ContextPinsProps {
|
|
|
37
37
|
flyoutDirection?: "up" | "down";
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/** Resolve the URL a context item navigates to when clicked.
|
|
41
|
+
*
|
|
42
|
+
* Two shapes the framework knows about:
|
|
43
|
+
*
|
|
44
|
+
* 1. **Page** (``type: "page"``) — the canonical case the OSS shell
|
|
45
|
+
* creates for "Pin this page". The payload's ``path`` IS the
|
|
46
|
+
* navigation target.
|
|
47
|
+
* 2. **Ref** (``type: "ref"``) — created by feature modules when the
|
|
48
|
+
* user pins a structured reference (Spaces blocks, code symbols,
|
|
49
|
+
* DB rows, …). The framework stays agnostic about the ref shape;
|
|
50
|
+
* the source module places the canonical deep-link URL — including
|
|
51
|
+
* any anchor hash — in the chip's ``sourcePage`` field, which is
|
|
52
|
+
* already the conventional "where the user pinned from" breadcrumb.
|
|
53
|
+
* Surfacing it here makes every feature module's ref chips clickable
|
|
54
|
+
* without an extension-point registry, and without the framework
|
|
55
|
+
* knowing module-specific URL shapes.
|
|
56
|
+
*
|
|
57
|
+
* Other item types (``selection``, ``note``) intentionally stay
|
|
58
|
+
* non-clickable — they are excerpts / annotations, not anchors.
|
|
59
|
+
*
|
|
60
|
+
* @internal Exported for unit tests; not part of the public API.
|
|
61
|
+
*/
|
|
62
|
+
export function getNavigablePath(item: ContextItem): string | null {
|
|
42
63
|
if (item.type === "page" && item.payload.kind === "page") return item.payload.path;
|
|
64
|
+
if (
|
|
65
|
+
item.type === "ref" &&
|
|
66
|
+
item.payload.kind === "ref" &&
|
|
67
|
+
typeof item.sourcePage === "string" &&
|
|
68
|
+
// Guard: ``sourcePage`` is also used as a free-form breadcrumb
|
|
69
|
+
// label by some callers (e.g. "page-label" instead of "/page").
|
|
70
|
+
// Treat it as a navigation target only when it looks like an
|
|
71
|
+
// in-app path. Hash-only links (``#anchor``) are also valid —
|
|
72
|
+
// they keep the user on the current entity but scroll to the
|
|
73
|
+
// pinned atom (e.g. Spaces ``#block-{id}`` deep-link from the
|
|
74
|
+
// current page).
|
|
75
|
+
(item.sourcePage.startsWith("/") || item.sourcePage.startsWith("#"))
|
|
76
|
+
) {
|
|
77
|
+
return item.sourcePage;
|
|
78
|
+
}
|
|
43
79
|
return null;
|
|
44
80
|
}
|
|
45
81
|
|