Skip to content

Commit b202027

Browse files
authored
Merge pull request #1156 from objectstack-ai/copilot/add-floating-chatbot-widget
2 parents ab815c8 + 219ff3f commit b202027

File tree

13 files changed

+898
-2
lines changed

13 files changed

+898
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Floating Chatbot FAB Widget** (`@object-ui/plugin-chatbot`): Airtable-style floating action button (FAB) that opens a chatbot panel overlay. New components: `FloatingChatbot`, `FloatingChatbotPanel`, `FloatingChatbotTrigger`, `FloatingChatbotProvider`. Features include configurable position (bottom-right/left), fullscreen toggle, close, portal rendering to avoid z-index conflicts, and `useFloatingChatbot` hook for programmatic control. Registered as `chatbot-floating` in ComponentRegistry. 21 new unit tests.
13+
14+
- **New ChatbotSchema floating fields** (`@object-ui/types`): Extended `ChatbotSchema` with `displayMode` (`'inline' | 'floating'`) and `floatingConfig` (`FloatingChatbotConfig`) for schema-driven floating chatbot configuration. New `FloatingChatbotConfig` interface with `position`, `defaultOpen`, `panelWidth`, `panelHeight`, `title`, `triggerIcon`, and `triggerSize` options.
15+
16+
- **Console floating chatbot integration** (`@object-ui/console`): Added the floating chatbot FAB to `ConsoleLayout`, making it available on every authenticated page. Uses `useObjectChat` with metadata-aware context (app name, object list) for intelligent auto-responses. Wired with demo auto-response mode by default, switchable to API streaming when `api` endpoint is configured.
17+
1218
- **AI SDUI Chatbot integration** (`@object-ui/plugin-chatbot`): Refactored chatbot plugin to support full AI streaming via `service-ai` backend and `vercel/ai` SDK (`@ai-sdk/react`). New `useObjectChat` composable hook wraps `@ai-sdk/react`'s `useChat` for SSE streaming, tool-calling, and production-grade chat. Auto-detects API mode (when `api` schema field is set) vs legacy local auto-response mode. ChatbotEnhanced component now supports stop, reload, error display, and streaming state indicators. 44 unit tests (19 new hook tests, 10 new streaming tests).
1319

1420
- **New ChatbotSchema fields** (`@object-ui/types`): Extended `ChatbotSchema` with `api`, `conversationId`, `systemPrompt`, `model`, `streamingEnabled`, `headers`, `requestBody`, `maxToolRoundtrips`, and `onError` fields for service-ai integration. Extended `ChatMessage` with `streaming`, `toolInvocations` fields and added `ChatToolInvocation` interface for tool-calling flows.

apps/console/src/components/ConsoleLayout.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,25 @@
33
*
44
* Root layout shell for the console application. Composes the AppShell
55
* with the sidebar, header, and main content area.
6+
* Includes the global floating chatbot (FAB) widget.
67
* @module
78
*/
89

910
import React from 'react';
1011
import { AppShell } from '@object-ui/layout';
12+
import { FloatingChatbot, useObjectChat, type ChatMessage } from '@object-ui/plugin-chatbot';
1113
import { AppSidebar } from './AppSidebar';
1214
import { AppHeader } from './AppHeader';
1315
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
1416
import { resolveI18nLabel } from '../utils';
1517
import type { ConnectionState } from '../dataSource';
1618

19+
/** Minimal object shape used by the chatbot context */
20+
interface ConsoleObject {
21+
name: string;
22+
label?: string;
23+
}
24+
1725
interface ConsoleLayoutProps {
1826
children: React.ReactNode;
1927
activeAppName: string;
@@ -29,6 +37,56 @@ function ConsoleLayoutInner({ children }: { children: React.ReactNode }) {
2937
return <>{children}</>;
3038
}
3139

40+
/** Floating chatbot wired with useObjectChat for demo auto-response */
41+
function ConsoleFloatingChatbot({ appLabel, objects }: { appLabel: string; objects: ConsoleObject[] }) {
42+
const objectNames = objects.map((o) => o.label || o.name).join(', ');
43+
44+
const {
45+
messages,
46+
isLoading,
47+
error,
48+
sendMessage,
49+
stop,
50+
reload,
51+
clear,
52+
} = useObjectChat({
53+
initialMessages: [
54+
{
55+
id: 'welcome',
56+
role: 'assistant' as const,
57+
content: `Hello! I'm your **${appLabel}** assistant. How can I help you today?`,
58+
},
59+
],
60+
autoResponse: true,
61+
autoResponseText: objectNames
62+
? `I can help you work with ${objectNames}. What would you like to do?`
63+
: 'Thanks for your message! I\'m here to help you navigate and manage your data.',
64+
autoResponseDelay: 800,
65+
});
66+
67+
return (
68+
<FloatingChatbot
69+
floatingConfig={{
70+
position: 'bottom-right',
71+
defaultOpen: false,
72+
panelWidth: 400,
73+
panelHeight: 520,
74+
title: `${appLabel} Assistant`,
75+
triggerSize: 56,
76+
}}
77+
messages={messages as ChatMessage[]}
78+
placeholder="Ask anything..."
79+
onSendMessage={(content: string) => sendMessage(content)}
80+
onClear={clear}
81+
onStop={isLoading ? stop : undefined}
82+
onReload={reload}
83+
isLoading={isLoading}
84+
error={error}
85+
enableMarkdown
86+
/>
87+
);
88+
}
89+
3290
export function ConsoleLayout({
3391
children,
3492
activeAppName,
@@ -37,6 +95,8 @@ export function ConsoleLayout({
3795
objects,
3896
connectionState
3997
}: ConsoleLayoutProps) {
98+
const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
99+
40100
return (
41101
<AppShell
42102
sidebar={
@@ -47,7 +107,7 @@ export function ConsoleLayout({
47107
}
48108
navbar={
49109
<AppHeader
50-
appName={resolveI18nLabel(activeApp?.label) || activeAppName}
110+
appName={appLabel}
51111
objects={objects}
52112
connectionState={connectionState}
53113
/>
@@ -70,6 +130,9 @@ export function ConsoleLayout({
70130
<ConsoleLayoutInner>
71131
{children}
72132
</ConsoleLayoutInner>
133+
134+
{/* Global floating chatbot — available on every page */}
135+
<ConsoleFloatingChatbot appLabel={appLabel} objects={objects} />
73136
</AppShell>
74137
);
75138
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from "react"
10+
import * as ReactDOM from "react-dom"
11+
import type { FloatingChatbotConfig } from "@object-ui/types"
12+
import { FloatingChatbotProvider } from "./FloatingChatbotProvider"
13+
import { FloatingChatbotTrigger } from "./FloatingChatbotTrigger"
14+
import { FloatingChatbotPanel } from "./FloatingChatbotPanel"
15+
import { ChatbotEnhanced, type ChatbotEnhancedProps } from "./ChatbotEnhanced"
16+
17+
export interface FloatingChatbotProps extends ChatbotEnhancedProps {
18+
/** Floating configuration */
19+
floatingConfig?: FloatingChatbotConfig
20+
}
21+
22+
/**
23+
* Floating Chatbot — Airtable-style FAB widget.
24+
*
25+
* Wraps `ChatbotEnhanced` in a floating panel that can be toggled
26+
* via a fixed FAB trigger button. Uses React portal to avoid
27+
* DOM/z-index conflicts.
28+
*/
29+
export function FloatingChatbot({
30+
floatingConfig,
31+
...chatbotProps
32+
}: FloatingChatbotProps) {
33+
const {
34+
position = "bottom-right",
35+
defaultOpen = false,
36+
panelWidth = 400,
37+
panelHeight = 520,
38+
title = "Chat",
39+
triggerSize = 56,
40+
} = floatingConfig ?? {}
41+
42+
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null)
43+
44+
React.useEffect(() => {
45+
// Create a portal root so the floating UI sits outside the normal DOM tree
46+
let container = document.getElementById("floating-chatbot-portal")
47+
if (!container) {
48+
container = document.createElement("div")
49+
container.id = "floating-chatbot-portal"
50+
document.body.appendChild(container)
51+
}
52+
setPortalContainer(container)
53+
54+
return () => {
55+
// Only remove if we created it and it's still in the DOM
56+
if (container && container.parentNode && !container.hasChildNodes()) {
57+
container.parentNode.removeChild(container)
58+
}
59+
}
60+
}, [])
61+
62+
const content = (
63+
<FloatingChatbotProvider defaultOpen={defaultOpen}>
64+
<FloatingChatbotTrigger
65+
position={position}
66+
size={triggerSize}
67+
/>
68+
<FloatingChatbotPanel
69+
title={title}
70+
position={position}
71+
width={panelWidth}
72+
height={panelHeight}
73+
>
74+
<ChatbotEnhanced
75+
{...chatbotProps}
76+
maxHeight="100%"
77+
className="h-full border-0 rounded-none"
78+
/>
79+
</FloatingChatbotPanel>
80+
</FloatingChatbotProvider>
81+
)
82+
83+
// Use portal rendering when in browser, fallback to inline for SSR / tests
84+
if (portalContainer) {
85+
return ReactDOM.createPortal(content, portalContainer)
86+
}
87+
88+
return content
89+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from "react"
10+
import { cn } from "@object-ui/components"
11+
import { Button } from "@object-ui/components"
12+
import { X, Maximize2, Minimize2 } from "lucide-react"
13+
import { useFloatingChatbot } from "./FloatingChatbotProvider"
14+
15+
export interface FloatingChatbotPanelProps {
16+
/** Panel title */
17+
title?: string
18+
/** Position of the panel (anchored to FAB corner) */
19+
position?: "bottom-right" | "bottom-left"
20+
/** Panel width in pixels (ignored in fullscreen) */
21+
width?: number
22+
/** Panel height in pixels (ignored in fullscreen) */
23+
height?: number
24+
/** Content to render inside the panel body */
25+
children: React.ReactNode
26+
/** Custom className for the panel container */
27+
className?: string
28+
}
29+
30+
/**
31+
* Floating panel overlay for the chatbot.
32+
* Renders above all content, anchored to the configured position.
33+
* Supports fullscreen toggle and close.
34+
*/
35+
export function FloatingChatbotPanel({
36+
title = "Chat",
37+
position = "bottom-right",
38+
width = 400,
39+
height = 520,
40+
children,
41+
className,
42+
}: FloatingChatbotPanelProps) {
43+
const { isOpen, isFullscreen, close, toggleFullscreen } = useFloatingChatbot()
44+
45+
if (!isOpen) return null
46+
47+
const panelStyle: React.CSSProperties = isFullscreen
48+
? { inset: 0, width: "100vw", height: "100vh" }
49+
: { width, height, maxHeight: "calc(100vh - 100px)" }
50+
51+
return (
52+
<div
53+
className={cn(
54+
"fixed z-50 flex flex-col rounded-lg border bg-background shadow-xl overflow-hidden transition-all",
55+
isFullscreen
56+
? "inset-0 rounded-none"
57+
: position === "bottom-right"
58+
? "right-6 bottom-20"
59+
: "left-6 bottom-20",
60+
className
61+
)}
62+
style={panelStyle}
63+
role="dialog"
64+
aria-label={title}
65+
data-testid="floating-chatbot-panel"
66+
>
67+
{/* Header */}
68+
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40">
69+
<span className="text-sm font-medium truncate">{title}</span>
70+
<div className="flex items-center gap-1">
71+
<Button
72+
variant="ghost"
73+
size="icon"
74+
className="h-7 w-7"
75+
onClick={toggleFullscreen}
76+
aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
77+
data-testid="floating-chatbot-fullscreen"
78+
>
79+
{isFullscreen ? (
80+
<Minimize2 className="h-4 w-4" />
81+
) : (
82+
<Maximize2 className="h-4 w-4" />
83+
)}
84+
</Button>
85+
<Button
86+
variant="ghost"
87+
size="icon"
88+
className="h-7 w-7"
89+
onClick={close}
90+
aria-label="Close chat"
91+
data-testid="floating-chatbot-close"
92+
>
93+
<X className="h-4 w-4" />
94+
</Button>
95+
</div>
96+
</div>
97+
98+
{/* Body */}
99+
<div className="flex-1 overflow-hidden">{children}</div>
100+
</div>
101+
)
102+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from "react"
10+
11+
export interface FloatingChatbotState {
12+
/** Whether the floating panel is currently open */
13+
isOpen: boolean
14+
/** Whether the panel is in fullscreen mode */
15+
isFullscreen: boolean
16+
}
17+
18+
export interface FloatingChatbotActions {
19+
/** Open the floating panel */
20+
open: () => void
21+
/** Close the floating panel */
22+
close: () => void
23+
/** Toggle the floating panel open/closed */
24+
toggle: () => void
25+
/** Toggle fullscreen mode */
26+
toggleFullscreen: () => void
27+
}
28+
29+
export type FloatingChatbotContextValue = FloatingChatbotState & FloatingChatbotActions
30+
31+
const FloatingChatbotContext = React.createContext<FloatingChatbotContextValue | null>(null)
32+
33+
export interface FloatingChatbotProviderProps {
34+
/** Whether the panel is open by default */
35+
defaultOpen?: boolean
36+
children: React.ReactNode
37+
}
38+
39+
export function FloatingChatbotProvider({
40+
defaultOpen = false,
41+
children,
42+
}: FloatingChatbotProviderProps) {
43+
const [isOpen, setIsOpen] = React.useState(defaultOpen)
44+
const [isFullscreen, setIsFullscreen] = React.useState(false)
45+
46+
const value = React.useMemo<FloatingChatbotContextValue>(
47+
() => ({
48+
isOpen,
49+
isFullscreen,
50+
open: () => setIsOpen(true),
51+
close: () => {
52+
setIsOpen(false)
53+
setIsFullscreen(false)
54+
},
55+
toggle: () => setIsOpen((prev) => !prev),
56+
toggleFullscreen: () => setIsFullscreen((prev) => !prev),
57+
}),
58+
[isOpen, isFullscreen]
59+
)
60+
61+
return (
62+
<FloatingChatbotContext.Provider value={value}>
63+
{children}
64+
</FloatingChatbotContext.Provider>
65+
)
66+
}
67+
68+
/**
69+
* Hook to access the floating chatbot state and actions.
70+
* Must be used within a `FloatingChatbotProvider`.
71+
*/
72+
export function useFloatingChatbot(): FloatingChatbotContextValue {
73+
const context = React.useContext(FloatingChatbotContext)
74+
if (!context) {
75+
throw new Error(
76+
"useFloatingChatbot must be used within a <FloatingChatbotProvider>"
77+
)
78+
}
79+
return context
80+
}

0 commit comments

Comments
 (0)