Skip to content

Commit 03266e8

Browse files
feat(studio): add collapsible right-side AI Chat panel (VS Code style)
- Add AiChatPanel component with 48px collapsed edge button and 380px expanded panel - Add use-ai-chat-panel hook with keyboard shortcut (Ctrl+Shift+I / Cmd+Shift+I) and localStorage persistence - Integrate @ai-sdk/react useChat with DefaultChatTransport for Vercel Data Stream Protocol - Mount AiChatPanel in App.tsx inside SidebarProvider - Add ai and @ai-sdk/react dependencies - Add unit tests for message persistence and panel state Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/2f8d08c4-2f0a-4f27-998c-b4c4a8805883 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
1 parent 57d74f6 commit 03266e8

7 files changed

Lines changed: 466 additions & 0 deletions

File tree

apps/studio/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# @objectstack/studio
22

3+
## Unreleased
4+
5+
### Minor Changes
6+
7+
- Add collapsible right-side AI Chat floating panel (VS Code Copilot Chat style).
8+
9+
- New `AiChatPanel` component: fixed right-side panel with 48px collapsed edge
10+
button and 380px expanded view. Supports stream chat via Vercel AI SDK
11+
`useChat` hook connected to `/api/v1/ai/chat`.
12+
- New `use-ai-chat-panel` hook: manages panel visibility toggle, keyboard
13+
shortcut (`Ctrl+Shift+I` / `Cmd+Shift+I`), and message history persistence
14+
to localStorage.
15+
- Added `ai` and `@ai-sdk/react` dependencies for Vercel Data Stream Protocol
16+
integration.
17+
318
## 4.0.0
419

520
### Patch Changes

apps/studio/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"preview": "vite preview"
1818
},
1919
"dependencies": {
20+
"@ai-sdk/react": "^3.0.144",
2021
"@hono/node-server": "^1.19.11",
2122
"@objectstack/client": "workspace:*",
2223
"@objectstack/client-react": "workspace:*",
@@ -47,6 +48,7 @@
4748
"@radix-ui/react-tabs": "^1.1.13",
4849
"@radix-ui/react-toast": "^1.2.15",
4950
"@radix-ui/react-tooltip": "^1.2.8",
51+
"ai": "^6.0.142",
5052
"class-variance-authority": "^0.7.1",
5153
"clsx": "^2.1.1",
5254
"hono": "^4.12.9",

apps/studio/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DeveloperOverview } from './components/DeveloperOverview';
1111
import { PackageManager } from './components/PackageManager';
1212
import { ApiConsolePage } from './components/ApiConsolePage';
1313
import { Toaster } from "@/components/ui/toaster"
14+
import { AiChatPanel } from "@/components/AiChatPanel"
1415
import { getApiBaseUrl, config } from './lib/config';
1516
import { PluginRegistryProvider, PluginHost } from './plugins';
1617
import { builtInPlugins } from './plugins/built-in';
@@ -142,6 +143,7 @@ export default function App() {
142143
</div>
143144
</main>
144145
<Toaster />
146+
<AiChatPanel />
145147
</SidebarProvider>
146148
</ErrorBoundary>
147149
</PluginRegistryProvider>
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useState, useRef, useEffect, useMemo } from 'react';
4+
import { useChat } from '@ai-sdk/react';
5+
import { DefaultChatTransport } from 'ai';
6+
import type { UIMessage } from 'ai';
7+
import { Bot, X, Send, Trash2, Sparkles } from 'lucide-react';
8+
import { Button } from '@/components/ui/button';
9+
import { ScrollArea } from '@/components/ui/scroll-area';
10+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
11+
import { cn } from '@/lib/utils';
12+
import { useAiChatPanel, loadMessages, saveMessages } from '@/hooks/use-ai-chat-panel';
13+
import { getApiBaseUrl } from '@/lib/config';
14+
15+
const PANEL_WIDTH = 380;
16+
const COLLAPSED_WIDTH = 48;
17+
18+
/**
19+
* Extract the text content from a UIMessage's parts array.
20+
*/
21+
function getMessageText(msg: UIMessage): string {
22+
return (msg.parts ?? [])
23+
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
24+
.map((p) => p.text)
25+
.join('');
26+
}
27+
28+
export function AiChatPanel() {
29+
const { isOpen, setOpen, toggle } = useAiChatPanel();
30+
const [input, setInput] = useState('');
31+
const scrollRef = useRef<HTMLDivElement>(null);
32+
const inputRef = useRef<HTMLTextAreaElement>(null);
33+
const baseUrl = getApiBaseUrl();
34+
35+
const transport = useMemo(
36+
() => new DefaultChatTransport({ api: `${baseUrl}/api/v1/ai/chat` }),
37+
[baseUrl],
38+
);
39+
40+
const { messages, sendMessage, setMessages, status, error } = useChat({
41+
transport,
42+
messages: loadMessages() as UIMessage[],
43+
});
44+
45+
const isStreaming = status === 'streaming' || status === 'submitted';
46+
47+
// Persist messages to localStorage whenever they change
48+
useEffect(() => {
49+
if (messages.length > 0) {
50+
saveMessages(messages);
51+
}
52+
}, [messages]);
53+
54+
// Auto-scroll to bottom on new messages
55+
useEffect(() => {
56+
if (scrollRef.current) {
57+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
58+
}
59+
}, [messages]);
60+
61+
// Focus input when panel opens
62+
useEffect(() => {
63+
if (isOpen && inputRef.current) {
64+
inputRef.current.focus();
65+
}
66+
}, [isOpen]);
67+
68+
const clearHistory = () => {
69+
setMessages([]);
70+
saveMessages([]);
71+
};
72+
73+
const handleSend = () => {
74+
const text = input.trim();
75+
if (!text || isStreaming) return;
76+
setInput('');
77+
sendMessage({ text });
78+
};
79+
80+
// Handle Enter to submit, Shift+Enter for newline
81+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
82+
if (e.key === 'Enter' && !e.shiftKey) {
83+
e.preventDefault();
84+
handleSend();
85+
}
86+
};
87+
88+
// ── Collapsed state: edge button ──
89+
if (!isOpen) {
90+
return (
91+
<TooltipProvider>
92+
<Tooltip>
93+
<TooltipTrigger asChild>
94+
<button
95+
onClick={toggle}
96+
data-testid="ai-chat-toggle"
97+
className={cn(
98+
'fixed right-0 top-1/2 -translate-y-1/2 z-50',
99+
'flex items-center justify-center',
100+
'h-10 rounded-l-md border border-r-0 border-border',
101+
'bg-background text-foreground shadow-md',
102+
'hover:bg-accent transition-colors',
103+
)}
104+
style={{ width: COLLAPSED_WIDTH }}
105+
>
106+
<Sparkles className="h-5 w-5" />
107+
</button>
108+
</TooltipTrigger>
109+
<TooltipContent side="left">
110+
<p>AI Chat <kbd className="ml-1 text-[10px] opacity-60">⌘⇧I</kbd></p>
111+
</TooltipContent>
112+
</Tooltip>
113+
</TooltipProvider>
114+
);
115+
}
116+
117+
// ── Expanded panel ──
118+
return (
119+
<aside
120+
data-testid="ai-chat-panel"
121+
className={cn(
122+
'fixed right-0 top-0 z-50 h-full',
123+
'flex flex-col border-l border-border',
124+
'bg-background shadow-xl',
125+
'animate-in slide-in-from-right duration-200',
126+
)}
127+
style={{ width: PANEL_WIDTH }}
128+
>
129+
{/* ── Header ── */}
130+
<div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
131+
<div className="flex items-center gap-2 text-sm font-semibold">
132+
<Bot className="h-4 w-4 text-primary" />
133+
AI Chat
134+
</div>
135+
<div className="flex items-center gap-1">
136+
<TooltipProvider>
137+
<Tooltip>
138+
<TooltipTrigger asChild>
139+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={clearHistory}>
140+
<Trash2 className="h-3.5 w-3.5" />
141+
<span className="sr-only">Clear chat</span>
142+
</Button>
143+
</TooltipTrigger>
144+
<TooltipContent><p>Clear history</p></TooltipContent>
145+
</Tooltip>
146+
</TooltipProvider>
147+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setOpen(false)}>
148+
<X className="h-4 w-4" />
149+
<span className="sr-only">Close</span>
150+
</Button>
151+
</div>
152+
</div>
153+
154+
{/* ── Messages ── */}
155+
<ScrollArea className="flex-1 overflow-hidden">
156+
<div ref={scrollRef} className="flex flex-col gap-3 p-3 overflow-y-auto h-full">
157+
{messages.length === 0 && (
158+
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-12 text-center text-muted-foreground">
159+
<Sparkles className="h-8 w-8 opacity-40" />
160+
<p className="text-sm">Ask anything about your project.</p>
161+
<p className="text-xs opacity-60">
162+
<kbd>⌘⇧I</kbd> to toggle this panel
163+
</p>
164+
</div>
165+
)}
166+
{messages.map((msg) => {
167+
const text = getMessageText(msg);
168+
if (!text && msg.role !== 'user') return null;
169+
return (
170+
<div
171+
key={msg.id}
172+
className={cn(
173+
'flex flex-col gap-1 rounded-lg px-3 py-2 text-sm',
174+
msg.role === 'user'
175+
? 'ml-8 bg-primary text-primary-foreground'
176+
: 'mr-8 bg-muted text-foreground',
177+
)}
178+
>
179+
<span className="text-[10px] font-medium opacity-60 uppercase">
180+
{msg.role === 'user' ? 'You' : 'Assistant'}
181+
</span>
182+
<div className="whitespace-pre-wrap break-words">{text}</div>
183+
</div>
184+
);
185+
})}
186+
{isStreaming && (
187+
<div className="mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
188+
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
189+
Thinking…
190+
</div>
191+
)}
192+
{error && (
193+
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
194+
Error: {error.message || 'Something went wrong'}
195+
</div>
196+
)}
197+
</div>
198+
</ScrollArea>
199+
200+
{/* ── Input ── */}
201+
<div className="shrink-0 border-t p-3">
202+
<div className="flex items-end gap-2">
203+
<textarea
204+
ref={inputRef}
205+
data-testid="ai-chat-input"
206+
value={input}
207+
onChange={(e) => setInput(e.target.value)}
208+
onKeyDown={handleKeyDown}
209+
placeholder="Ask AI…"
210+
rows={1}
211+
className={cn(
212+
'flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm',
213+
'placeholder:text-muted-foreground',
214+
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
215+
'max-h-32 min-h-[36px]',
216+
)}
217+
/>
218+
<Button
219+
type="button"
220+
size="icon"
221+
className="h-9 w-9 shrink-0"
222+
disabled={!input.trim() || isStreaming}
223+
onClick={handleSend}
224+
>
225+
<Send className="h-4 w-4" />
226+
<span className="sr-only">Send</span>
227+
</Button>
228+
</div>
229+
</div>
230+
</aside>
231+
);
232+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useState, useEffect, useCallback } from 'react';
4+
import type { UIMessage } from 'ai';
5+
6+
const STORAGE_KEY = 'objectstack:ai-chat-messages';
7+
const PANEL_STATE_KEY = 'objectstack:ai-chat-panel-open';
8+
9+
/**
10+
* Load persisted chat messages from localStorage.
11+
*/
12+
export function loadMessages(): UIMessage[] {
13+
try {
14+
const raw = localStorage.getItem(STORAGE_KEY);
15+
if (!raw) return [];
16+
const parsed = JSON.parse(raw);
17+
return Array.isArray(parsed) ? parsed : [];
18+
} catch {
19+
return [];
20+
}
21+
}
22+
23+
/**
24+
* Persist chat messages to localStorage.
25+
*/
26+
export function saveMessages(messages: UIMessage[]): void {
27+
try {
28+
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
29+
} catch {
30+
// localStorage may be full or unavailable — silently ignore
31+
}
32+
}
33+
34+
/**
35+
* Load panel open/closed state from localStorage.
36+
*/
37+
function loadPanelState(): boolean {
38+
try {
39+
return localStorage.getItem(PANEL_STATE_KEY) === 'true';
40+
} catch {
41+
return false;
42+
}
43+
}
44+
45+
/**
46+
* Hook for managing AI Chat Panel state:
47+
* - Panel visibility toggle (open/close)
48+
* - Global keyboard shortcut (Ctrl+Shift+I / Cmd+Shift+I)
49+
* - Panel state persistence to localStorage
50+
*/
51+
export function useAiChatPanel() {
52+
const [isOpen, setIsOpen] = useState<boolean>(loadPanelState);
53+
54+
// Persist panel state to localStorage
55+
const setOpen = useCallback((open: boolean) => {
56+
setIsOpen(open);
57+
try {
58+
localStorage.setItem(PANEL_STATE_KEY, String(open));
59+
} catch {
60+
// silently ignore
61+
}
62+
}, []);
63+
64+
const toggle = useCallback(() => {
65+
setOpen(!isOpen);
66+
}, [isOpen, setOpen]);
67+
68+
// Global keyboard shortcut: Ctrl+Shift+I / Cmd+Shift+I
69+
useEffect(() => {
70+
function handleKeyDown(e: KeyboardEvent) {
71+
if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key === 'I') {
72+
e.preventDefault();
73+
setOpen(!isOpen);
74+
}
75+
}
76+
window.addEventListener('keydown', handleKeyDown);
77+
return () => window.removeEventListener('keydown', handleKeyDown);
78+
}, [isOpen, setOpen]);
79+
80+
return { isOpen, setOpen, toggle };
81+
}

0 commit comments

Comments
 (0)