@iloveagents/foundry-web-ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # @iloveagents/foundry-web-ui
2
+
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [30a4346]
8
+ - @iloveagents/foundry-agent@0.1.2
9
+ - @iloveagents/foundry-web-primitives@0.1.2
10
+
11
+ ## 0.1.1
12
+
13
+ ### Patch Changes
14
+
15
+ - 689d3e9: feat(sidebar): distinct file-drop affordance for nav containers
16
+
17
+ `useNavItemDnd` now exposes `isFileDragOver` separately from `isDragOver`
18
+ so consuming nav items can render a prominent file-drop visual (dashed
19
+ primary outline + soft primary background + Upload icon) when the user
20
+ is dragging native files over the container. Previously file drags
21
+ shared the same subtle ring as entity-move drags, so users couldn't
22
+ tell that a folder accepted external files. Applies to all five nav-
23
+ item shapes (action-row leaf, button leaf, `NestedFolderItem`,
24
+ `CollapsibleNavItem` with children/actions, `NavLink` fallthrough) and
25
+ updates `ContainerDropZone` for visual parity.
26
+
27
+ Also fixes a related regression: the capture-phase
28
+ `onDragEnterCapture` / `onDragOverCapture` handlers were calling
29
+ `e.stopPropagation()`, which short-circuits React's synthetic dispatch
30
+ and prevented the bubble-phase `onDragEnter` (where state actually
31
+ mutates) from running. Removed — `preventDefault()` alone is enough to
32
+ mark the element as droppable.
33
+
34
+ - Updated dependencies [689d3e9]
35
+ - @iloveagents/foundry-agent@0.1.1
36
+ - @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.0",
3
+ "version": "0.1.2",
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.0",
27
- "@iloveagents/foundry-web-primitives": "0.1.0"
26
+ "@iloveagents/foundry-agent": "0.1.2",
27
+ "@iloveagents/foundry-web-primitives": "0.1.2"
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
+ });
@@ -14,7 +14,7 @@ export function AppBrand({
14
14
  labelClassName?: string;
15
15
  }) {
16
16
  const runtime = useThemeRuntime();
17
- const brandName = runtime.branding.appName || "LastSpace.ai";
17
+ const brandName = runtime.branding.appName || "eomi";
18
18
  const logoUrl =
19
19
  runtime.mode === "dark"
20
20
  ? runtime.branding.darkLogoUrl || runtime.branding.logoUrl
@@ -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
- <AssistantActionBar />
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 hideWhenRunning className="flex items-center gap-1 -ml-2">
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}>
@@ -212,24 +223,34 @@ const ComposerActionButton: FC = () => {
212
223
  );
213
224
  };
214
225
 
215
- /** Starter prompts shown on empty state. Customize these for your agent's capabilities. */
216
- const STARTER_SUGGESTIONS = [
217
- "Show me a short document about Python",
218
- "Summarize what you can help me with here",
219
- "Draft a concise release note from bullet points",
226
+ /**
227
+ * Default starter prompts shown on empty state. Override per-app by passing
228
+ * a `starterSuggestions` prop to `<ChatContent>`. An empty array (`[]`)
229
+ * suppresses the suggestion buttons entirely.
230
+ */
231
+ export const DEFAULT_STARTER_SUGGESTIONS = [
232
+ "Create a new Contract Space",
233
+ "List all contracts I have access to",
234
+ "Show me what I can do here",
220
235
  ];
221
236
 
222
- const ScrollToBottomButton: FC = () => (
223
- <ThreadPrimitive.ScrollToBottom asChild>
237
+ const ScrollToBottomButton: FC = () => {
238
+ const isAtBottom = useThreadViewport((s) => s.isAtBottom);
239
+ const scrollToBottom = useThreadViewport((s) => s.scrollToBottom);
240
+
241
+ if (isAtBottom) return null;
242
+
243
+ return (
224
244
  <Button
225
245
  variant="outline"
226
246
  size="icon"
227
247
  className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md"
248
+ onClick={() => scrollToBottom({ behavior: "smooth" })}
228
249
  >
229
250
  <ChevronDown className="size-4" />
230
251
  </Button>
231
- </ThreadPrimitive.ScrollToBottom>
232
- );
252
+ );
253
+ };
233
254
 
234
255
  const DropZone: FC<{ children: React.ReactNode }> = ({ children }) => {
235
256
  const [isDragging, setIsDragging] = useState(false);
@@ -289,11 +310,23 @@ const DropZone: FC<{ children: React.ReactNode }> = ({ children }) => {
289
310
  );
290
311
  };
291
312
 
313
+ export interface ChatContentProps {
314
+ /**
315
+ * Starter prompts shown when the thread is empty. Defaults to
316
+ * `DEFAULT_STARTER_SUGGESTIONS`. Pass an empty array to hide the
317
+ * suggestion buttons entirely. Customer apps (e.g. ANDRITZ Contracts)
318
+ * can pass a workspace-aware list derived from app context.
319
+ */
320
+ starterSuggestions?: string[];
321
+ }
322
+
292
323
  /**
293
324
  * Reusable chat content — used by ChatPage and ChatBubble.
294
325
  * Renders inside a ThreadPrimitive.Root context.
295
326
  */
296
- export function ChatContent() {
327
+ export function ChatContent({
328
+ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS,
329
+ }: ChatContentProps = {}) {
297
330
  const isExpanded = useChatBubbleStore((s) => s.isExpanded);
298
331
  const collapseChat = useChatBubbleStore((s) => s.collapse);
299
332
  const showPagePanel = useChatBubbleStore((s) => s.showPagePanel);
@@ -340,15 +373,17 @@ export function ChatContent() {
340
373
  <h1 className="text-[2rem] font-normal text-foreground text-center mb-6">
341
374
  How can I help you today?
342
375
  </h1>
343
- <div className="flex flex-wrap justify-center gap-2 max-w-lg">
344
- {STARTER_SUGGESTIONS.map((prompt) => (
345
- <ThreadPrimitive.Suggestion key={prompt} prompt={prompt} send asChild>
346
- <Button variant="outline" className="text-sm">
347
- {prompt}
348
- </Button>
349
- </ThreadPrimitive.Suggestion>
350
- ))}
351
- </div>
376
+ {starterSuggestions.length > 0 && (
377
+ <div className="flex flex-wrap justify-center gap-2 max-w-lg">
378
+ {starterSuggestions.map((prompt) => (
379
+ <ThreadPrimitive.Suggestion key={prompt} prompt={prompt} send asChild>
380
+ <Button variant="outline" className="text-sm">
381
+ {prompt}
382
+ </Button>
383
+ </ThreadPrimitive.Suggestion>
384
+ ))}
385
+ </div>
386
+ )}
352
387
  </div>
353
388
  </ThreadPrimitive.Empty>
354
389
 
@@ -367,7 +402,9 @@ export function ChatContent() {
367
402
  </div>
368
403
  </ThreadPrimitive.If>
369
404
  </ThreadPrimitive.Viewport>
370
- <ScrollToBottomButton />
405
+ <ThreadPrimitive.If empty={false}>
406
+ <ScrollToBottomButton />
407
+ </ThreadPrimitive.If>
371
408
  </div>
372
409
 
373
410
  <div className="shrink-0 bg-background">
@@ -389,6 +426,7 @@ export function ChatContent() {
389
426
  "transition-all duration-300",
390
427
  )}
391
428
  >
429
+ <ComposerSubmitBridge />
392
430
  <ComposerContextBadges />
393
431
  <ComposerPrimitive.Attachments components={composerAttachmentComponents} />
394
432
  <div className="flex items-center gap-2">
@@ -4,11 +4,14 @@ 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,
10
12
  X,
11
13
  Maximize2,
14
+ SquarePen,
12
15
  ArrowUp,
13
16
  Square,
14
17
  ChevronDown,
@@ -20,11 +23,13 @@ import { cn } from "@iloveagents/foundry-web-primitives";
20
23
  import { Button } from "@iloveagents/foundry-web-primitives";
21
24
  import { TooltipIconButton } from "./tooltip-icon-button.tsx";
22
25
  import { ContextPins } from "./context-bar.tsx";
23
- import { ComposerContextBadges } from "./context-badges.tsx";
26
+ import { ComposerContextBadges, SentContextBadges } from "./context-badges.tsx";
27
+ import { ComposerSubmitBridge } from "./composer-submit-bridge.tsx";
24
28
  import { MarkdownText } from "./markdown-text.tsx";
25
29
  import { LoadingIndicator } from "./loading-indicator.tsx";
26
30
  import { useAppStore } from "../lib/app-store.ts";
27
31
  import { useChatBubbleStore } from "../lib/chat-bubble-store.ts";
32
+ import { useNewConversation } from "../lib/use-new-conversation.ts";
28
33
  import { RAIL_WIDTH, useSidebarStore } from "../lib/sidebar-store.ts";
29
34
 
30
35
  const CHAT_BUBBLE_INSET = 8;
@@ -39,8 +44,15 @@ function BubbleAssistantMessage() {
39
44
  }}
40
45
  />
41
46
  </div>
42
- <div className="flex items-center gap-1 mt-1">
43
- <ActionBarPrimitive.Root hideWhenRunning className="flex items-center gap-1">
47
+ <div className="flex h-7 w-8 items-center mt-1">
48
+ <ActionBarPrimitive.Root
49
+ hideWhenRunning
50
+ className={cn(
51
+ "pointer-events-none flex items-center gap-1 opacity-0 transition-opacity",
52
+ "group-hover:pointer-events-auto group-hover:opacity-100",
53
+ "group-focus-within:pointer-events-auto group-focus-within:opacity-100",
54
+ )}
55
+ >
44
56
  <ActionBarPrimitive.Copy asChild>
45
57
  <TooltipIconButton tooltip="Copy" size="icon" className="size-7">
46
58
  <MessagePrimitive.If copied={false}>
@@ -58,9 +70,12 @@ function BubbleAssistantMessage() {
58
70
  }
59
71
 
60
72
  function BubbleUserMessage() {
73
+ const messageId = useAuiState((s) => s.message.id);
74
+
61
75
  return (
62
76
  <MessagePrimitive.Root className="w-full mb-2">
63
- <div className="flex justify-end">
77
+ <div className="flex flex-col items-end gap-1">
78
+ <SentContextBadges messageId={messageId} />
64
79
  <div className="max-w-[85%] bg-muted rounded-2xl px-3 py-2 text-sm text-foreground">
65
80
  <MessagePrimitive.Content />
66
81
  </div>
@@ -69,12 +84,31 @@ function BubbleUserMessage() {
69
84
  );
70
85
  }
71
86
 
87
+ function BubbleScrollToBottomButton() {
88
+ const isAtBottom = useThreadViewport((s) => s.isAtBottom);
89
+ const scrollToBottom = useThreadViewport((s) => s.scrollToBottom);
90
+
91
+ if (isAtBottom) return null;
92
+
93
+ return (
94
+ <Button
95
+ variant="outline"
96
+ size="icon"
97
+ className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 rounded-full shadow-md size-7"
98
+ onClick={() => scrollToBottom({ behavior: "smooth" })}
99
+ >
100
+ <ChevronDown className="size-3" />
101
+ </Button>
102
+ );
103
+ }
104
+
72
105
  export function ChatBubble() {
73
106
  const isOpen = useChatBubbleStore((s) => s.isOpen);
74
107
  const isExpanded = useChatBubbleStore((s) => s.isExpanded);
75
108
  const openBubble = useChatBubbleStore((s) => s.open);
76
109
  const closeBubble = useChatBubbleStore((s) => s.close);
77
110
  const expandChat = useChatBubbleStore((s) => s.expand);
111
+ const startNewThread = useNewConversation({ navigateToChat: false, preserveNavigation: true });
78
112
  const isSidebarOpen = useSidebarStore((s) => s.isOpen);
79
113
  const sidebarWidth = useSidebarStore((s) => s.width);
80
114
  const pageLabel = useAppStore((s) => s.navContext.label);
@@ -208,6 +242,14 @@ export function ChatBubble() {
208
242
  </span>
209
243
  </div>
210
244
  <div className="flex items-center gap-0.5">
245
+ <TooltipIconButton
246
+ tooltip="New Thread"
247
+ size="icon"
248
+ className="size-7"
249
+ onClick={startNewThread}
250
+ >
251
+ <SquarePen className="size-3.5" />
252
+ </TooltipIconButton>
211
253
  <TooltipIconButton
212
254
  tooltip="Expand chat"
213
255
  size="icon"
@@ -253,17 +295,8 @@ export function ChatBubble() {
253
295
  </div>
254
296
  </ThreadPrimitive.If>
255
297
  </ThreadPrimitive.Viewport>
256
-
257
298
  <ThreadPrimitive.If empty={false}>
258
- <ThreadPrimitive.ScrollToBottom asChild>
259
- <Button
260
- variant="outline"
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>
299
+ <BubbleScrollToBottomButton />
267
300
  </ThreadPrimitive.If>
268
301
  </div>
269
302
 
@@ -279,6 +312,7 @@ export function ChatBubble() {
279
312
  "transition-all duration-200",
280
313
  )}
281
314
  >
315
+ <ComposerSubmitBridge />
282
316
  <ComposerContextBadges />
283
317
  <div className="flex items-center gap-2">
284
318
  <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 (no remove button, compact). */
82
- const SentBadge: FC<{ item: ContextItem }> = ({ item }) => (
83
- <div
84
- className={cn(
85
- "inline-flex items-center gap-1 max-w-60",
86
- "rounded-md border border-primary/20 bg-primary/5",
87
- "px-2 py-1",
88
- )}
89
- >
90
- <ContextIcon type={item.type} className="size-3 text-primary shrink-0" />
91
- <span className="text-[11px] leading-tight text-foreground truncate">{item.label}</span>
92
- </div>
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
- /** Get navigable path from a context item (page items only). */
41
- function getNavigablePath(item: ContextItem): string | null {
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