create-pyric 0.1.0-alpha.21 → 0.1.0-alpha.24

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.
@@ -0,0 +1,8 @@
1
+ import type { UiUser } from './chat-types';
2
+
3
+ /** The initials shown in an avatar for a user, or for "You" when there is none. */
4
+ export const initials = (user: UiUser | null): string =>
5
+ (user?.displayName ?? user?.email ?? 'You').split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
6
+
7
+ /** A short local time for a conversation or message timestamp. */
8
+ export const formatTime = (date?: Date): string => date ? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date) : '';
@@ -4,21 +4,18 @@ import * as jsxRuntime from 'react/jsx-runtime';
4
4
  import * as jsxDevRuntime from 'react/jsx-dev-runtime';
5
5
  import { createElement, isValidElement, type ComponentType } from 'react';
6
6
  import { createPortal } from 'react-dom';
7
- import { ArrowUp, Bell, Check, CheckCircle2, ChevronsUpDown, Code2, Copy, LoaderCircle, LogIn, LogOut, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Square, TerminalSquare, Trash2, XCircle } from 'lucide-react';
7
+ import { useListenerOwner, type ListenerOwner } from '@pyric/ui/listener-owner';
8
+ import { ArrowUp, Bell, Code2, MessageSquarePlus, PanelLeftClose, Square, XCircle } from 'lucide-react';
8
9
  import { Button } from '@/components/ui/button';
9
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
10
- import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader } from '@/components/ui/message';
11
- import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';
12
- import type { ChatPageServices, UiConversation, UiMessage, UiNotification, UiPresence, UiToolCall, UiUsage, UiUser } from './chat-types';
10
+ import type { ChatPageServices, UiConversation, UiMessage, UiNotification, UiPresence, UiUser } from './chat-types';
13
11
  import type { ChatMode } from '../../chat-mode';
14
- import { MarkdownMessage } from './markdown-message';
12
+ import { ConversationHeader } from './conversation-header';
13
+ import { ConversationList } from './conversation-list';
14
+ import { MessageThread } from './message-thread';
15
+ import { PresenceBar } from './presence-bar';
15
16
  import { storePreviewComponent } from './preview-component-state';
16
17
  import { reconcileMessages, starterMessages } from './message-reconciliation';
17
18
 
18
- const initials = (user: UiUser | null): string =>
19
- (user?.displayName ?? user?.email ?? 'You').split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
20
-
21
- const formatTime = (date?: Date): string => date ? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date) : '';
22
19
  const errorMessage = (reason: unknown, fallback: string): string => reason instanceof Error ? reason.message : fallback;
23
20
  const isAbort = (reason: unknown): boolean => reason instanceof DOMException && reason.name === 'AbortError';
24
21
  const modeOptions: Array<{ id: ChatMode; label: string; description: string; placeholder: string }> = [
@@ -36,114 +33,6 @@ const writeConversationUrl = (conversationId: string | null, replace = false): v
36
33
  window.history[replace ? 'replaceState' : 'pushState']({}, '', `${url.pathname}${url.search}${url.hash}`);
37
34
  };
38
35
 
39
- function CopyMessageButton({ text }: { text: string }) {
40
- const [copied, setCopied] = useState(false);
41
-
42
- const copy = async () => {
43
- try {
44
- await navigator.clipboard.writeText(text);
45
- setCopied(true);
46
- window.setTimeout(() => setCopied(false), 1600);
47
- } catch {
48
- setCopied(false);
49
- }
50
- };
51
-
52
- return (
53
- <Button variant="ghost" size="icon-xs" aria-label={copied ? 'Message copied' : 'Copy message'} title={copied ? 'Message copied' : 'Copy message'} onClick={() => void copy()} disabled={!text}>
54
- {copied ? <Check /> : <Copy />}
55
- </Button>
56
- );
57
- }
58
-
59
- function StartPlanCard({ onStart, onDecline, onSendDirection }: { onStart: () => void; onDecline: () => void; onSendDirection: (direction: string) => void }) {
60
- const [editing, setEditing] = useState(false);
61
- const [direction, setDirection] = useState('');
62
-
63
- const sendDirection = () => {
64
- const value = direction.trim();
65
- if (!value) return;
66
- onSendDirection(value);
67
- };
68
-
69
- return (
70
- <section aria-label="Start plan" className="mt-2 max-w-full rounded-lg border border-primary/30 bg-primary/5 p-3">
71
- <div className="flex items-start justify-between gap-3">
72
- <div className="min-w-0">
73
- <p className="text-sm font-medium">Ready to start the plan?</p>
74
- <p className="mt-1 text-xs leading-5 text-muted-foreground">PyChat can turn this direction into the first actionable step.</p>
75
- </div>
76
- <span className="shrink-0 rounded-md bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">Plan</span>
77
- </div>
78
- {editing && <textarea value={direction} onChange={(event) => setDirection(event.target.value)} onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') sendDirection(); }} autoFocus rows={2} placeholder="Tell PyChat what to change…" aria-label="What should PyChat change?" className="mt-3 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50" />}
79
- <div className="mt-3 flex flex-wrap items-center gap-2">
80
- <Button size="sm" onClick={onStart}>Start plan</Button>
81
- {editing ? <Button size="sm" variant="secondary" onClick={sendDirection} disabled={!direction.trim()}>Send direction</Button> : <Button size="sm" variant="outline" onClick={() => setEditing(true)}>Tell PyChat what to change</Button>}
82
- <Button size="sm" variant="ghost" onClick={onDecline}>Decline</Button>
83
- </div>
84
- </section>
85
- );
86
- }
87
-
88
- function ToolActivity({ calls }: { calls: UiToolCall[] }) {
89
- if (!calls.length) return null;
90
- return (
91
- <Collapsible className="mb-1 min-w-0 w-full max-w-full rounded-lg border bg-background/40 px-3 py-2">
92
- <div className="flex items-center justify-between gap-3">
93
- <span className="flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground"><TerminalSquare className="size-3.5 shrink-0" /> Workspace activity <span className="font-normal">· {calls.length}</span></span>
94
- <CollapsibleTrigger render={<Button variant="ghost" size="icon" className="size-7" />} aria-label="Toggle workspace activity"><ChevronsUpDown /></CollapsibleTrigger>
95
- </div>
96
- <CollapsibleContent className="space-y-1.5 pt-2">
97
- {calls.map((call) => <div key={call.id} className="flex min-w-0 items-center gap-2 text-xs"><span className="shrink-0 text-muted-foreground">{call.status === 'running' ? <LoaderCircle className="size-3.5 animate-spin" /> : call.status === 'complete' ? <CheckCircle2 className="size-3.5 text-primary" /> : <XCircle className="size-3.5 text-destructive" />}</span><span className="truncate font-mono">{call.name}</span><span className="truncate text-muted-foreground">{call.summary ?? (call.status === 'running' ? 'Running…' : '')}</span></div>)}
98
- </CollapsibleContent>
99
- </Collapsible>
100
- );
101
- }
102
-
103
- function UsageSummary({ usage }: { usage?: UiUsage }) {
104
- if (!usage) return null;
105
- const total = usage.inputTokens + usage.outputTokens;
106
- return <span className="inline-flex items-center gap-1.5"><span>{total.toLocaleString()} tokens</span>{usage.reasoningTokens ? <span>· {usage.reasoningTokens.toLocaleString()} thinking</span> : null}</span>;
107
- }
108
-
109
- function ThoughtsDisclosure({ thoughts, streaming }: { thoughts: string; streaming: boolean }) {
110
- return (
111
- <Collapsible defaultOpen={streaming} className="mb-1 min-w-0 w-full max-w-full rounded-lg border border-dashed bg-muted/30 px-3 py-2">
112
- <div className="flex items-center justify-between gap-3">
113
- <span className="text-xs font-medium text-muted-foreground">Thoughts</span>
114
- <CollapsibleTrigger render={<Button variant="ghost" size="icon" className="size-7" />} aria-label="Toggle thoughts">
115
- <ChevronsUpDown />
116
- </CollapsibleTrigger>
117
- </div>
118
- <CollapsibleContent className="pt-2">
119
- <MarkdownMessage content={thoughts} className="max-w-full text-xs leading-5 text-muted-foreground" />
120
- </CollapsibleContent>
121
- </Collapsible>
122
- );
123
- }
124
-
125
- function StreamingFollow({ revision, streaming }: { revision: string; streaming: boolean }) {
126
- const { end } = useMessageScrollerScrollable();
127
- const { scrollToEnd } = useMessageScroller();
128
- const followingRef = useRef(true);
129
-
130
- useEffect(() => {
131
- if (!streaming) {
132
- followingRef.current = true;
133
- return;
134
- }
135
- if (!end) followingRef.current = false;
136
- }, [end, streaming]);
137
-
138
- useEffect(() => {
139
- if (!streaming || !followingRef.current) return;
140
- const frame = window.requestAnimationFrame(() => scrollToEnd());
141
- return () => window.cancelAnimationFrame(frame);
142
- }, [revision, scrollToEnd, streaming]);
143
-
144
- return null;
145
- }
146
-
147
36
  class PreviewErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: string | null }> {
148
37
  state: { error: string | null } = { error: null };
149
38
  static getDerivedStateFromError(error: unknown) {
@@ -245,6 +134,20 @@ export function ChatPage({ services }: ChatPageProps) {
245
134
  useEffect(() => { messagesRef.current = messages; }, [messages]);
246
135
  useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]);
247
136
 
137
+ // The React component that owns these listeners, captured during render.
138
+ // In a production build the hook returns no owner and reads no stack, so
139
+ // each listener falls back to the name of the UI region it feeds.
140
+ const { owner: componentOwner } = useListenerOwner();
141
+ // The element each listener feeds, so the sandbox can outline it on the page.
142
+ const presenceRef = useRef<HTMLDivElement | null>(null);
143
+ const conversationListRef = useRef<HTMLElement | null>(null);
144
+ const conversationHeaderRef = useRef<HTMLElement | null>(null);
145
+ const messageThreadRef = useRef<HTMLDivElement | null>(null);
146
+ const ownerFor = (region: string, element: Element | null): string | ListenerOwner => {
147
+ if (!componentOwner) return region;
148
+ return element ? { ...componentOwner, element } : componentOwner;
149
+ };
150
+
248
151
  useEffect(() => {
249
152
  setAuthLoading(true);
250
153
  return services.auth.observe((nextUser) => {
@@ -258,7 +161,7 @@ export function ChatPage({ services }: ChatPageProps) {
258
161
  setOnline([]);
259
162
  return;
260
163
  }
261
- const unsubscribe = services.presence.observe(setOnline);
164
+ const unsubscribe = services.presence.observe(setOnline, { owner: ownerFor('presence-bar', presenceRef.current) });
262
165
  let alive = true;
263
166
  if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
264
167
  void services.notifications.enable((message) => {
@@ -315,7 +218,7 @@ export function ChatPage({ services }: ChatPageProps) {
315
218
  void services.conversations.list().then(applyList).catch((reason: unknown) => {
316
219
  if (alive) { setError(errorMessage(reason, 'Could not load conversations')); setConversationLoading(false); }
317
220
  });
318
- const unsubscribe = services.conversations.observeList(applyList);
221
+ const unsubscribe = services.conversations.observeList(applyList, { owner: ownerFor('conversation-list', conversationListRef.current) });
319
222
  return () => { alive = false; unsubscribe(); };
320
223
  }, [deletingConversationId, services, user]);
321
224
 
@@ -334,10 +237,10 @@ export function ChatPage({ services }: ChatPageProps) {
334
237
  if (!alive) return;
335
238
  setMessages((current) => reconcileMessages(nextMessages, current));
336
239
  setMessageLoading(false);
337
- });
240
+ }, { owner: ownerFor('message-thread', messageThreadRef.current) });
338
241
  const unsubscribeConversation = services.conversations.observe(activeConversationId, (conversation) => {
339
242
  setConversations((current) => current.map((item) => item.id === conversation.id ? conversation : item));
340
- });
243
+ }, { owner: ownerFor('conversation-header', conversationHeaderRef.current) });
341
244
  return () => { alive = false; unsubscribeMessages(); unsubscribeConversation(); };
342
245
  }, [activeConversationId, services, user]);
343
246
 
@@ -519,13 +422,13 @@ export function ChatPage({ services }: ChatPageProps) {
519
422
  <aside className={`${sidebarOpen ? 'w-72' : 'w-0'} absolute inset-y-0 start-0 z-20 flex shrink-0 flex-col overflow-hidden border-e bg-card shadow-xl transition-[width] duration-200 sm:relative sm:inset-auto sm:z-auto sm:shadow-none`}>
520
423
  <div className="flex h-16 items-center justify-between border-b px-4"><div><p className="text-sm font-semibold tracking-tight">PyChat</p><p className="text-xs text-muted-foreground">Private AI workspace</p></div><Button variant="ghost" size="icon" aria-label="Close conversation sidebar" onClick={() => setSidebarOpen(false)}><PanelLeftClose /></Button></div>
521
424
  <div className="p-3"><Button className="w-full justify-start gap-2" variant="outline" onClick={() => void startConversation()} disabled={!user || streaming}><MessageSquarePlus /> New conversation</Button></div>
522
- <nav aria-label="Conversations" className="flex-1 space-y-1 overflow-y-auto px-2">{conversations.map((conversation) => <div key={conversation.id} className={`group flex items-center gap-1 rounded-lg text-sm transition-colors ${conversation.id === activeConversationId ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}><button className="min-w-0 flex-1 px-3 py-2 text-start" onClick={() => selectConversation(conversation.id)}><span className="block truncate font-medium">{conversation.title}</span><span className="mt-0.5 block text-xs opacity-70">{conversation.updatedAt ? formatTime(conversation.updatedAt) : 'Just now'}</span></button><Button variant="ghost" size="icon" className="me-1 size-8 shrink-0 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 focus-visible:opacity-100" aria-label={`Delete conversation ${conversation.title}`} disabled={deletingConversationId !== null} onClick={() => void deleteConversation(conversation.id)}><Trash2 /></Button></div>)}{!conversations.length && !conversationLoading && <p className="px-3 py-8 text-center text-xs text-muted-foreground">No conversations yet. Start one to develop an idea.</p>}</nav>
523
- <div className="border-t p-3">{user ? <div className="flex items-center gap-2"><div className="relative shrink-0"><div className="grid size-8 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">{initials(user)}</div><span className="absolute -end-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-card bg-emerald-500" aria-hidden="true" /></div><div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{user.displayName ?? 'Signed in'}</p><p className="truncate text-xs text-muted-foreground">{online.length} online · {user.email}</p></div><Button variant="ghost" size="icon" aria-label="Sign out" onClick={() => void signOut()}><LogOut /></Button></div> : <Button className="w-full gap-2" onClick={() => void signIn()} disabled={authLoading}><LogIn /> Sign in to continue</Button>}</div>
425
+ <ConversationList ref={conversationListRef} conversations={conversations} activeConversationId={activeConversationId} deletingConversationId={deletingConversationId} loading={conversationLoading} onSelect={selectConversation} onDelete={(id) => void deleteConversation(id)} />
426
+ <PresenceBar ref={presenceRef} user={user} onlineCount={online.length} authLoading={authLoading} onSignIn={() => void signIn()} onSignOut={() => void signOut()} />
524
427
  </aside>
525
428
  <section className="flex min-w-0 flex-1 flex-col">
526
- <header className="flex h-16 shrink-0 items-center gap-3 border-b px-4 sm:px-6">{!sidebarOpen && <Button variant="ghost" size="icon" aria-label="Open conversation sidebar" onClick={() => setSidebarOpen(true)}><PanelLeftOpen /></Button>}<div className="min-w-0 flex-1"><p className="truncate text-sm font-semibold">{activeConversation?.title ?? 'New conversation'}</p><p className="text-xs text-muted-foreground">Private thinking space · {modeOptions.find((option) => option.id === mode)?.label}</p></div><Button variant={notificationsEnabled ? 'secondary' : 'ghost'} size="icon" aria-label={notificationsEnabled ? 'Send test notification' : 'Enable notifications'} title={notificationsEnabled ? 'Send test notification' : 'Enable notifications'} disabled={!user || notificationsBusy} onClick={() => void enableNotifications()}>{notificationsBusy ? <LoaderCircle className="animate-spin" /> : <Bell />}</Button><Button variant={workspaceOpen ? 'secondary' : 'ghost'} size="icon" aria-label={workspaceOpen ? 'Workspace open' : 'Open workspace'} title="Open workspace" onClick={() => setWorkspaceOpen((open) => !open)}><Code2 /></Button></header>
429
+ <ConversationHeader ref={conversationHeaderRef} title={activeConversation?.title ?? 'New conversation'} modeLabel={modeOptions.find((option) => option.id === mode)?.label} sidebarOpen={sidebarOpen} onOpenSidebar={() => setSidebarOpen(true)} notificationsEnabled={notificationsEnabled} notificationsBusy={notificationsBusy} notificationsDisabled={!user || notificationsBusy} onEnableNotifications={() => void enableNotifications()} workspaceOpen={workspaceOpen} onToggleWorkspace={() => setWorkspaceOpen((open) => !open)} />
527
430
  {notice && <div role="status" className="chat-message-in border-b bg-primary/5 px-4 py-2.5 sm:px-6"><div className="mx-auto flex max-w-3xl items-start gap-3"><Bell className="mt-0.5 size-4 shrink-0 text-primary" /><div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{notice.title}</p>{notice.body && <p className="truncate text-xs text-muted-foreground">{notice.body}</p>}</div><Button variant="ghost" size="icon-xs" aria-label="Dismiss notification" onClick={() => setNotice(null)}><XCircle /></Button></div></div>}
528
- <MessageScrollerProvider autoScroll><MessageScroller className="min-h-0 flex-1"><StreamingFollow revision={messageRevision} streaming={streaming} /><MessageScrollerViewport className="px-4 py-8 sm:px-8"><MessageScrollerContent className="mx-auto w-full max-w-3xl gap-8"><MessageScrollerItem messageId="intro" scrollAnchor={messages.length <= 1}><div className="mx-auto max-w-xl py-16 text-center"><div className="mx-auto mb-5 grid size-12 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-sm"><span className="text-lg">✦</span></div><h1 className="text-2xl font-semibold tracking-tight">A clearer place to think.</h1><p className="mt-2 text-sm leading-6 text-muted-foreground">Explore possibilities, turn ideas into plans, or pressure-test a direction. Your context stays with you across conversations.</p></div></MessageScrollerItem><MessageGroup className="gap-8">{messages.map((message, index) => <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === 'user'} className="chat-message-in" style={{ animationDelay: `${Math.min(index * 35, 350)}ms` }}><Message align={message.role === 'user' ? 'end' : 'start'}><MessageAvatar className={message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'}><span className="grid size-8 place-items-center text-xs font-semibold">{message.role === 'user' ? initials(user) : '✦'}</span></MessageAvatar><MessageContent className={message.role === 'assistant' ? 'w-full max-w-[min(42rem,78%)]' : 'w-fit max-w-[min(42rem,78%)]'}><MessageHeader>{message.role === 'user' ? 'You' : 'PyChat'}</MessageHeader>{message.role === 'assistant' && message.toolCalls && <ToolActivity calls={message.toolCalls} />}{message.role === 'assistant' && message.thoughts && <ThoughtsDisclosure thoughts={message.thoughts} streaming={message.status === 'streaming'} />}<div className={`w-fit max-w-full rounded-xl px-4 py-3 leading-7 shadow-sm ${message.role === 'user' ? 'bg-primary text-primary-foreground' : 'border-transparent bg-muted text-foreground'}`}>{message.text ? message.role === 'assistant' ? <MarkdownMessage content={message.text} /> : <p className="whitespace-pre-wrap text-sm">{message.text}</p> : <span className="inline-flex gap-1 text-muted-foreground"><i className="size-1.5 animate-pulse rounded-full bg-current" /><i className="size-1.5 animate-pulse rounded-full bg-current [animation-delay:150ms]" /><i className="size-1.5 animate-pulse rounded-full bg-current [animation-delay:300ms]" /></span>}</div>{message.status === 'streaming' && <MessageFooter>Thinking…</MessageFooter>}{message.status === 'error' && <MessageFooter><span role="alert">Response unavailable.</span><Button variant="ghost" size="sm" onClick={() => retryRef.current?.()}>Retry</Button></MessageFooter>}{message.role === 'assistant' && message.usage && <MessageFooter><UsageSummary usage={message.usage} /></MessageFooter>}<div className={message.role === 'user' ? 'self-end' : 'self-start'}><CopyMessageButton text={message.text} /></div>{message.role === 'assistant' && planReadyMessageId && (message.id === planReadyMessageId || message.clientMessageId === planReadyMessageId) && <StartPlanCard onStart={() => { setPlanReadyMessageId(null); void sendMessage('Start the plan above. Begin with the first actionable step.', 'plan'); }} onDecline={() => setPlanReadyMessageId(null)} onSendDirection={(direction) => { setPlanReadyMessageId(null); void sendMessage(direction, 'plan'); }} />}</MessageContent></Message></MessageScrollerItem>)}</MessageGroup></MessageScrollerContent></MessageScrollerViewport><MessageScrollerButton /></MessageScroller></MessageScrollerProvider>
431
+ <MessageThread ref={messageThreadRef} messages={messages} user={user} streaming={streaming} revision={messageRevision} planReadyMessageId={planReadyMessageId} onRetry={() => retryRef.current?.()} onStartPlan={() => { setPlanReadyMessageId(null); void sendMessage('Start the plan above. Begin with the first actionable step.', 'plan'); }} onDeclinePlan={() => setPlanReadyMessageId(null)} onSendDirection={(direction) => { setPlanReadyMessageId(null); void sendMessage(direction, 'plan'); }} />
529
432
  <div className="border-t bg-background/90 p-4 backdrop-blur sm:px-8"><div className="mx-auto max-w-3xl">{error && <p role="alert" className="mb-2 text-xs text-destructive">{error}</p>}<div className="mb-2 flex flex-wrap items-center gap-1" role="tablist" aria-label="Thinking mode">{modeOptions.map((option) => <button key={option.id} type="button" role="tab" aria-selected={mode === option.id} title={option.description} disabled={!user || streaming} onClick={() => { setMode(option.id); setPlanReadyMessageId(null); }} className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${mode === option.id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}>{option.label}</button>)}</div><div className="relative rounded-xl border bg-card shadow-sm focus-within:ring-2 focus-within:ring-ring/30"><textarea ref={textareaRef} value={draft} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }} disabled={!user || streaming} rows={3} placeholder={user ? modeOptions.find((option) => option.id === mode)?.placeholder : 'Sign in to start a private conversation'} aria-label="Message" className="min-h-24 w-full resize-none bg-transparent px-4 pb-12 pt-4 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" /><div className="absolute inset-x-3 bottom-3 flex items-center justify-between"><span className="text-xs text-muted-foreground">Enter to send · Shift + Enter for a new line</span>{streaming ? <Button variant="secondary" size="icon" aria-label="Stop generating" onClick={stopStreaming}><Square /></Button> : <Button size="icon" aria-label="Send message" disabled={!user || !draft.trim() || messageLoading} onClick={() => void sendMessage()}><ArrowUp /></Button>}</div></div><p className="mt-2 text-center text-[11px] text-muted-foreground">PyChat helps you explore, plan, and refine. Check important information.</p></div></div>
530
433
  </section>
531
434
  {workspaceOpen && <PreviewErrorBoundary><WorkspacePreview services={services} revision={workspaceRevision} onClose={() => setWorkspaceOpen(false)} /></PreviewErrorBoundary>}
@@ -1,3 +1,4 @@
1
+ import type { ListenerOwner } from '@pyric/ui/listener-owner';
1
2
  import type { ChatMode } from '../../chat-mode';
2
3
 
3
4
  export type UiMessageRole = 'user' | 'assistant' | 'system';
@@ -55,6 +56,17 @@ export type UiToolCall = {
55
56
  summary?: string;
56
57
  };
57
58
 
59
+ /**
60
+ * Who owns a listener: the React component `useListenerOwner` identified, or
61
+ * the name of the UI region it feeds. Under the local sandbox this is
62
+ * recorded as the listener's owner, so the runtime chip and Studio group
63
+ * listeners by the UI they feed; the production SDK ignores it.
64
+ *
65
+ * `ListenerOwner` is a type-only import, so `@pyric/ui` stays a development
66
+ * dependency and never reaches the production build.
67
+ */
68
+ export type ListenerOptions = { owner?: string | ListenerOwner };
69
+
58
70
  export type ChatPageServices = {
59
71
  auth: {
60
72
  currentUser(): UiUser | null;
@@ -66,14 +78,14 @@ export type ChatPageServices = {
66
78
  list(): Promise<UiConversation[]>;
67
79
  create(input?: { title?: string }): Promise<string>;
68
80
  delete(id: string): Promise<void>;
69
- observe(id: string, callback: (conversation: UiConversation) => void): () => void;
70
- observeList(callback: (conversations: UiConversation[]) => void): () => void;
81
+ observe(id: string, callback: (conversation: UiConversation) => void, options?: ListenerOptions): () => void;
82
+ observeList(callback: (conversations: UiConversation[]) => void, options?: ListenerOptions): () => void;
71
83
  };
72
84
  messages: {
73
85
  list(conversationId: string): Promise<UiMessage[]>;
74
86
  appendUserMessage(input: { conversationId: string; text: string; clientMessageId: string }): Promise<string>;
75
87
  appendAssistantMessage(input: { conversationId: string; text: string; clientMessageId: string; thoughts?: string; model?: string; finishReason?: UiFinishReason; inputTokenCount?: number | null; outputTokenCount?: number | null }): Promise<string>;
76
- observeRecent(conversationId: string, callback: (messages: UiMessage[]) => void): () => void;
88
+ observeRecent(conversationId: string, callback: (messages: UiMessage[]) => void, options?: ListenerOptions): () => void;
77
89
  };
78
90
  ai: {
79
91
  stream(
@@ -86,7 +98,7 @@ export type ChatPageServices = {
86
98
  ): Promise<{ text: string; thoughts?: string; model?: string; finishReason?: UiFinishReason; inputTokenCount?: number | null; outputTokenCount?: number | null; usage?: UiUsage }>;
87
99
  };
88
100
  presence: {
89
- observe(callback: (online: UiPresence[]) => void): () => void;
101
+ observe(callback: (online: UiPresence[]) => void, options?: ListenerOptions): () => void;
90
102
  };
91
103
  notifications: {
92
104
  enable(onMessage: (message: UiNotification) => void): Promise<boolean>;
@@ -0,0 +1,26 @@
1
+ import type { Ref } from 'react';
2
+ import { Bell, Code2, LoaderCircle, PanelLeftOpen } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+
5
+ type ConversationHeaderProps = {
6
+ ref?: Ref<HTMLElement>;
7
+ title: string;
8
+ modeLabel?: string;
9
+ sidebarOpen: boolean;
10
+ onOpenSidebar: () => void;
11
+ notificationsEnabled: boolean;
12
+ notificationsBusy: boolean;
13
+ notificationsDisabled: boolean;
14
+ onEnableNotifications: () => void;
15
+ workspaceOpen: boolean;
16
+ onToggleWorkspace: () => void;
17
+ };
18
+
19
+ /** The header above the thread: the conversation title, the current mode, and
20
+ * the notification and workspace controls. Its root element is the one the
21
+ * active-conversation listener feeds. */
22
+ export function ConversationHeader({ ref, title, modeLabel, sidebarOpen, onOpenSidebar, notificationsEnabled, notificationsBusy, notificationsDisabled, onEnableNotifications, workspaceOpen, onToggleWorkspace }: ConversationHeaderProps) {
23
+ return (
24
+ <header ref={ref} id="conversation-header" className="flex h-16 shrink-0 items-center gap-3 border-b px-4 sm:px-6">{!sidebarOpen && <Button variant="ghost" size="icon" aria-label="Open conversation sidebar" onClick={onOpenSidebar}><PanelLeftOpen /></Button>}<div className="min-w-0 flex-1"><p className="truncate text-sm font-semibold">{title}</p><p className="text-xs text-muted-foreground">Private thinking space · {modeLabel}</p></div><Button variant={notificationsEnabled ? 'secondary' : 'ghost'} size="icon" aria-label={notificationsEnabled ? 'Send test notification' : 'Enable notifications'} title={notificationsEnabled ? 'Send test notification' : 'Enable notifications'} disabled={notificationsDisabled} onClick={onEnableNotifications}>{notificationsBusy ? <LoaderCircle className="animate-spin" /> : <Bell />}</Button><Button variant={workspaceOpen ? 'secondary' : 'ghost'} size="icon" aria-label={workspaceOpen ? 'Workspace open' : 'Open workspace'} title="Open workspace" onClick={onToggleWorkspace}><Code2 /></Button></header>
25
+ );
26
+ }
@@ -0,0 +1,38 @@
1
+ import type { Ref } from 'react';
2
+ import { Trash2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { formatTime } from './chat-format';
5
+ import type { UiConversation } from './chat-types';
6
+
7
+ type ConversationItemProps = {
8
+ conversation: UiConversation;
9
+ active: boolean;
10
+ deleteDisabled: boolean;
11
+ onSelect: (id: string) => void;
12
+ onDelete: (id: string) => void;
13
+ };
14
+
15
+ /** One row of the sidebar: the button that opens a conversation, and the control that deletes it. */
16
+ function ConversationItem({ conversation, active, deleteDisabled, onSelect, onDelete }: ConversationItemProps) {
17
+ return (
18
+ <div className={`group flex items-center gap-1 rounded-lg text-sm transition-colors ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}><button className="min-w-0 flex-1 px-3 py-2 text-start" onClick={() => onSelect(conversation.id)}><span className="block truncate font-medium">{conversation.title}</span><span className="mt-0.5 block text-xs opacity-70">{conversation.updatedAt ? formatTime(conversation.updatedAt) : 'Just now'}</span></button><Button variant="ghost" size="icon" className="me-1 size-8 shrink-0 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100 focus-visible:opacity-100" aria-label={`Delete conversation ${conversation.title}`} disabled={deleteDisabled} onClick={() => onDelete(conversation.id)}><Trash2 /></Button></div>
19
+ );
20
+ }
21
+
22
+ type ConversationListProps = {
23
+ ref?: Ref<HTMLElement>;
24
+ conversations: UiConversation[];
25
+ activeConversationId: string | null;
26
+ deletingConversationId: string | null;
27
+ loading: boolean;
28
+ onSelect: (id: string) => void;
29
+ onDelete: (id: string) => void;
30
+ };
31
+
32
+ /** The sidebar's list of conversations. Its root element is the one the
33
+ * conversation-list listener feeds, so the page passes a ref for it. */
34
+ export function ConversationList({ ref, conversations, activeConversationId, deletingConversationId, loading, onSelect, onDelete }: ConversationListProps) {
35
+ return (
36
+ <nav ref={ref} id="conversations" aria-label="Conversations" className="flex-1 space-y-1 overflow-y-auto px-2">{conversations.map((conversation) => <ConversationItem key={conversation.id} conversation={conversation} active={conversation.id === activeConversationId} deleteDisabled={deletingConversationId !== null} onSelect={onSelect} onDelete={onDelete} />)}{!conversations.length && !loading && <p className="px-3 py-8 text-center text-xs text-muted-foreground">No conversations yet. Start one to develop an idea.</p>}</nav>
37
+ );
38
+ }
@@ -80,7 +80,7 @@ export const createFirebaseChatGateway = (): ChatPageServices => {
80
80
  },
81
81
  },
82
82
  presence: {
83
- observe: (callback) => presence.observe((entries) => callback(entries.map(toUiPresence))),
83
+ observe: (callback, options) => presence.observe((entries) => callback(entries.map(toUiPresence)), options),
84
84
  },
85
85
  notifications: {
86
86
  enable: async (onMessage) => (await notifications.enable((message) => onMessage({ title: message.title, body: message.body }))) !== null,
@@ -90,14 +90,14 @@ export const createFirebaseChatGateway = (): ChatPageServices => {
90
90
  list: async () => (await conversations.list()).items.map(toUiConversation),
91
91
  create: (input) => conversations.create(input),
92
92
  delete: (id) => conversations.delete(id as Conversation['id']),
93
- observe: (id, callback) => conversations.observe(id as Conversation['id'], (value) => callback(toUiConversation(value))),
94
- observeList: (callback) => conversations.observeList((values) => callback(values.map(toUiConversation))),
93
+ observe: (id, callback, options) => conversations.observe(id as Conversation['id'], (value) => callback(toUiConversation(value)), options),
94
+ observeList: (callback, options) => conversations.observeList((values) => callback(values.map(toUiConversation)), options),
95
95
  },
96
96
  messages: {
97
97
  list: async (conversationId) => (await messages.list(conversationId)).items.map(toUiMessage),
98
98
  appendUserMessage: (input) => messages.appendUserMessage({ ...input, conversationId: asConversationId(input.conversationId) }),
99
99
  appendAssistantMessage: (input) => messages.appendAssistantMessage({ ...input, conversationId: asConversationId(input.conversationId) }),
100
- observeRecent: (conversationId, callback) => messages.observeRecent(conversationId, (value) => callback(value.map(toUiMessage))),
100
+ observeRecent: (conversationId, callback, options) => messages.observeRecent(conversationId, (value) => callback(value.map(toUiMessage)), options),
101
101
  },
102
102
  ai: {
103
103
  stream: async (input, onChunk, onThought, onTool, onUsage, onWorkspaceChanged) => {
@@ -0,0 +1,156 @@
1
+ import { useEffect, useRef, useState, type Ref } from 'react';
2
+ import { Check, CheckCircle2, ChevronsUpDown, Copy, LoaderCircle, TerminalSquare, XCircle } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
5
+ import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader } from '@/components/ui/message';
6
+ import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';
7
+ import { initials } from './chat-format';
8
+ import type { UiMessage, UiToolCall, UiUsage, UiUser } from './chat-types';
9
+ import { MarkdownMessage } from './markdown-message';
10
+
11
+ function CopyMessageButton({ text }: { text: string }) {
12
+ const [copied, setCopied] = useState(false);
13
+
14
+ const copy = async () => {
15
+ try {
16
+ await navigator.clipboard.writeText(text);
17
+ setCopied(true);
18
+ window.setTimeout(() => setCopied(false), 1600);
19
+ } catch {
20
+ setCopied(false);
21
+ }
22
+ };
23
+
24
+ return (
25
+ <Button variant="ghost" size="icon-xs" aria-label={copied ? 'Message copied' : 'Copy message'} title={copied ? 'Message copied' : 'Copy message'} onClick={() => void copy()} disabled={!text}>
26
+ {copied ? <Check /> : <Copy />}
27
+ </Button>
28
+ );
29
+ }
30
+
31
+ function StartPlanCard({ onStart, onDecline, onSendDirection }: { onStart: () => void; onDecline: () => void; onSendDirection: (direction: string) => void }) {
32
+ const [editing, setEditing] = useState(false);
33
+ const [direction, setDirection] = useState('');
34
+
35
+ const sendDirection = () => {
36
+ const value = direction.trim();
37
+ if (!value) return;
38
+ onSendDirection(value);
39
+ };
40
+
41
+ return (
42
+ <section aria-label="Start plan" className="mt-2 max-w-full rounded-lg border border-primary/30 bg-primary/5 p-3">
43
+ <div className="flex items-start justify-between gap-3">
44
+ <div className="min-w-0">
45
+ <p className="text-sm font-medium">Ready to start the plan?</p>
46
+ <p className="mt-1 text-xs leading-5 text-muted-foreground">PyChat can turn this direction into the first actionable step.</p>
47
+ </div>
48
+ <span className="shrink-0 rounded-md bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">Plan</span>
49
+ </div>
50
+ {editing && <textarea value={direction} onChange={(event) => setDirection(event.target.value)} onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') sendDirection(); }} autoFocus rows={2} placeholder="Tell PyChat what to change…" aria-label="What should PyChat change?" className="mt-3 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50" />}
51
+ <div className="mt-3 flex flex-wrap items-center gap-2">
52
+ <Button size="sm" onClick={onStart}>Start plan</Button>
53
+ {editing ? <Button size="sm" variant="secondary" onClick={sendDirection} disabled={!direction.trim()}>Send direction</Button> : <Button size="sm" variant="outline" onClick={() => setEditing(true)}>Tell PyChat what to change</Button>}
54
+ <Button size="sm" variant="ghost" onClick={onDecline}>Decline</Button>
55
+ </div>
56
+ </section>
57
+ );
58
+ }
59
+
60
+ function ToolActivity({ calls }: { calls: UiToolCall[] }) {
61
+ if (!calls.length) return null;
62
+ return (
63
+ <Collapsible className="mb-1 min-w-0 w-full max-w-full rounded-lg border bg-background/40 px-3 py-2">
64
+ <div className="flex items-center justify-between gap-3">
65
+ <span className="flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground"><TerminalSquare className="size-3.5 shrink-0" /> Workspace activity <span className="font-normal">· {calls.length}</span></span>
66
+ <CollapsibleTrigger render={<Button variant="ghost" size="icon" className="size-7" />} aria-label="Toggle workspace activity"><ChevronsUpDown /></CollapsibleTrigger>
67
+ </div>
68
+ <CollapsibleContent className="space-y-1.5 pt-2">
69
+ {calls.map((call) => <div key={call.id} className="flex min-w-0 items-center gap-2 text-xs"><span className="shrink-0 text-muted-foreground">{call.status === 'running' ? <LoaderCircle className="size-3.5 animate-spin" /> : call.status === 'complete' ? <CheckCircle2 className="size-3.5 text-primary" /> : <XCircle className="size-3.5 text-destructive" />}</span><span className="truncate font-mono">{call.name}</span><span className="truncate text-muted-foreground">{call.summary ?? (call.status === 'running' ? 'Running…' : '')}</span></div>)}
70
+ </CollapsibleContent>
71
+ </Collapsible>
72
+ );
73
+ }
74
+
75
+ function UsageSummary({ usage }: { usage?: UiUsage }) {
76
+ if (!usage) return null;
77
+ const total = usage.inputTokens + usage.outputTokens;
78
+ return <span className="inline-flex items-center gap-1.5"><span>{total.toLocaleString()} tokens</span>{usage.reasoningTokens ? <span>· {usage.reasoningTokens.toLocaleString()} thinking</span> : null}</span>;
79
+ }
80
+
81
+ function ThoughtsDisclosure({ thoughts, streaming }: { thoughts: string; streaming: boolean }) {
82
+ return (
83
+ <Collapsible defaultOpen={streaming} className="mb-1 min-w-0 w-full max-w-full rounded-lg border border-dashed bg-muted/30 px-3 py-2">
84
+ <div className="flex items-center justify-between gap-3">
85
+ <span className="text-xs font-medium text-muted-foreground">Thoughts</span>
86
+ <CollapsibleTrigger render={<Button variant="ghost" size="icon" className="size-7" />} aria-label="Toggle thoughts">
87
+ <ChevronsUpDown />
88
+ </CollapsibleTrigger>
89
+ </div>
90
+ <CollapsibleContent className="pt-2">
91
+ <MarkdownMessage content={thoughts} className="max-w-full text-xs leading-5 text-muted-foreground" />
92
+ </CollapsibleContent>
93
+ </Collapsible>
94
+ );
95
+ }
96
+
97
+ function StreamingFollow({ revision, streaming }: { revision: string; streaming: boolean }) {
98
+ const { end } = useMessageScrollerScrollable();
99
+ const { scrollToEnd } = useMessageScroller();
100
+ const followingRef = useRef(true);
101
+
102
+ useEffect(() => {
103
+ if (!streaming) {
104
+ followingRef.current = true;
105
+ return;
106
+ }
107
+ if (!end) followingRef.current = false;
108
+ }, [end, streaming]);
109
+
110
+ useEffect(() => {
111
+ if (!streaming || !followingRef.current) return;
112
+ const frame = window.requestAnimationFrame(() => scrollToEnd());
113
+ return () => window.cancelAnimationFrame(frame);
114
+ }, [revision, scrollToEnd, streaming]);
115
+
116
+ return null;
117
+ }
118
+
119
+ type MessageBubbleProps = {
120
+ message: UiMessage;
121
+ user: UiUser | null;
122
+ planReady: boolean;
123
+ onRetry: () => void;
124
+ onStartPlan: () => void;
125
+ onDeclinePlan: () => void;
126
+ onSendDirection: (direction: string) => void;
127
+ };
128
+
129
+ /** One message in the thread, with its tool activity, thoughts, usage, and the
130
+ * plan card the assistant offers after a plan turn. */
131
+ function MessageBubble({ message, user, planReady, onRetry, onStartPlan, onDeclinePlan, onSendDirection }: MessageBubbleProps) {
132
+ return (
133
+ <Message align={message.role === 'user' ? 'end' : 'start'}><MessageAvatar className={message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'}><span className="grid size-8 place-items-center text-xs font-semibold">{message.role === 'user' ? initials(user) : '✦'}</span></MessageAvatar><MessageContent className={message.role === 'assistant' ? 'w-full max-w-[min(42rem,78%)]' : 'w-fit max-w-[min(42rem,78%)]'}><MessageHeader>{message.role === 'user' ? 'You' : 'PyChat'}</MessageHeader>{message.role === 'assistant' && message.toolCalls && <ToolActivity calls={message.toolCalls} />}{message.role === 'assistant' && message.thoughts && <ThoughtsDisclosure thoughts={message.thoughts} streaming={message.status === 'streaming'} />}<div className={`w-fit max-w-full rounded-xl px-4 py-3 leading-7 shadow-sm ${message.role === 'user' ? 'bg-primary text-primary-foreground' : 'border-transparent bg-muted text-foreground'}`}>{message.text ? message.role === 'assistant' ? <MarkdownMessage content={message.text} /> : <p className="whitespace-pre-wrap text-sm">{message.text}</p> : <span className="inline-flex gap-1 text-muted-foreground"><i className="size-1.5 animate-pulse rounded-full bg-current" /><i className="size-1.5 animate-pulse rounded-full bg-current [animation-delay:150ms]" /><i className="size-1.5 animate-pulse rounded-full bg-current [animation-delay:300ms]" /></span>}</div>{message.status === 'streaming' && <MessageFooter>Thinking…</MessageFooter>}{message.status === 'error' && <MessageFooter><span role="alert">Response unavailable.</span><Button variant="ghost" size="sm" onClick={onRetry}>Retry</Button></MessageFooter>}{message.role === 'assistant' && message.usage && <MessageFooter><UsageSummary usage={message.usage} /></MessageFooter>}<div className={message.role === 'user' ? 'self-end' : 'self-start'}><CopyMessageButton text={message.text} /></div>{message.role === 'assistant' && planReady && <StartPlanCard onStart={onStartPlan} onDecline={onDeclinePlan} onSendDirection={onSendDirection} />}</MessageContent></Message>
134
+ );
135
+ }
136
+
137
+ type MessageThreadProps = {
138
+ ref?: Ref<HTMLDivElement>;
139
+ messages: UiMessage[];
140
+ user: UiUser | null;
141
+ streaming: boolean;
142
+ revision: string;
143
+ planReadyMessageId: string | null;
144
+ onRetry: () => void;
145
+ onStartPlan: () => void;
146
+ onDeclinePlan: () => void;
147
+ onSendDirection: (direction: string) => void;
148
+ };
149
+
150
+ /** The scrolling thread: the intro, then one bubble per message. Its root
151
+ * element is the one the message listener feeds. */
152
+ export function MessageThread({ ref, messages, user, streaming, revision, planReadyMessageId, onRetry, onStartPlan, onDeclinePlan, onSendDirection }: MessageThreadProps) {
153
+ return (
154
+ <div ref={ref} id="message-thread" className="flex min-h-0 flex-1 flex-col"><MessageScrollerProvider autoScroll><MessageScroller className="min-h-0 flex-1"><StreamingFollow revision={revision} streaming={streaming} /><MessageScrollerViewport className="px-4 py-8 sm:px-8"><MessageScrollerContent className="mx-auto w-full max-w-3xl gap-8"><MessageScrollerItem messageId="intro" scrollAnchor={messages.length <= 1}><div className="mx-auto max-w-xl py-16 text-center"><div className="mx-auto mb-5 grid size-12 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-sm"><span className="text-lg">✦</span></div><h1 className="text-2xl font-semibold tracking-tight">A clearer place to think.</h1><p className="mt-2 text-sm leading-6 text-muted-foreground">Explore possibilities, turn ideas into plans, or pressure-test a direction. Your context stays with you across conversations.</p></div></MessageScrollerItem><MessageGroup className="gap-8">{messages.map((message, index) => <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === 'user'} className="chat-message-in" style={{ animationDelay: `${Math.min(index * 35, 350)}ms` }}><MessageBubble message={message} user={user} planReady={Boolean(planReadyMessageId) && (message.id === planReadyMessageId || message.clientMessageId === planReadyMessageId)} onRetry={onRetry} onStartPlan={onStartPlan} onDeclinePlan={onDeclinePlan} onSendDirection={onSendDirection} /></MessageScrollerItem>)}</MessageGroup></MessageScrollerContent></MessageScrollerViewport><MessageScrollerButton /></MessageScroller></MessageScrollerProvider></div>
155
+ );
156
+ }
@@ -0,0 +1,23 @@
1
+ import type { Ref } from 'react';
2
+ import { LogIn, LogOut } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { initials } from './chat-format';
5
+ import type { UiUser } from './chat-types';
6
+
7
+ type PresenceBarProps = {
8
+ ref?: Ref<HTMLDivElement>;
9
+ user: UiUser | null;
10
+ onlineCount: number;
11
+ authLoading: boolean;
12
+ onSignIn: () => void;
13
+ onSignOut: () => void;
14
+ };
15
+
16
+ /** The foot of the sidebar: the signed-in user with the online count and a
17
+ * sign-out control, or a sign-in button. Its root element is the one the
18
+ * presence listener feeds. */
19
+ export function PresenceBar({ ref, user, onlineCount, authLoading, onSignIn, onSignOut }: PresenceBarProps) {
20
+ return (
21
+ <div ref={ref} id="presence-bar" className="border-t p-3">{user ? <div className="flex items-center gap-2"><div className="relative shrink-0"><div className="grid size-8 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">{initials(user)}</div><span className="absolute -end-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-card bg-emerald-500" aria-hidden="true" /></div><div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{user.displayName ?? 'Signed in'}</p><p className="truncate text-xs text-muted-foreground">{onlineCount} online · {user.email}</p></div><Button variant="ghost" size="icon" aria-label="Sign out" onClick={onSignOut}><LogOut /></Button></div> : <Button className="w-full gap-2" onClick={onSignIn} disabled={authLoading}><LogIn /> Sign in to continue</Button>}</div>
22
+ );
23
+ }
@@ -11,11 +11,15 @@ import path from 'node:path';
11
11
  // self-contained sandbox preview you can serve under `pyric sandbox`, build with a
12
12
  // non-production mode: `vite build --mode development` (see the `build:sandbox`
13
13
  // script). That output is marked and can never be deployed.
14
- export default defineConfig({
14
+ export default defineConfig(({ mode }) => ({
15
15
  plugins: [react(), tailwindcss(), pyric()],
16
+ // The sandbox build keeps function names: pyric's listener attribution reads
17
+ // the owning React component's name from the render stack, and minification
18
+ // would replace it with a mangled one. The production build is unaffected.
19
+ build: mode === 'production' ? {} : { minify: false },
16
20
  resolve: {
17
21
  alias: {
18
22
  '@': path.resolve(__dirname, './src'),
19
23
  },
20
24
  },
21
- });
25
+ }));