@iloveagents/foundry-web-ui 0.5.0 → 0.7.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.
@@ -1,3 +1,26 @@
1
+ import type { ThemeBranding } from "../lib/theme-runtime.js";
2
+ export interface BrandLockup {
3
+ /** Image to render, or undefined to fall back to the generic glyph. */
4
+ markUrl?: string;
5
+ /** True when `markUrl` is a compact icon rather than a wordmark. */
6
+ isIcon: boolean;
7
+ name: string;
8
+ showName: boolean;
9
+ }
10
+ /**
11
+ * Decide what the brand lockup renders. Exported (underscore-prefixed) so the
12
+ * branching is unit-testable without a DOM renderer — same approach as
13
+ * ``tool-fallback.tsx``.
14
+ *
15
+ * The rule worth stating: whether the name is printed depends on the *shape*
16
+ * of the brand image. A wordmark already spells the brand out, so pairing it
17
+ * with `appName` stutters ("ANDRITZ ANDRITZ"). An icon says nothing on its
18
+ * own, so it needs the name to complete the lockup. Hence an icon wins over a
19
+ * wordmark when both are set — supplying an icon is an explicit request for
20
+ * "<mark> Product Name", which is what a tenant whose product name differs
21
+ * from their company name is asking for.
22
+ */
23
+ export declare function _resolveBrandLockup(branding: ThemeBranding, mode: "light" | "dark", showName: boolean): BrandLockup;
1
24
  export declare function AppBrand({ showName, className, iconClassName, labelClassName, }: {
2
25
  showName?: boolean;
3
26
  className?: string;
@@ -2,12 +2,42 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Layers3 } from "lucide-react";
3
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
4
  import { useThemeRuntime } from "./theme-runtime-provider.js";
5
+ /**
6
+ * Decide what the brand lockup renders. Exported (underscore-prefixed) so the
7
+ * branching is unit-testable without a DOM renderer — same approach as
8
+ * ``tool-fallback.tsx``.
9
+ *
10
+ * The rule worth stating: whether the name is printed depends on the *shape*
11
+ * of the brand image. A wordmark already spells the brand out, so pairing it
12
+ * with `appName` stutters ("ANDRITZ ANDRITZ"). An icon says nothing on its
13
+ * own, so it needs the name to complete the lockup. Hence an icon wins over a
14
+ * wordmark when both are set — supplying an icon is an explicit request for
15
+ * "<mark> Product Name", which is what a tenant whose product name differs
16
+ * from their company name is asking for.
17
+ */
18
+ export function _resolveBrandLockup(branding, mode, showName) {
19
+ const isDark = mode === "dark";
20
+ const pick = (dark, light) => (isDark ? dark || light : light || dark);
21
+ const iconUrl = pick(branding.darkIconUrl, branding.iconUrl);
22
+ const logoUrl = pick(branding.darkLogoUrl, branding.logoUrl);
23
+ const markUrl = iconUrl || logoUrl;
24
+ return {
25
+ markUrl,
26
+ isIcon: Boolean(iconUrl),
27
+ name: branding.appName || "Foundry UI",
28
+ showName: showName && (Boolean(iconUrl) || !markUrl),
29
+ };
30
+ }
5
31
  export function AppBrand({ showName = true, className, iconClassName, labelClassName, }) {
6
32
  const runtime = useThemeRuntime();
7
- const brandName = runtime.branding.appName || "Foundry UI";
8
- const logoUrl = runtime.mode === "dark"
9
- ? runtime.branding.darkLogoUrl || runtime.branding.logoUrl
10
- : runtime.branding.logoUrl || runtime.branding.darkLogoUrl;
11
- const shouldShowName = showName && !logoUrl;
12
- return (_jsxs("span", { className: cn("flex items-center gap-2", className), children: [logoUrl ? (_jsx("img", { src: logoUrl, alt: brandName, className: cn("h-5 w-auto max-w-[7rem] shrink-0 object-contain", iconClassName) })) : (_jsx(Layers3, { className: cn("size-5 shrink-0 text-primary", iconClassName) })), shouldShowName && _jsx("span", { className: cn("truncate", labelClassName), children: brandName })] }));
33
+ const lockup = _resolveBrandLockup(runtime.branding, runtime.mode, showName);
34
+ return (_jsxs("span", { className: cn("flex items-center gap-2", className), children: [lockup.markUrl ? (_jsx("img", { src: lockup.markUrl, alt: lockup.name, className: cn("shrink-0 object-contain",
35
+ // Both lock HEIGHT and let width follow the aspect ratio. Sizing an
36
+ // icon into a square box (`size-5`) silently shrinks any mark that
37
+ // is not 1:1 `object-contain` letterboxes a 4:3 glyph to 20x15,
38
+ // so it lands visibly smaller and thinner than the wordmark it
39
+ // replaced. Marks are rarely square, so height is the only
40
+ // dimension worth fixing. The max-width is just a runaway guard:
41
+ // tighter for an icon, which has to leave room for the name.
42
+ lockup.isIcon ? "h-5 w-auto max-w-8" : "h-5 w-auto max-w-[7rem]", iconClassName) })) : (_jsx(Layers3, { className: cn("size-5 shrink-0 text-primary", iconClassName) })), lockup.showName && _jsx("span", { className: cn("truncate", labelClassName), children: lockup.name })] }));
13
43
  }
@@ -9,6 +9,7 @@ import { userAttachmentComponents, composerAttachmentComponents } from "./chat-a
9
9
  import { SentContextBadges, ComposerContextBadges } from "./context-badges.js";
10
10
  import { ContextPins } from "./context-bar.js";
11
11
  import { ComposerSubmitBridge } from "./composer-submit-bridge.js";
12
+ import { ChatComposerBannerSlot } from "./chat-slots.js";
12
13
  import { useChatBubbleStore } from "../lib/chat-bubble-store.js";
13
14
  import { MarkdownText } from "./markdown-text.js";
14
15
  import { ToolFallback } from "./tool-fallback.js";
@@ -90,5 +91,5 @@ export function ChatContent({ starterSuggestions = DEFAULT_STARTER_SUGGESTIONS,
90
91
  UserMessage,
91
92
  EditComposer,
92
93
  AssistantMessage,
93
- } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(ScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 bg-background", children: [_jsx(ThreadPrimitive.If, { running: false, children: _jsx("div", { className: "border-t border-border" }) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingBar, {}) }), _jsx("div", { className: "mx-auto max-w-3xl px-4 py-4", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-2", "rounded-3xl border border-input", "bg-background shadow-sm", "px-4 py-2", "focus-within:border-primary/50 focus-within:shadow-md", "transition-all duration-300"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsx(ComposerPrimitive.Attachments, { components: composerAttachmentComponents }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", variant: "ghost", className: "shrink-0", children: _jsx(Paperclip, { className: "size-5" }) }) }), _jsx(ContextPins, { showPinButton: showPinButton })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message Assistant...", rows: 1, className: cn("flex-1 bg-transparent", "py-2 text-base", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-32") }), _jsx(ComposerActionButton, {})] })] }) })] })] })] }));
94
+ } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(ScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 bg-background", children: [_jsx(ChatComposerBannerSlot, {}), _jsx(ThreadPrimitive.If, { running: false, children: _jsx("div", { className: "border-t border-border" }) }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingBar, {}) }), _jsx("div", { className: "mx-auto max-w-3xl px-4 py-4", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-2", "rounded-3xl border border-input", "bg-background shadow-sm", "px-4 py-2", "focus-within:border-primary/50 focus-within:shadow-md", "transition-all duration-300"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsx(ComposerPrimitive.Attachments, { components: composerAttachmentComponents }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", variant: "ghost", className: "shrink-0", children: _jsx(Paperclip, { className: "size-5" }) }) }), _jsx(ContextPins, { showPinButton: showPinButton })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message Assistant...", rows: 1, className: cn("flex-1 bg-transparent", "py-2 text-base", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-32") }), _jsx(ComposerActionButton, {})] })] }) })] })] })] }));
94
95
  }
@@ -8,6 +8,7 @@ import { TooltipIconButton } from "./tooltip-icon-button.js";
8
8
  import { ContextPins } from "./context-bar.js";
9
9
  import { ComposerContextBadges, SentContextBadges } from "./context-badges.js";
10
10
  import { ComposerSubmitBridge } from "./composer-submit-bridge.js";
11
+ import { ChatComposerBannerSlot, ChatLauncherBadgeSlot } from "./chat-slots.js";
11
12
  import { MarkdownText } from "./markdown-text.js";
12
13
  import { LoadingIndicator } from "./loading-indicator.js";
13
14
  import { MessageTimestamp } from "./message-timestamp.js";
@@ -96,10 +97,10 @@ export function ChatBubble() {
96
97
  return (_jsxs(_Fragment, { children: [!isOpen && (_jsx("div", { "aria-hidden": "true", className: cn("pointer-events-none fixed bottom-0 left-0 z-40 hidden md:block", "border-r border-sidebar-border", "bg-gradient-to-b from-sidebar/0 via-sidebar/88 to-sidebar/96", "supports-[backdrop-filter]:backdrop-blur"), style: {
97
98
  width: isSidebarOpen ? sidebarWidth : RAIL_WIDTH,
98
99
  height: 64,
99
- } })), !isOpen && (_jsx("button", { type: "button", onClick: openBubble, className: cn("fixed bottom-4 z-50", "size-10 rounded-full", "bg-primary text-primary-foreground shadow-lg", "flex items-center justify-center", "hover:bg-primary/90 transition-all duration-200", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"), style: { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, "aria-label": "Open chat", children: _jsx(MessageSquare, { className: "size-4" }) })), isOpen && (_jsxs("div", { ref: panelRef, className: cn("fixed z-50", "rounded-2xl border border-border", "bg-background shadow-2xl", "flex flex-col overflow-hidden", "w-95 h-150"), style: dragPos
100
+ } })), !isOpen && (_jsxs("div", { className: "fixed z-50 group", style: { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsx("button", { type: "button", onClick: openBubble, className: cn("size-10 rounded-full", "bg-primary text-primary-foreground shadow-lg", "flex items-center justify-center", "hover:bg-primary/90 transition-all duration-200", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"), "aria-label": "Open chat", children: _jsx(MessageSquare, { className: "size-4" }) }), _jsx(ChatLauncherBadgeSlot, {})] })), isOpen && (_jsxs("div", { ref: panelRef, className: cn("fixed z-50", "rounded-2xl border border-border", "bg-background shadow-2xl", "flex flex-col overflow-hidden", "w-95 h-150"), style: dragPos
100
101
  ? { left: dragPos.x, top: dragPos.y }
101
102
  : { left: CHAT_BUBBLE_INSET, bottom: CHAT_BUBBLE_INSET }, children: [_jsxs("div", { className: cn("flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0", "cursor-grab active:cursor-grabbing select-none"), style: { touchAction: "none" }, onPointerDown: onHeaderPointerDown, onPointerMove: onHeaderPointerMove, onPointerUp: onHeaderPointerUp, onLostPointerCapture: onHeaderLostPointerCapture, children: [_jsxs("div", { className: "flex flex-col min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-sm", children: [_jsx(MessageSquare, { className: "size-3.5 text-primary shrink-0" }), _jsx("span", { className: "font-semibold text-foreground truncate", children: pageLabel })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: threadActive ? "Active thread" : "New conversation" })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsx(TooltipIconButton, { tooltip: "New Thread", size: "icon", className: "size-7", onClick: startNewThread, children: _jsx(SquarePen, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Expand chat", size: "icon", className: "size-7", onClick: expandChat, children: _jsx(Maximize2, { className: "size-3.5" }) }), _jsx(TooltipIconButton, { tooltip: "Close", size: "icon", className: "size-7", onClick: handleClose, children: _jsx(X, { className: "size-4" }) })] })] }), _jsxs(ThreadPrimitive.Root, { className: "flex flex-col flex-1 overflow-hidden", children: [_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs(ThreadPrimitive.Viewport, { className: "absolute inset-0 overflow-y-auto", children: [_jsx(ThreadPrimitive.Empty, { children: _jsx("div", { className: "flex h-full items-center justify-center p-4", children: _jsx("p", { className: "text-sm text-muted-foreground text-center", children: "Ask the agent about this page" }) }) }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsxs("div", { className: "px-4 pt-4", children: [_jsx(ThreadPrimitive.Messages, { components: {
102
103
  UserMessage: BubbleUserMessage,
103
104
  AssistantMessage: BubbleAssistantMessage,
104
- } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(BubbleScrollToBottomButton, {}) })] }), _jsx("div", { className: "shrink-0 border-t border-border px-3 py-3", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-1", "rounded-2xl border border-input", "bg-background shadow-sm", "px-3 py-2", "focus-within:border-primary/50", "transition-all duration-200"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", size: "icon", className: "size-7 shrink-0", children: _jsx(Paperclip, { className: "size-3.5" }) }) }), _jsx(ContextPins, { compact: true })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message...", rows: 1, className: cn("flex-1 bg-transparent", "py-1 text-sm", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-20") }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "secondary", size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Stop", children: _jsx(Square, { className: "size-3" }) }) }) }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Send", children: _jsx(ArrowUp, { className: "size-4" }) }) }) })] })] }) })] })] }))] }));
105
+ } }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(LoadingIndicator, {}) })] }) })] }), _jsx(ThreadPrimitive.If, { empty: false, children: _jsx(BubbleScrollToBottomButton, {}) })] }), _jsxs("div", { className: "shrink-0 border-t border-border", children: [_jsx(ChatComposerBannerSlot, {}), _jsx("div", { className: "px-3 py-3", children: _jsxs(ComposerPrimitive.Root, { className: cn("flex flex-col gap-1", "rounded-2xl border border-input", "bg-background shadow-sm", "px-3 py-2", "focus-within:border-primary/50", "transition-all duration-200"), children: [_jsx(ComposerSubmitBridge, {}), _jsx(ComposerContextBadges, {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center -space-x-1 shrink-0", children: [_jsx(ComposerPrimitive.AddAttachment, { asChild: true, children: _jsx(TooltipIconButton, { tooltip: "Attach file", size: "icon", className: "size-7 shrink-0", children: _jsx(Paperclip, { className: "size-3.5" }) }) }), _jsx(ContextPins, { compact: true })] }), _jsx(ComposerPrimitive.Input, { autoFocus: true, placeholder: "Message...", rows: 1, className: cn("flex-1 bg-transparent", "py-1 text-sm", "text-foreground outline-none", "placeholder:text-muted-foreground", "resize-none max-h-20") }), _jsx(ThreadPrimitive.If, { running: true, children: _jsx(ComposerPrimitive.Cancel, { asChild: true, children: _jsx(Button, { variant: "secondary", size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Stop", children: _jsx(Square, { className: "size-3" }) }) }) }), _jsx(ThreadPrimitive.If, { running: false, children: _jsx(ComposerPrimitive.Send, { asChild: true, children: _jsx(Button, { size: "icon", className: "rounded-full size-8 shrink-0", "aria-label": "Send", children: _jsx(ArrowUp, { className: "size-4" }) }) }) })] })] }) })] })] })] }))] }));
105
106
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Chat extension slots — module-injectable UI anchored to the chat chrome.
3
+ *
4
+ * Modules (via ``bootstrapShell``) sometimes need to render inside the
5
+ * chat surfaces the shell owns — e.g. a background-task progress strip
6
+ * pinned above the composer, or an activity badge on the collapsed chat
7
+ * launcher. Threading those through props would couple the shell to
8
+ * feature modules, so this is a tiny registry instead: a module calls
9
+ * :func:`registerChatSlots` once (import time or ``useInit``) and the
10
+ * shell's chat components render the slots wherever they belong.
11
+ *
12
+ * Render points:
13
+ * - ``composerBanner`` — directly above the composer in BOTH the full
14
+ * chat (``ChatContent``) and the floating bubble panel. It renders
15
+ * inside a fixed (non-scrolling) footer, so it never scrolls away
16
+ * with the thread.
17
+ * - ``launcherBadge`` — inside the collapsed chat-bubble launcher's
18
+ * wrapper (a positioned ``group`` container around the round button),
19
+ * so a badge can position itself absolutely over the button and use
20
+ * ``group-hover:`` styles for a hover flyout.
21
+ */
22
+ import type { ComponentType } from "react";
23
+ interface ChatSlotsState {
24
+ composerBanner: ComponentType | null;
25
+ launcherBadge: ComponentType | null;
26
+ }
27
+ export declare const useChatSlotsStore: import("zustand").UseBoundStore<import("zustand").StoreApi<ChatSlotsState>>;
28
+ /**
29
+ * Register chat slot components. Later calls overwrite only the keys
30
+ * they pass, so independent modules can each claim a different slot.
31
+ * Explicit ``null`` clears a slot; ``undefined`` values are ignored so
32
+ * an optional-spread caller can't corrupt the state shape.
33
+ */
34
+ export declare function registerChatSlots(slots: Partial<ChatSlotsState>): void;
35
+ export declare function ChatComposerBannerSlot(): import("react/jsx-runtime").JSX.Element | null;
36
+ export declare function ChatLauncherBadgeSlot(): import("react/jsx-runtime").JSX.Element | null;
37
+ export {};
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { create } from "zustand";
3
+ export const useChatSlotsStore = create(() => ({
4
+ composerBanner: null,
5
+ launcherBadge: null,
6
+ }));
7
+ /**
8
+ * Register chat slot components. Later calls overwrite only the keys
9
+ * they pass, so independent modules can each claim a different slot.
10
+ * Explicit ``null`` clears a slot; ``undefined`` values are ignored so
11
+ * an optional-spread caller can't corrupt the state shape.
12
+ */
13
+ export function registerChatSlots(slots) {
14
+ const next = {};
15
+ if (slots.composerBanner !== undefined)
16
+ next.composerBanner = slots.composerBanner;
17
+ if (slots.launcherBadge !== undefined)
18
+ next.launcherBadge = slots.launcherBadge;
19
+ useChatSlotsStore.setState(next);
20
+ }
21
+ export function ChatComposerBannerSlot() {
22
+ const Banner = useChatSlotsStore((s) => s.composerBanner);
23
+ return Banner ? _jsx(Banner, {}) : null;
24
+ }
25
+ export function ChatLauncherBadgeSlot() {
26
+ const Badge = useChatSlotsStore((s) => s.launcherBadge);
27
+ return Badge ? _jsx(Badge, {}) : null;
28
+ }
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState, Suspense } from "react";
3
3
  import { X, Maximize2, Minimize2, Copy, Check, Pin, PinOff, FolderOpen } from "lucide-react";
4
4
  import { useNavigate } from "react-router";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
- import { useToolPanelStore } from "../lib/tool-panel-store.js";
6
+ import { clampPanelWidth, MIN_PANEL_WIDTH, useToolPanelStore, } from "../lib/tool-panel-store.js";
7
7
  import { useAppStore } from "../lib/app-store.js";
8
8
  import ReactMarkdown from "react-markdown";
9
9
  import remarkGfm from "remark-gfm";
@@ -26,6 +26,40 @@ export function ToolPanel() {
26
26
  const content = useToolPanelStore((state) => state.content);
27
27
  const closePanel = useToolPanelStore((state) => state.closePanel);
28
28
  const panelWidth = useToolPanelStore((state) => state.panelWidth);
29
+ const setPanelWidth = useToolPanelStore((state) => state.setPanelWidth);
30
+ const useOverlay = useToolPanelStore((state) => state.useOverlay);
31
+ /** Pointer id of an in-flight resize drag (null when idle). */
32
+ const dragPointerId = useRef(null);
33
+ const maxPanelWidth = typeof window !== "undefined"
34
+ ? clampPanelWidth(Number.POSITIVE_INFINITY, window.innerWidth)
35
+ : MIN_PANEL_WIDTH;
36
+ useEffect(() => {
37
+ const onPointerMove = (event) => {
38
+ if (dragPointerId.current === null)
39
+ return;
40
+ setPanelWidth(window.innerWidth - event.clientX);
41
+ };
42
+ // pointercancel matters: a cancelled drag must not leave the document
43
+ // unselectable with a col-resize cursor (review catch).
44
+ const onPointerEnd = (event) => {
45
+ if (dragPointerId.current !== null && event.pointerId !== dragPointerId.current)
46
+ return;
47
+ dragPointerId.current = null;
48
+ document.body.style.cursor = "";
49
+ document.body.style.userSelect = "";
50
+ };
51
+ window.addEventListener("pointermove", onPointerMove);
52
+ window.addEventListener("pointerup", onPointerEnd);
53
+ window.addEventListener("pointercancel", onPointerEnd);
54
+ return () => {
55
+ window.removeEventListener("pointermove", onPointerMove);
56
+ window.removeEventListener("pointerup", onPointerEnd);
57
+ window.removeEventListener("pointercancel", onPointerEnd);
58
+ // Unmounting mid-drag must not strand the styles either.
59
+ document.body.style.cursor = "";
60
+ document.body.style.userSelect = "";
61
+ };
62
+ }, [setPanelWidth]);
29
63
  const isFullscreen = useToolPanelStore((state) => state.isFullscreen);
30
64
  const toggleFullscreen = useToolPanelStore((state) => state.toggleFullscreen);
31
65
  const navigate = useNavigate();
@@ -85,5 +119,23 @@ export function ToolPanel() {
85
119
  }, [isOpen, closePanel]);
86
120
  if (!content)
87
121
  return null;
88
- return (_jsxs("div", { "data-tool-panel": true, className: cn("fixed top-0 right-0 z-40", "h-dvh", "bg-background border-l border-border shadow-2xl", "transition-transform duration-300 ease-in-out", "flex flex-col", isOpen ? "translate-x-0" : "translate-x-full"), style: { width: panelWidth }, children: [_jsx("div", { className: "flex-shrink-0 border-b border-border px-6 py-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h2", { className: "text-lg font-semibold text-foreground truncate pr-4", children: content.title }), _jsxs("div", { className: "flex items-center gap-1", children: [entityId && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: handleToggleEntityPin, className: cn("flex items-center justify-center", "size-8 rounded-md shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", isEntityPinned && "text-primary"), "aria-label": isEntityPinned ? "Unpin document" : "Pin document", title: isEntityPinned ? "Unpin document" : "Pin document", children: isEntityPinned ? (_jsx(PinOff, { className: "size-4" })) : (_jsx(Pin, { className: "size-4 text-muted-foreground" })) }), _jsx("button", { type: "button", onClick: handleNavigateToEntity, className: cn("flex items-center justify-center", "size-8 rounded-md shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": "Open in navigation", title: "Open in navigation", children: _jsx(FolderOpen, { className: "size-4 text-muted-foreground" }) })] })), _jsx("button", { type: "button", onClick: handleCopyAll, className: cn("flex items-center justify-center", "size-8 rounded-md flex-shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": "Copy content", children: copied ? (_jsx(Check, { className: "size-4 text-success" })) : (_jsx(Copy, { className: "size-4 text-muted-foreground" })) }), _jsx("button", { type: "button", onClick: toggleFullscreen, className: cn("flex items-center justify-center", "size-8 rounded-md flex-shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": isFullscreen ? "Exit fullscreen" : "Fullscreen", children: isFullscreen ? (_jsx(Minimize2, { className: "size-4 text-muted-foreground" })) : (_jsx(Maximize2, { className: "size-4 text-muted-foreground" })) }), _jsx("button", { type: "button", onClick: closePanel, className: cn("flex items-center justify-center", "size-8 rounded-md flex-shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": "Close panel", children: _jsx(X, { className: "size-5 text-muted-foreground" }) })] })] }) }), _jsxs("div", { className: "relative flex-1 overflow-y-auto", ref: contentRef, "data-testid": "tool-panel-content", children: [_jsx(PanelContentRenderer, { content: content }), _jsx(SelectionPopover, { containerRef: contentRef })] })] }));
122
+ return (_jsxs("div", { "data-tool-panel": true, className: cn("fixed top-0 right-0 z-40", "h-dvh", "bg-background border-l border-border shadow-2xl", "transition-transform duration-300 ease-in-out", "flex flex-col", isOpen ? "translate-x-0" : "translate-x-full"), style: { width: panelWidth }, children: [isOpen && !useOverlay && !isFullscreen ? (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize panel", "aria-valuemin": MIN_PANEL_WIDTH, "aria-valuemax": maxPanelWidth, "aria-valuenow": Math.round(panelWidth), tabIndex: 0, onPointerDown: (event) => {
123
+ event.preventDefault();
124
+ dragPointerId.current = event.pointerId;
125
+ document.body.style.cursor = "col-resize";
126
+ document.body.style.userSelect = "none";
127
+ }, onKeyDown: (event) => {
128
+ const step = event.shiftKey ? 80 : 24;
129
+ if (event.key === "ArrowLeft")
130
+ setPanelWidth(panelWidth + step);
131
+ else if (event.key === "ArrowRight")
132
+ setPanelWidth(panelWidth - step);
133
+ else if (event.key === "Home")
134
+ setPanelWidth(maxPanelWidth);
135
+ else if (event.key === "End")
136
+ setPanelWidth(MIN_PANEL_WIDTH);
137
+ else
138
+ return;
139
+ event.preventDefault();
140
+ }, className: cn("absolute left-0 top-0 z-10 h-full w-2 -translate-x-1/2", "cursor-col-resize touch-none", "before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2", "before:bg-transparent hover:before:bg-primary/40 focus-visible:before:bg-primary", "focus:outline-none") })) : null, _jsx("div", { className: "flex-shrink-0 border-b border-border px-6 py-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h2", { className: "text-lg font-semibold text-foreground truncate pr-4", children: content.title }), _jsxs("div", { className: "flex items-center gap-1", children: [entityId && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: handleToggleEntityPin, className: cn("flex items-center justify-center", "size-8 rounded-md shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", isEntityPinned && "text-primary"), "aria-label": isEntityPinned ? "Unpin document" : "Pin document", title: isEntityPinned ? "Unpin document" : "Pin document", children: isEntityPinned ? (_jsx(PinOff, { className: "size-4" })) : (_jsx(Pin, { className: "size-4 text-muted-foreground" })) }), _jsx("button", { type: "button", onClick: handleNavigateToEntity, className: cn("flex items-center justify-center", "size-8 rounded-md shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": "Open in navigation", title: "Open in navigation", children: _jsx(FolderOpen, { className: "size-4 text-muted-foreground" }) })] })), _jsx("button", { type: "button", onClick: handleCopyAll, className: cn("flex items-center justify-center", "size-8 rounded-md flex-shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": "Copy content", children: copied ? (_jsx(Check, { className: "size-4 text-success" })) : (_jsx(Copy, { className: "size-4 text-muted-foreground" })) }), _jsx("button", { type: "button", onClick: toggleFullscreen, className: cn("flex items-center justify-center", "size-8 rounded-md flex-shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": isFullscreen ? "Exit fullscreen" : "Fullscreen", children: isFullscreen ? (_jsx(Minimize2, { className: "size-4 text-muted-foreground" })) : (_jsx(Maximize2, { className: "size-4 text-muted-foreground" })) }), _jsx("button", { type: "button", onClick: closePanel, className: cn("flex items-center justify-center", "size-8 rounded-md flex-shrink-0", "hover:bg-muted transition-colors", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"), "aria-label": "Close panel", children: _jsx(X, { className: "size-5 text-muted-foreground" }) })] })] }) }), _jsxs("div", { className: "relative flex-1 overflow-y-auto", ref: contentRef, "data-testid": "tool-panel-content", children: [_jsx(PanelContentRenderer, { content: content }), _jsx(SelectionPopover, { containerRef: contentRef })] })] }));
89
141
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export type { AGUIChatConversationFactoryArgs, AGUIHistoryAdapterFactory, } from
8
8
  export { ChatContent, DEFAULT_STARTER_SUGGESTIONS } from "./components/assistant-chat.js";
9
9
  export type { ChatContentProps } from "./components/assistant-chat.js";
10
10
  export { ChatBubble } from "./components/chat-bubble.js";
11
+ export { registerChatSlots, useChatSlotsStore, ChatComposerBannerSlot, ChatLauncherBadgeSlot, } from "./components/chat-slots.js";
11
12
  export { ChatHeader } from "./components/chat-header.js";
12
13
  export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
13
14
  export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export { ClientToolExecutor } from "./components/client-tool-executor.js";
7
7
  export { AGUIRuntimeProvider, useAGUIAdapter } from "./components/ag-ui-runtime-provider.js";
8
8
  export { ChatContent, DEFAULT_STARTER_SUGGESTIONS } from "./components/assistant-chat.js";
9
9
  export { ChatBubble } from "./components/chat-bubble.js";
10
+ export { registerChatSlots, useChatSlotsStore, ChatComposerBannerSlot, ChatLauncherBadgeSlot, } from "./components/chat-slots.js";
10
11
  export { ChatHeader } from "./components/chat-header.js";
11
12
  export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
12
13
  export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
@@ -84,8 +84,21 @@ export interface ThemeAppSlots {
84
84
  export interface ThemeBranding {
85
85
  appName?: string;
86
86
  appTitle?: string;
87
+ /**
88
+ * Full wordmark — an image that already spells the brand out. Rendered on
89
+ * its own, because setting it beside `appName` prints the brand twice.
90
+ */
87
91
  logoUrl?: string;
88
92
  darkLogoUrl?: string;
93
+ /**
94
+ * Compact mark (square-ish glyph, no words). Rendered *with* `appName`
95
+ * beside it, so a tenant can show "<mark> Product Name" — the usual answer
96
+ * when the product name differs from the company name. Takes precedence
97
+ * over `logoUrl` when both are set, since supplying an icon is an explicit
98
+ * request for the lockup.
99
+ */
100
+ iconUrl?: string;
101
+ darkIconUrl?: string;
89
102
  }
90
103
  export interface ThemeDefinition {
91
104
  light?: ThemeColorSlots;
@@ -82,6 +82,8 @@ const BRANDING_KEYS = [
82
82
  "appTitle",
83
83
  "logoUrl",
84
84
  "darkLogoUrl",
85
+ "iconUrl",
86
+ "darkIconUrl",
85
87
  ];
86
88
  function cleanObject(value, keys) {
87
89
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -412,9 +414,9 @@ export const FOUNDRY_VIOLET_THEME = {
412
414
  ring: "oklch(0.72 0.18 286)",
413
415
  },
414
416
  typography: {
415
- bodyFont: "\"Segoe UI\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
416
- headingFont: "\"Segoe UI\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
417
- monoFont: "\"SFMono-Regular\", \"Cascadia Code\", \"JetBrains Mono\", ui-monospace, monospace",
417
+ bodyFont: '"Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
418
+ headingFont: '"Segoe UI", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
419
+ monoFont: '"SFMono-Regular", "Cascadia Code", "JetBrains Mono", ui-monospace, monospace',
418
420
  },
419
421
  radius: {
420
422
  radius: "0.875rem",
@@ -433,10 +435,7 @@ export function resolveThemeRuntime(layers, mode) {
433
435
  const effectiveMode = getEffectiveThemeMode(mode);
434
436
  const basePreset = layers.find((layer) => layer.basePreset && layer.basePreset.trim())?.basePreset ??
435
437
  "foundry-violet";
436
- const definitions = [
437
- getThemePreset(basePreset),
438
- ...layers.map((layer) => layer.definition),
439
- ];
438
+ const definitions = [getThemePreset(basePreset), ...layers.map((layer) => layer.definition)];
440
439
  const definition = mergeThemeDefinitions(...definitions);
441
440
  const colors = {
442
441
  ...(effectiveMode === "dark" ? definition.dark : definition.light),
@@ -29,6 +29,10 @@ export interface PanelRendererEntry {
29
29
  content: ToolPanelContent;
30
30
  }>>;
31
31
  }
32
+ /** Smallest useful panel, and the space the chat/content beside it must keep. */
33
+ export declare const MIN_PANEL_WIDTH = 360;
34
+ /** Clamp a desired width so neither side can be squeezed out of usefulness. */
35
+ export declare const clampPanelWidth: (desired: number, viewport: number) => number;
32
36
  interface ToolPanelState {
33
37
  isOpen: boolean;
34
38
  content: ToolPanelContent | null;
@@ -41,6 +45,8 @@ interface ToolPanelState {
41
45
  setContent: (content: ToolPanelContent) => void;
42
46
  updateLayout: () => void;
43
47
  toggleFullscreen: () => void;
48
+ /** Drag-to-resize the split; clamped and persisted per user. */
49
+ setPanelWidth: (width: number) => void;
44
50
  /** Register a custom content renderer (e.g., PDF viewer, entity pages). */
45
51
  registerRenderer: (entry: PanelRendererEntry) => void;
46
52
  }
@@ -6,12 +6,40 @@
6
6
  */
7
7
  import { create } from "zustand";
8
8
  const SIDE_BY_SIDE_BREAKPOINT = 1280;
9
+ /** Smallest useful panel, and the space the chat/content beside it must keep. */
10
+ export const MIN_PANEL_WIDTH = 360;
11
+ const MIN_SIBLING_WIDTH = 420;
12
+ const PANEL_WIDTH_STORAGE_KEY = "foundry.toolPanel.width";
13
+ /** Clamp a desired width so neither side can be squeezed out of usefulness. */
14
+ export const clampPanelWidth = (desired, viewport) => {
15
+ if (viewport < SIDE_BY_SIDE_BREAKPOINT)
16
+ return viewport;
17
+ const max = Math.max(MIN_PANEL_WIDTH, viewport - MIN_SIBLING_WIDTH);
18
+ return Math.round(Math.min(max, Math.max(MIN_PANEL_WIDTH, desired)));
19
+ };
20
+ const readStoredPanelWidth = () => {
21
+ if (typeof window === "undefined")
22
+ return null;
23
+ try {
24
+ const raw = window.localStorage.getItem(PANEL_WIDTH_STORAGE_KEY);
25
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
26
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
27
+ }
28
+ catch {
29
+ return null; // private mode / storage disabled — fall back to the default
30
+ }
31
+ };
9
32
  const getPanelWidth = () => {
10
33
  if (typeof window === "undefined")
11
34
  return 800;
12
35
  const screenWidth = window.innerWidth;
13
36
  if (screenWidth < SIDE_BY_SIDE_BREAKPOINT)
14
37
  return screenWidth;
38
+ // A width the user dragged to wins over the computed default, re-clamped
39
+ // to the current viewport so a narrow window can't strand the content.
40
+ const stored = readStoredPanelWidth();
41
+ if (stored !== null)
42
+ return clampPanelWidth(stored, screenWidth);
15
43
  return Math.min(1000, Math.max(600, screenWidth * 0.5));
16
44
  };
17
45
  const shouldUseOverlay = () => {
@@ -30,6 +58,20 @@ export const useToolPanelStore = create((set, get) => ({
30
58
  registerRenderer: (entry) => set((state) => ({ renderers: [...state.renderers, entry] })),
31
59
  closePanel: () => set({ isOpen: false, isFullscreen: false }),
32
60
  setContent: (content) => set({ content }),
61
+ setPanelWidth: (width) => {
62
+ if (typeof window === "undefined")
63
+ return;
64
+ if (get().isFullscreen)
65
+ return;
66
+ const next = clampPanelWidth(width, window.innerWidth);
67
+ try {
68
+ window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(next));
69
+ }
70
+ catch {
71
+ // Persisting is best-effort; the drag still applies for this session.
72
+ }
73
+ set({ panelWidth: next });
74
+ },
33
75
  updateLayout: () => {
34
76
  const { isFullscreen } = get();
35
77
  if (!isFullscreen) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,8 +53,8 @@
53
53
  "tailwind-merge": "^3.5.0",
54
54
  "react-markdown": "^10.0.0",
55
55
  "remark-gfm": "^4.0.0",
56
- "@iloveagents/foundry-agent": "^0.5.0",
57
- "@iloveagents/foundry-web-primitives": "^0.5.0"
56
+ "@iloveagents/foundry-agent": "^0.7.0",
57
+ "@iloveagents/foundry-web-primitives": "^0.7.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@ag-ui/client": "^0.0.52",