deepspace 0.3.10 → 0.4.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.
Files changed (120) hide show
  1. package/dist/cli.js +331 -115
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +199 -16
  4. package/dist/index.js +354 -166
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +242 -73
  7. package/dist/server.js +985 -374
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +322 -73
  10. package/dist/worker.js +961 -343
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,692 @@
1
+ /**
2
+ * AiChatPage — app shell with a chat card on the right (mirrors Miyagi's
3
+ * create-mode chat layout).
4
+ *
5
+ * Layout:
6
+ * [ app card, flex-1 ] [gap] [ chat card ]
7
+ *
8
+ * The chat card has a header bar at the top with the editable title on the
9
+ * left and three action icons on the right: New chat, History, Close.
10
+ * Chat history is a slide-in overlay (not an inline rail) — clicking the
11
+ * History icon overlays the chat panel with a list of past conversations.
12
+ * When the chat is closed, a single floating button at the top-right of
13
+ * the page reopens it.
14
+ *
15
+ * - Chat card: width animates 0 ↔ chatWidth. Inner content is absolutely
16
+ * positioned at fixed width so the panel doesn't reflow during the
17
+ * width animation — it just gets clipped by the card's overflow.
18
+ * - Resize handle sits on the chat card's left edge.
19
+ */
20
+
21
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
+ import {
23
+ History,
24
+ PanelRightClose,
25
+ PanelRightOpen,
26
+ Pencil,
27
+ Plus,
28
+ X,
29
+ } from 'lucide-react'
30
+ import { useAuth, AuthOverlay, getAuthToken, useQuery } from 'deepspace'
31
+ // Paths resolve post-install (page → src/pages/, components → src/components/).
32
+ import { ChatPanel } from '../components/ChatPanel'
33
+
34
+ const CHAT_W_MIN = 320
35
+ const CHAT_W_MAX = 720
36
+ const CHAT_W_DEFAULT = 380
37
+ const STORAGE_OPEN = 'ai-chat-open'
38
+ const STORAGE_WIDTH = 'ai-chat-width'
39
+
40
+ const EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
41
+
42
+ interface ChatRow {
43
+ userId: string
44
+ title?: string
45
+ }
46
+
47
+ function loadOpen(): boolean {
48
+ try { return localStorage.getItem(STORAGE_OPEN) !== '0' } catch { return true }
49
+ }
50
+ function loadWidth(): number {
51
+ try {
52
+ const v = parseInt(localStorage.getItem(STORAGE_WIDTH) ?? '', 10)
53
+ if (!isNaN(v) && v >= CHAT_W_MIN && v <= CHAT_W_MAX) return v
54
+ } catch { /* ignore */ }
55
+ return CHAT_W_DEFAULT
56
+ }
57
+ // Compact relative timestamp: now / 5m / 3h / 2d / 3w. Falls back to '' when
58
+ // the input isn't a parseable ISO string.
59
+ function formatRelative(ts?: string): string {
60
+ if (!ts) return ''
61
+ const t = Date.parse(ts)
62
+ if (Number.isNaN(t)) return ''
63
+ const diff = Math.max(0, Date.now() - t)
64
+ if (diff < 60_000) return 'now'
65
+ const m = Math.floor(diff / 60_000)
66
+ if (m < 60) return `${m}m`
67
+ const h = Math.floor(m / 60)
68
+ if (h < 24) return `${h}h`
69
+ const d = Math.floor(h / 24)
70
+ if (d < 7) return `${d}d`
71
+ return `${Math.floor(d / 7)}w`
72
+ }
73
+
74
+ export default function AiChatPage() {
75
+ const { isLoaded, isSignedIn, userId } = useAuth()
76
+ const [showAuth, setShowAuth] = useState(false)
77
+ const [activeChatId, setActiveChatId] = useState<string | null>(null)
78
+
79
+ const [chatOpen, setChatOpen] = useState<boolean>(loadOpen)
80
+ const [chatWidth, setChatWidth] = useState<number>(loadWidth)
81
+ const [historyOpen, setHistoryOpen] = useState(false)
82
+ const [dragging, setDragging] = useState(false)
83
+ const [creatingChat, setCreatingChat] = useState(false)
84
+ const [createError, setCreateError] = useState<string | null>(null)
85
+
86
+ const chatCardRef = useRef<HTMLDivElement>(null)
87
+
88
+ useEffect(() => { try { localStorage.setItem(STORAGE_OPEN, chatOpen ? '1' : '0') } catch { /* ignore */ } }, [chatOpen])
89
+ useEffect(() => { try { localStorage.setItem(STORAGE_WIDTH, String(chatWidth)) } catch { /* ignore */ } }, [chatWidth])
90
+
91
+ const { records: chatsRaw } = useQuery<ChatRow>('ai-chats', {
92
+ where: { userId: userId ?? '__none__' },
93
+ orderBy: 'updatedAt',
94
+ orderDir: 'desc',
95
+ limit: 50,
96
+ })
97
+
98
+ // Re-sort newest-first on the client. The SDK's useQuery applies orderBy
99
+ // server-side at the initial fetch but appends WebSocket-broadcasted
100
+ // inserts to the tail of the local cache, so without this a freshly-
101
+ // created chat would land at the bottom until the next page refresh.
102
+ const chats = useMemo(() => {
103
+ return [...chatsRaw].sort((a, b) => {
104
+ const aT = Date.parse(a.updatedAt ?? a.createdAt ?? '') || 0
105
+ const bT = Date.parse(b.updatedAt ?? b.createdAt ?? '') || 0
106
+ return bT - aT
107
+ })
108
+ }, [chatsRaw])
109
+
110
+ const activeChat = useMemo(
111
+ () => chats.find((c) => c.recordId === activeChatId) ?? null,
112
+ [chats, activeChatId],
113
+ )
114
+ const activeTitle = (activeChat?.data.title ?? '').trim() || (activeChatId ? 'Untitled' : 'New chat')
115
+
116
+ // Resize drag — instant width updates while dragging (no transition).
117
+ // Window blur and visibility loss force-end the drag so the body cursor
118
+ // never gets stuck on `col-resize` if the user alt-tabs mid-drag.
119
+ useEffect(() => {
120
+ if (!dragging) return
121
+ function onMove(e: MouseEvent) {
122
+ if (!chatCardRef.current) return
123
+ // Width is the distance from mouse-x to the chat card's right edge.
124
+ // The rail sits to the right of this card on the page background and
125
+ // is not part of the chat width any more.
126
+ const rect = chatCardRef.current.getBoundingClientRect()
127
+ const raw = rect.right - e.clientX
128
+ setChatWidth(Math.max(CHAT_W_MIN, Math.min(CHAT_W_MAX, raw)))
129
+ }
130
+ function endDrag() { setDragging(false) }
131
+ document.addEventListener('mousemove', onMove)
132
+ document.addEventListener('mouseup', endDrag)
133
+ window.addEventListener('blur', endDrag)
134
+ document.addEventListener('visibilitychange', endDrag)
135
+ const prevCursor = document.body.style.cursor
136
+ const prevSelect = document.body.style.userSelect
137
+ document.body.style.cursor = 'col-resize'
138
+ document.body.style.userSelect = 'none'
139
+ return () => {
140
+ document.removeEventListener('mousemove', onMove)
141
+ document.removeEventListener('mouseup', endDrag)
142
+ window.removeEventListener('blur', endDrag)
143
+ document.removeEventListener('visibilitychange', endDrag)
144
+ document.body.style.cursor = prevCursor
145
+ document.body.style.userSelect = prevSelect
146
+ }
147
+ }, [dragging])
148
+
149
+ const handleSelect = useCallback((id: string) => {
150
+ setActiveChatId(id)
151
+ setChatOpen(true)
152
+ setHistoryOpen(false)
153
+ }, [])
154
+
155
+ // Stable callback for the history overlay's onClose. Without this, an
156
+ // inline arrow would change identity on every parent re-render and the
157
+ // overlay's focus-management effect (which lists onClose in its deps)
158
+ // would re-fire on every WS broadcast — losing the user's tab position.
159
+ const closeHistory = useCallback(() => setHistoryOpen(false), [])
160
+
161
+ const handleNew = useCallback(async () => {
162
+ // Eager create: row appears in the sidebar at click-time. We set chatId
163
+ // to null up front so the panel renders the empty state immediately,
164
+ // and flip `creatingChat` so the panel's input is suspended — without
165
+ // that gate a fast typist could send before our POST resolves and the
166
+ // panel would also auto-create, spawning a second chat.
167
+ setActiveChatId(null)
168
+ setChatOpen(true)
169
+ setHistoryOpen(false)
170
+ setCreateError(null)
171
+ setCreatingChat(true)
172
+ try {
173
+ const token = await getAuthToken()
174
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
175
+ if (token) headers.Authorization = `Bearer ${token}`
176
+ const res = await fetch('/api/ai/chats', { method: 'POST', headers })
177
+ if (!res.ok) throw new Error(`create chat failed: ${res.status}`)
178
+ const data = (await res.json()) as { chat?: { id?: string } }
179
+ if (data.chat?.id) setActiveChatId(data.chat.id)
180
+ } catch (err) {
181
+ console.error('[ai-chat-page] create chat failed:', err)
182
+ setCreateError(err instanceof Error ? err.message : 'Failed to create chat')
183
+ } finally {
184
+ setCreatingChat(false)
185
+ }
186
+ }, [])
187
+
188
+ const handleDelete = useCallback(async (id: string) => {
189
+ // Don't clear `activeChatId` until the DELETE actually succeeds: if it
190
+ // fails, the row stays in the user's sidebar (because useQuery still
191
+ // reflects it server-side) and we don't want the UI to mislead the user
192
+ // into thinking it's gone. The chat-switch effect in ChatPanel aborts
193
+ // any in-flight stream when activeChatId flips id→null, so the orphan
194
+ // assistant write we'd otherwise produce on cascade-delete is prevented
195
+ // by F1 (abort on id→null) — not by ordering this call before the fetch.
196
+ try {
197
+ const token = await getAuthToken()
198
+ const headers: Record<string, string> = {}
199
+ if (token) headers.Authorization = `Bearer ${token}`
200
+ const res = await fetch(`/api/ai/chats/${id}`, { method: 'DELETE', headers })
201
+ if (!res.ok) throw new Error(`delete failed: ${res.status}`)
202
+ setActiveChatId((cur) => (cur === id ? null : cur))
203
+ } catch (err) {
204
+ console.error('[ai-chat-page] delete failed:', err)
205
+ setCreateError(err instanceof Error ? `Couldn't delete chat: ${err.message}` : "Couldn't delete chat")
206
+ }
207
+ }, [])
208
+
209
+ const handleRename = useCallback(async (id: string, title: string) => {
210
+ try {
211
+ const token = await getAuthToken()
212
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
213
+ if (token) headers.Authorization = `Bearer ${token}`
214
+ const res = await fetch(`/api/ai/chats/${id}`, {
215
+ method: 'PATCH',
216
+ headers,
217
+ body: JSON.stringify({ title }),
218
+ })
219
+ if (!res.ok) throw new Error(`rename failed: ${res.status}`)
220
+ } catch (err) {
221
+ console.error('[ai-chat-page] rename failed:', err)
222
+ }
223
+ }, [])
224
+
225
+ if (!isLoaded) {
226
+ return (
227
+ <div className="flex h-full items-center justify-center text-muted-foreground">
228
+ Loading...
229
+ </div>
230
+ )
231
+ }
232
+
233
+ if (!isSignedIn || !userId) {
234
+ return (
235
+ <>
236
+ <div className="flex h-full items-center justify-center px-4">
237
+ <div className="max-w-md space-y-4 text-center">
238
+ <h2 className="text-xl font-semibold text-foreground">Sign in to use the assistant</h2>
239
+ <p className="text-sm text-muted-foreground">
240
+ The AI assistant inspects live app data using your permissions.
241
+ </p>
242
+ <button
243
+ onClick={() => setShowAuth(true)}
244
+ className="rounded-full bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
245
+ >
246
+ Sign in
247
+ </button>
248
+ </div>
249
+ </div>
250
+ {showAuth && <AuthOverlay onClose={() => setShowAuth(false)} />}
251
+ </>
252
+ )
253
+ }
254
+
255
+ const chatCardW = chatOpen ? chatWidth : 0
256
+
257
+ return (
258
+ <div className="relative flex h-full min-h-0 w-full gap-2 bg-muted/40 p-2">
259
+ <div className="flex h-full min-h-0 flex-1 items-center justify-center rounded-xl border border-border bg-background">
260
+ <div className="max-w-md space-y-2 px-8 text-center">
261
+ <h2 className="text-lg font-medium tracking-tight text-foreground">Your app content</h2>
262
+ <p className="text-sm text-muted-foreground">
263
+ Talk to the assistant on the right — it can query your data with server-side tools.
264
+ </p>
265
+ </div>
266
+ </div>
267
+
268
+ {/* Chat region — no card chrome (matches Miyagi: transparent container,
269
+ no border, no shadow, no rounded corners). The chat is just content
270
+ laid out vertically; the only visible separator from the app card is
271
+ the gap and the resize handle's hover line. */}
272
+ <div
273
+ ref={chatCardRef}
274
+ style={{
275
+ width: chatCardW,
276
+ marginLeft: chatOpen ? 0 : -8,
277
+ transition: dragging ? 'none' : `width 220ms ${EASE}, margin-left 220ms ${EASE}`,
278
+ }}
279
+ className="relative h-full shrink-0"
280
+ >
281
+ {chatOpen && <ResizeHandle onStart={() => setDragging(true)} dragging={dragging} />}
282
+
283
+ {/* Inner clip layer holds ONLY the chat panel — keeping the history
284
+ overlay outside this layer means its mount/unmount can't disturb
285
+ the right-anchored chat panel's layout (which used to cause a
286
+ visible "slide-left-and-back" sweep on the chat content). */}
287
+ <div className="absolute inset-0 overflow-hidden">
288
+ <div className="absolute inset-y-0 right-0" style={{ width: chatWidth }}>
289
+ <ChatPanel
290
+ chatId={activeChatId}
291
+ userId={userId}
292
+ onChatCreated={setActiveChatId}
293
+ disabled={creatingChat}
294
+ className="!bg-transparent"
295
+ compact
296
+ header={
297
+ <>
298
+ <ChatHeaderBar
299
+ chatId={activeChatId}
300
+ title={activeTitle}
301
+ onRename={handleRename}
302
+ onNew={handleNew}
303
+ onHistory={() => setHistoryOpen(true)}
304
+ />
305
+ {createError && (
306
+ <div role="alert" className="mx-4 mb-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
307
+ <div className="flex items-start gap-2">
308
+ <span className="flex-1 leading-relaxed">{createError}</span>
309
+ <button
310
+ type="button"
311
+ onClick={() => { void handleNew() }}
312
+ className="rounded-md border border-destructive/30 px-2 py-0.5 text-[12px] font-medium hover:bg-destructive/10"
313
+ >
314
+ Retry
315
+ </button>
316
+ <button
317
+ type="button"
318
+ onClick={() => setCreateError(null)}
319
+ aria-label="Dismiss"
320
+ className="rounded-md px-1 text-[14px] leading-none hover:bg-destructive/10"
321
+ >
322
+ ×
323
+ </button>
324
+ </div>
325
+ </div>
326
+ )}
327
+ </>
328
+ }
329
+ />
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ {/* History overlay — sibling of app-card / chat-card in the outer
335
+ flex (NOT inside chat-card). `position: absolute` inherits the
336
+ outer flex's padding edges, so the panel lines up with the chat
337
+ region's vertical bounds (top: 8, bottom: 8) instead of stretching
338
+ to the viewport. Living outside chat-card also keeps its
339
+ mount/unmount animation from disturbing the chat panel's layout. */}
340
+ <ChatHistoryOverlay
341
+ open={historyOpen}
342
+ chats={chats}
343
+ activeChatId={activeChatId}
344
+ onClose={closeHistory}
345
+ onSelect={handleSelect}
346
+ onDelete={handleDelete}
347
+ />
348
+
349
+ {/* Single page-level toggle — same button in both states, just swaps
350
+ the icon. Position and chrome are identical when chat is open vs
351
+ closed, so toggling never moves the button visually. The chat
352
+ region animates open/closed behind it. */}
353
+ <PanelToggleButton
354
+ open={chatOpen}
355
+ onClick={() => setChatOpen((o) => !o)}
356
+ />
357
+ </div>
358
+ )
359
+ }
360
+
361
+ // ============================================================================
362
+ // Chat header bar — title on the left, action icons on the right.
363
+ // Mirrors Miyagi's FloatingChatHeader (`title | new chat | history | close`).
364
+ // ============================================================================
365
+
366
+ function ChatHeaderBar({
367
+ chatId, title, onRename, onNew, onHistory,
368
+ }: {
369
+ chatId: string | null
370
+ title: string
371
+ onRename: (id: string, title: string) => Promise<void>
372
+ onNew: () => void
373
+ onHistory: () => void
374
+ }) {
375
+ // pr-12 leaves room for the page-level toggle button that floats at the
376
+ // top-right corner. Without this, [history] would sit at the same x as
377
+ // the toggle and they'd visually overlap.
378
+ return (
379
+ <div className="flex h-11 shrink-0 items-center gap-1 pl-2 pr-12">
380
+ <div className="min-w-0 flex-1">
381
+ <ChatTitleBar chatId={chatId} title={title} onRename={onRename} />
382
+ </div>
383
+ <div className="flex shrink-0 items-center gap-0.5">
384
+ <HeaderIconButton label="New chat" onClick={onNew}>
385
+ <Plus className="h-3.5 w-3.5" aria-hidden="true" />
386
+ </HeaderIconButton>
387
+ <HeaderIconButton label="History" onClick={onHistory}>
388
+ <History className="h-3.5 w-3.5" aria-hidden="true" />
389
+ </HeaderIconButton>
390
+ </div>
391
+ </div>
392
+ )
393
+ }
394
+
395
+ function HeaderIconButton({
396
+ label, onClick, children,
397
+ }: {
398
+ label: string
399
+ onClick: () => void
400
+ children: React.ReactNode
401
+ }) {
402
+ return (
403
+ <button
404
+ type="button"
405
+ onClick={onClick}
406
+ title={label}
407
+ aria-label={label}
408
+ className="inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
409
+ >
410
+ {children}
411
+ </button>
412
+ )
413
+ }
414
+
415
+ // ============================================================================
416
+ // Chat history overlay — slides in from the right inside the chat card.
417
+ // Mirrors Miyagi's ChatHistoryModal (slide-in side panel with gradient backdrop).
418
+ // ============================================================================
419
+
420
+ function ChatHistoryOverlay({
421
+ open, chats, activeChatId, onClose, onSelect, onDelete,
422
+ }: {
423
+ open: boolean
424
+ chats: Array<{ recordId: string; data: ChatRow; createdAt?: string; updatedAt?: string }>
425
+ activeChatId: string | null
426
+ onClose: () => void
427
+ onSelect: (id: string) => void
428
+ onDelete: (id: string) => Promise<void>
429
+ }) {
430
+ const closeButtonRef = useRef<HTMLButtonElement>(null)
431
+
432
+ // Dialog basics: Escape closes; focus moves to the close button on open and
433
+ // returns to the previously-focused element on close. Without this the
434
+ // overlay traps keyboard users (the trigger is hidden behind it).
435
+ useEffect(() => {
436
+ if (!open) return
437
+ const prevFocus = document.activeElement as HTMLElement | null
438
+ closeButtonRef.current?.focus()
439
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
440
+ window.addEventListener('keydown', onKey)
441
+ return () => {
442
+ window.removeEventListener('keydown', onKey)
443
+ prevFocus?.focus?.()
444
+ }
445
+ }, [open, onClose])
446
+
447
+ // Mirrors Miyagi's ChatHistoryModal exactly: a conditional render with no
448
+ // framer-motion, no AnimatePresence, no transforms. Click outside the
449
+ // panel closes (the outer div's onClick); the panel itself stops
450
+ // propagation. Earlier framer-motion versions caused the chat panel to
451
+ // appear to slide left-and-back because their layout calculations +
452
+ // backdrop-filter compositing inside the page tree disturbed the
453
+ // chat-card's layout context. Static positioning has none of that risk.
454
+ if (!open) return null
455
+
456
+ return (
457
+ <div
458
+ className="absolute inset-2 z-40 flex justify-end"
459
+ onClick={onClose}
460
+ >
461
+ <div
462
+ className="absolute inset-0"
463
+ style={{
464
+ // Theme-neutral dim — modal backdrops conventionally use a flat
465
+ // dark tint regardless of theme. Gradient leans heavier on the
466
+ // right (where the panel sits) so the app card on the left stays
467
+ // mostly visible.
468
+ background:
469
+ 'linear-gradient(270deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.35) 35%, rgba(0,0,0,0.15) 100%)',
470
+ backdropFilter: 'blur(2px)',
471
+ WebkitBackdropFilter: 'blur(2px)',
472
+ }}
473
+ aria-hidden="true"
474
+ />
475
+ <div
476
+ role="dialog"
477
+ aria-modal="true"
478
+ aria-label="Chat history"
479
+ onClick={(e) => e.stopPropagation()}
480
+ className="relative flex h-full w-full max-w-[320px] flex-col rounded-2xl border border-border/40 bg-background/95"
481
+ style={{ boxShadow: 'var(--shadow-card)' }}
482
+ >
483
+ <div className="flex shrink-0 items-center justify-between px-4 py-3">
484
+ <span className="text-[12px] font-semibold tracking-wide text-foreground">
485
+ History
486
+ </span>
487
+ <button
488
+ ref={closeButtonRef}
489
+ type="button"
490
+ onClick={onClose}
491
+ aria-label="Close history"
492
+ className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted"
493
+ >
494
+ <X className="h-3 w-3" aria-hidden="true" />
495
+ </button>
496
+ </div>
497
+ <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
498
+ {chats.length === 0 ? (
499
+ <div className="text-[12px] text-muted-foreground">
500
+ No previous conversations.
501
+ </div>
502
+ ) : (
503
+ chats.map((chat) => (
504
+ <ChatHistoryRow
505
+ key={chat.recordId}
506
+ title={chat.data.title}
507
+ timestamp={formatRelative(chat.updatedAt ?? chat.createdAt)}
508
+ active={chat.recordId === activeChatId}
509
+ onSelect={() => onSelect(chat.recordId)}
510
+ onDelete={() => onDelete(chat.recordId)}
511
+ />
512
+ ))
513
+ )}
514
+ </div>
515
+ </div>
516
+ </div>
517
+ )
518
+ }
519
+
520
+ function ChatHistoryRow({
521
+ title, timestamp, active, onSelect, onDelete,
522
+ }: {
523
+ title?: string
524
+ timestamp: string
525
+ active: boolean
526
+ onSelect: () => void
527
+ onDelete: () => void
528
+ }) {
529
+ const display = (title ?? '').trim() || 'Untitled'
530
+
531
+ return (
532
+ <div
533
+ role="button"
534
+ tabIndex={0}
535
+ onClick={onSelect}
536
+ onKeyDown={(e) => {
537
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() }
538
+ }}
539
+ className={`group relative flex w-full shrink-0 cursor-pointer flex-col rounded-lg border border-transparent px-3 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-foreground/20 ${
540
+ active
541
+ ? 'border-border/60 bg-muted'
542
+ : 'hover:border-border hover:bg-muted/60'
543
+ }`}
544
+ >
545
+ <div className="flex items-center gap-2">
546
+ <span className={`min-w-0 flex-1 truncate text-[12px] ${active ? 'font-semibold text-foreground' : 'font-medium text-foreground/90'}`}>
547
+ {display}
548
+ </span>
549
+ <button
550
+ type="button"
551
+ onClick={(e) => { e.stopPropagation(); onDelete() }}
552
+ aria-label={`Delete ${display}`}
553
+ tabIndex={-1}
554
+ className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 opacity-0 transition-all group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive"
555
+ >
556
+ <X className="h-3 w-3" aria-hidden="true" />
557
+ </button>
558
+ </div>
559
+ {timestamp && (
560
+ <span className="mt-0.5 text-[10px] tabular-nums text-foreground/60">
561
+ {timestamp}
562
+ </span>
563
+ )}
564
+ </div>
565
+ )
566
+ }
567
+
568
+ // ============================================================================
569
+ // Panel toggle — single fixed button at top-right that swaps its icon based
570
+ // on chatOpen. Position, size, and chrome are identical in both states so
571
+ // the button NEVER appears to move when toggled — only the chat region
572
+ // animates open/closed behind it.
573
+ // ============================================================================
574
+
575
+ function PanelToggleButton({ open, onClick }: { open: boolean; onClick: () => void }) {
576
+ const label = open ? 'Close assistant' : 'Open assistant'
577
+ return (
578
+ <button
579
+ type="button"
580
+ onClick={onClick}
581
+ aria-label={label}
582
+ title={label}
583
+ className="absolute z-30 inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
584
+ style={{ right: 16, top: 16 }}
585
+ >
586
+ {open
587
+ ? <PanelRightClose className="h-3.5 w-3.5" aria-hidden="true" />
588
+ : <PanelRightOpen className="h-3.5 w-3.5" aria-hidden="true" />}
589
+ </button>
590
+ )
591
+ }
592
+
593
+ // ============================================================================
594
+ // Editable title bar (no border-b — clean, no extra boxes)
595
+ // ============================================================================
596
+
597
+ function ChatTitleBar({
598
+ chatId, title, onRename,
599
+ }: {
600
+ chatId: string | null
601
+ title: string
602
+ onRename: (id: string, title: string) => Promise<void>
603
+ }) {
604
+ const [editing, setEditing] = useState(false)
605
+ const [draft, setDraft] = useState(title)
606
+ const inputRef = useRef<HTMLInputElement>(null)
607
+
608
+ // Sync draft to the latest title — but skip while the user is actively
609
+ // editing, otherwise an incoming server-side rename (e.g. auto-title) would
610
+ // clobber their in-progress text under the cursor.
611
+ useEffect(() => {
612
+ if (!editing) setDraft(title)
613
+ }, [title, editing])
614
+ useEffect(() => {
615
+ if (editing) {
616
+ inputRef.current?.focus()
617
+ inputRef.current?.select()
618
+ }
619
+ }, [editing])
620
+
621
+ function commit() {
622
+ if (!editing) return
623
+ setEditing(false)
624
+ const next = draft.trim()
625
+ if (chatId && next && next !== title) void onRename(chatId, next)
626
+ else setDraft(title)
627
+ }
628
+
629
+ if (!chatId) {
630
+ return (
631
+ <div className="flex h-9 min-w-0 items-center px-2 text-[13px] text-muted-foreground">
632
+ {title}
633
+ </div>
634
+ )
635
+ }
636
+
637
+ if (editing) {
638
+ return (
639
+ <div className="flex h-9 min-w-0 items-center px-2">
640
+ <input
641
+ ref={inputRef}
642
+ value={draft}
643
+ onChange={(e) => setDraft(e.target.value)}
644
+ onBlur={commit}
645
+ onKeyDown={(e) => {
646
+ if (e.key === 'Enter') { e.preventDefault(); commit() }
647
+ if (e.key === 'Escape') { setDraft(title); setEditing(false) }
648
+ }}
649
+ className="block w-full bg-transparent text-[13px] font-medium text-foreground outline-none"
650
+ />
651
+ </div>
652
+ )
653
+ }
654
+
655
+ return (
656
+ <div className="flex h-9 min-w-0 items-center px-2">
657
+ <button
658
+ type="button"
659
+ onClick={() => setEditing(true)}
660
+ title="Rename chat"
661
+ className="group inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5"
662
+ >
663
+ <span className="min-w-0 truncate text-[13px] font-medium text-foreground/85">
664
+ {title}
665
+ </span>
666
+ <Pencil className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100" aria-hidden="true" />
667
+ </button>
668
+ </div>
669
+ )
670
+ }
671
+
672
+ // ============================================================================
673
+ // Resize handle
674
+ // ============================================================================
675
+
676
+ function ResizeHandle({ onStart, dragging }: { onStart: () => void; dragging: boolean }) {
677
+ return (
678
+ <div
679
+ role="separator"
680
+ aria-orientation="vertical"
681
+ onMouseDown={(e) => { e.preventDefault(); onStart() }}
682
+ className="group absolute -left-2 top-0 z-20 h-full w-2 cursor-col-resize"
683
+ >
684
+ <span
685
+ className={`pointer-events-none absolute inset-y-2 left-1/2 w-px -translate-x-1/2 transition-colors ${
686
+ dragging ? 'bg-primary' : 'bg-transparent group-hover:bg-border'
687
+ }`}
688
+ />
689
+ </div>
690
+ )
691
+ }
692
+