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.
- package/dist/cli.js +331 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +199 -16
- package/dist/index.js +354 -166
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +242 -73
- package/dist/server.js +985 -374
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +322 -73
- package/dist/worker.js +961 -343
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
|