@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.
- package/dist/components/app-brand.d.ts +23 -0
- package/dist/components/app-brand.js +36 -6
- package/dist/components/assistant-chat.js +2 -1
- package/dist/components/chat-bubble.js +3 -2
- package/dist/components/chat-slots.d.ts +37 -0
- package/dist/components/chat-slots.js +28 -0
- package/dist/components/tool-panel.js +54 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/theme-runtime.d.ts +13 -0
- package/dist/lib/theme-runtime.js +6 -7
- package/dist/lib/tool-panel-store.d.ts +6 -0
- package/dist/lib/tool-panel-store.js +42 -0
- package/package.json +3 -3
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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("
|
|
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, {}) })] }),
|
|
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: [
|
|
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: "
|
|
416
|
-
headingFont: "
|
|
417
|
-
monoFont: "
|
|
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.
|
|
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.
|
|
57
|
-
"@iloveagents/foundry-web-primitives": "^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",
|