@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 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.0",
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.0",
27
- "@iloveagents/foundry-web-primitives": "0.1.0"
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
- <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}>
@@ -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
- <ThreadPrimitive.ScrollToBottom asChild>
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
- </ThreadPrimitive.ScrollToBottom>
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 gap-1 mt-1">
43
- <ActionBarPrimitive.Root hideWhenRunning className="flex items-center gap-1">
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 justify-end">
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
- <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>
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 (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