deepspace 0.4.5 → 0.5.1

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 (37) hide show
  1. package/README.md +90 -0
  2. package/dist/cli.js +3590 -1485
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +876 -3664
  5. package/dist/index.js +511 -366
  6. package/dist/index.js.map +1 -1
  7. package/dist/server.d.ts +132 -61
  8. package/dist/server.js +149 -110
  9. package/dist/server.js.map +1 -1
  10. package/dist/testing.js.map +1 -1
  11. package/dist/worker.d.ts +128 -59
  12. package/dist/worker.js +144 -99
  13. package/dist/worker.js.map +1 -1
  14. package/features/admin-page/src/AdminPage.tsx +3 -5
  15. package/features/canvas/src/CanvasEditorPage.tsx +0 -2
  16. package/features/canvas/src/components/CanvasView.tsx +1 -1
  17. package/features/docs/src/LibrarySidebar.tsx +7 -1
  18. package/features/docs/src/editor/useDocEditor.ts +5 -1
  19. package/features/docs/src/use-yjs-room-with-awareness.ts +4 -0
  20. package/features/file-attachments/src/AttachmentsPage.tsx +7 -1
  21. package/features/file-attachments/src/useFilePreview.ts +7 -5
  22. package/features/file-manager/src/FileManagerPage.tsx +2 -3
  23. package/features/integration-test/feature.json +10 -3
  24. package/features/integration-test/src/IntegrationTestPage.tsx +15 -12
  25. package/features/landing/src/LandingPage.tsx +1 -1
  26. package/features/landing/src/sections/FeaturesGridSection.tsx +0 -92
  27. package/features/messaging/src/ChatPage.tsx +6 -1
  28. package/features/messaging/src/components/chat/MessageItem.tsx +5 -1
  29. package/features/messaging/src/hooks/useChatChannel.ts +23 -7
  30. package/features/presence-test/feature.json +2 -1
  31. package/features/sidebar/src/AppSidebar.tsx +1 -1
  32. package/features/tasks/src/TasksPage.tsx +1 -1
  33. package/features/testing/feature.json +2 -1
  34. package/features/testing/src/TestPage.tsx +8 -8
  35. package/package.json +8 -4
  36. package/scripts/add-feature.cjs +296 -282
  37. package/dist/testing.d.ts +0 -115
@@ -13,7 +13,7 @@ import { useUser } from 'deepspace'
13
13
  import { useUsers } from 'deepspace'
14
14
  import { useQuery } from 'deepspace'
15
15
  import { useMutations } from 'deepspace'
16
- import { Button, Badge, Avatar, AvatarFallback, useToast } from '@/components/ui'
16
+ import { Button, Badge, Avatar, AvatarFallback, useToast, type BadgeProps } from '@/components/ui'
17
17
  import { ROLES, ROLE_CONFIG, type Role } from 'deepspace'
18
18
 
19
19
  // ============================================================================
@@ -129,8 +129,6 @@ interface UsersPanelProps {
129
129
  }
130
130
 
131
131
  function UsersPanel({ users, currentUserId, onSetRole }: UsersPanelProps) {
132
- const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
133
-
134
132
  // Sort users by last seen
135
133
  const sortedUsers = useMemo(() => {
136
134
  return [...users].sort((a, b) =>
@@ -169,7 +167,7 @@ function UsersPanel({ users, currentUserId, onSetRole }: UsersPanelProps) {
169
167
  </p>
170
168
 
171
169
  <div className="flex items-center gap-2">
172
- <Badge variant={roleConfig.badgeVariant as any}>{roleConfig.title}</Badge>
170
+ <Badge variant={roleConfig.badgeVariant as BadgeProps['variant']}>{roleConfig.title}</Badge>
173
171
  <select
174
172
  value={user.role}
175
173
  onChange={(e) => onSetRole(user.id, e.target.value)}
@@ -211,7 +209,7 @@ function SettingsPanel({ toast }: SettingsPanelProps) {
211
209
 
212
210
  // Query settings (admin-only collection)
213
211
  const { records: settings, status } = useQuery<Setting>('settings')
214
- const { create, put, remove } = useMutations<Setting>('settings')
212
+ const { create, remove } = useMutations<Setting>('settings')
215
213
 
216
214
  const handleCreate = async () => {
217
215
  if (!newKey.trim() || !newValue.trim()) return
@@ -9,7 +9,6 @@
9
9
 
10
10
  import { useMemo } from 'react'
11
11
  import { useParams, useNavigate } from 'react-router-dom'
12
- import { useUser } from 'deepspace'
13
12
  import { useQuery } from 'deepspace'
14
13
  import { CanvasView } from '../../components/canvas/CanvasView'
15
14
 
@@ -29,7 +28,6 @@ interface CanvasDocument {
29
28
  export default function CanvasEditorPage() {
30
29
  const { docId } = useParams<{ docId: string }>()
31
30
  const navigate = useNavigate()
32
- const { user } = useUser()
33
31
 
34
32
  const { records: canvases, status } = useQuery<CanvasDocument>('canvases', {
35
33
  orderBy: 'createdAt',
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { useState, useRef, useCallback, useEffect } from 'react'
6
- import { useCanvas, type CanvasShapeClient } from 'deepspace'
6
+ import { useCanvas } from 'deepspace'
7
7
  import { CanvasToolbar, type CanvasTool } from './CanvasToolbar'
8
8
  import { ShapeRenderer } from './ShapeRenderer'
9
9
  import { Badge } from '@/components/ui'
@@ -126,7 +126,9 @@ function SidebarFolderRenameField({
126
126
  }
127
127
  })
128
128
  return () => cancelAnimationFrame(raf)
129
- }, [folderId])
129
+ // sessionStartRef is a stable ref (only written here); listing it is a no-op
130
+ // but satisfies the rule without changing re-run behavior.
131
+ }, [folderId, sessionStartRef])
130
132
 
131
133
  return (
132
134
  <input
@@ -216,6 +218,10 @@ export function LibrarySidebar({
216
218
 
217
219
  useEffect(() => {
218
220
  return () => {
221
+ // Intentionally read the ref's LATEST value at unmount to clear whatever
222
+ // blur timer is pending; capturing it at effect-setup time (null) would be
223
+ // wrong here since this is a timer ref, not a DOM node.
224
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- must clear the latest pending timer at unmount, not the setup-time value
219
225
  if (folderRenameBlurTimerRef.current) clearTimeout(folderRenameBlurTimerRef.current)
220
226
  }
221
227
  }, [])
@@ -175,7 +175,11 @@ export function useDocEditor({
175
175
  // in dev (the editor view is destroyed and re-created, and any subscriber
176
176
  // that runs in between throws on `editor.view`). Defer to a useEffect.
177
177
  immediatelyRender: false,
178
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ // Bare directive on purpose: naming @typescript-eslint/no-explicit-any
179
+ // here is a hard "unknown rule" error under the scaffolded app's
180
+ // minimal eslint config (which never registers that plugin), while the
181
+ // SDK monorepo's config does enable the rule and needs the suppression.
182
+ // eslint-disable-next-line
179
183
  extensions: extensions as any,
180
184
  editorProps: {
181
185
  transformPastedHTML: stripCollaborationArtifactsFromHTML,
@@ -53,6 +53,10 @@ export function useYjsRoomWithAwareness(
53
53
  const { doc, awareness } = useMemo(() => {
54
54
  const nextDoc = new Y.Doc()
55
55
  return { doc: nextDoc, awareness: new Awareness(nextDoc) }
56
+ // docId is a deliberate semantic dep (not textually used): switching rooms
57
+ // MUST mint a fresh Y.Doc/Awareness so state from the old room never leaks
58
+ // into the new one. Removing it would reuse a stale doc across docIds.
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- docId forces a fresh Y.Doc per room; removing it reuses a stale doc across rooms
56
60
  }, [docId])
57
61
 
58
62
  const wsRef = useRef<WebSocket | null>(null)
@@ -69,7 +69,7 @@ function FileIcon({ category }: { category: FileCategory }) {
69
69
  // Thumbnail Preview (card grid view)
70
70
  // ============================================================================
71
71
 
72
- function ThumbnailPreview({ url, fileName, mimeType, category }: {
72
+ function ThumbnailPreview({ url, fileName, mimeType: _mimeType, category }: {
73
73
  url: string | null
74
74
  fileName: string
75
75
  mimeType: string
@@ -616,9 +616,15 @@ export default function AttachmentsPage() {
616
616
  // Free all blob URLs we created on unmount.
617
617
  useEffect(() => {
618
618
  return () => {
619
+ // Read the refs' LATEST contents at unmount so we revoke every URL and
620
+ // clear every key accumulated over the component's life. Capturing at
621
+ // setup time (empty) would leak URLs; these are collection refs, not DOM
622
+ // nodes.
623
+ /* eslint-disable react-hooks/exhaustive-deps -- must revoke/clear the latest ref contents at unmount, not the empty setup-time values */
619
624
  for (const url of objectUrlsRef.current) URL.revokeObjectURL(url)
620
625
  objectUrlsRef.current.clear()
621
626
  inflightKeysRef.current.clear()
627
+ /* eslint-enable react-hooks/exhaustive-deps */
622
628
  }
623
629
  }, [])
624
630
 
@@ -22,7 +22,7 @@
22
22
  * ```
23
23
  */
24
24
 
25
- import { useState, useEffect, useCallback, useRef } from 'react'
25
+ import { useState, useEffect } from 'react'
26
26
  import { getFileCategory, getExtension, type FileCategory } from './useMimeTypeDetection'
27
27
 
28
28
  // ============================================================================
@@ -64,9 +64,11 @@ export interface FilePreviewResult {
64
64
  }
65
65
 
66
66
  export interface CodeEditorKit {
67
- Component: React.ComponentType<any>
68
- langExtensions: any[]
69
- darkTheme: any
67
+ Component: React.ComponentType<Record<string, unknown>>
68
+ /** Opaque CodeMirror Extension objects (dynamically imported, no stable public type here). */
69
+ langExtensions: unknown[]
70
+ /** Opaque CodeMirror theme Extension. */
71
+ darkTheme: unknown
70
72
  }
71
73
 
72
74
  export interface XlsxSheet {
@@ -245,7 +247,7 @@ async function loadCodePreview(
245
247
  const [cmModule, jsModule, themeModule] = editorModules
246
248
  if (cmModule && jsModule && themeModule) {
247
249
  const mode = getLanguageMode(fileName)
248
- const langExtensions: any[] = []
250
+ const langExtensions: unknown[] = []
249
251
 
250
252
  if (mode === 'javascript') {
251
253
  langExtensions.push(jsModule.javascript({ jsx: true }))
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { useState, useEffect, useCallback, useRef } from 'react'
12
- import { useUser, useR2Files } from 'deepspace'
13
- import { Button, EmptyState, Badge, Modal } from '@/components/ui'
12
+ import { useR2Files } from 'deepspace'
13
+ import { EmptyState, Badge, Modal } from '@/components/ui'
14
14
  import type { R2FileInfo } from 'deepspace'
15
15
 
16
16
  // ============================================================================
@@ -226,7 +226,6 @@ function PreviewModal({ file, imageUrl, onClose }: PreviewModalProps) {
226
226
  // ============================================================================
227
227
 
228
228
  export default function FileManagerPage({ className }: { className?: string }) {
229
- const { user } = useUser()
230
229
  const { upload, downloadFile, deleteFile, list, getUrl, isUploading } = useR2Files()
231
230
  const [files, setFiles] = useState<R2FileInfo[]>([])
232
231
  const [loading, setLoading] = useState(true)
@@ -5,8 +5,14 @@
5
5
  "description": "Test page for exercising integration API endpoints",
6
6
  "details": "Lets you call any integration endpoint (OpenAI, weather, search, etc.) and see the result. Used by Playwright tests to verify the full integration proxy flow.",
7
7
  "files": [
8
- { "src": "src/IntegrationTestPage.tsx", "dest": "src/pages/integration-test.tsx" },
9
- { "src": "src/integration.spec.ts", "dest": "tests/integration.spec.ts" }
8
+ {
9
+ "src": "src/IntegrationTestPage.tsx",
10
+ "dest": "src/pages/integration-test.tsx"
11
+ },
12
+ {
13
+ "src": "src/integration.spec.ts",
14
+ "dest": "tests/integration.spec.ts"
15
+ }
10
16
  ],
11
17
  "route": {
12
18
  "path": "/integration-test",
@@ -14,5 +20,6 @@
14
20
  },
15
21
  "requires": [],
16
22
  "instructions": [],
17
- "patterns": []
23
+ "patterns": [],
24
+ "internal": true
18
25
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { useState, useEffect, useMemo } from 'react'
13
13
  import { integration } from 'deepspace'
14
+ import type { IntegrationResponse } from 'deepspace'
14
15
  import { useAuth } from 'deepspace'
15
16
 
16
17
  // ============================================================================
@@ -41,6 +42,7 @@ interface JsonSchemaProperty {
41
42
  maxLength?: number
42
43
  items?: JsonSchemaProperty
43
44
  properties?: Record<string, JsonSchemaProperty>
45
+ anyOf?: JsonSchemaProperty[]
44
46
  }
45
47
 
46
48
  type Catalog = Record<string, CatalogEntry[]>
@@ -68,19 +70,19 @@ function buildFullExample(schema?: JsonSchema | null, catalogExample?: Record<st
68
70
  return example
69
71
  }
70
72
 
71
- function buildExampleItem(items: any): unknown {
73
+ function buildExampleItem(items: JsonSchemaProperty): unknown {
72
74
  if (items.type === 'string') return 'example'
73
75
  if (items.type === 'number' || items.type === 'integer') return 0
74
76
  if (items.type === 'object' && items.properties) {
75
77
  const obj: Record<string, unknown> = {}
76
- for (const [k, v] of Object.entries(items.properties as Record<string, any>)) {
78
+ for (const [k, v] of Object.entries(items.properties)) {
77
79
  if (v.default !== undefined) obj[k] = v.default
78
80
  else if (v.enum) obj[k] = v.enum[0]
79
81
  else if (v.type === 'string') obj[k] = k
80
82
  else if (v.type === 'number' || v.type === 'integer') obj[k] = v.minimum ?? 0
81
83
  else if (v.type === 'boolean') obj[k] = false
82
84
  else if (v.anyOf) {
83
- const str = v.anyOf.find((a: any) => a.type === 'string')
85
+ const str = v.anyOf.find((a) => a.type === 'string')
84
86
  obj[k] = str ? k : null
85
87
  }
86
88
  }
@@ -186,7 +188,7 @@ function FieldInput({
186
188
  if (prop.type === 'array' && Array.isArray(defaultValue) && defaultValue.length === 0 && prop.items?.properties) {
187
189
  // Build an example object from the items schema
188
190
  const exampleItem: Record<string, unknown> = {}
189
- for (const [k, v] of Object.entries(prop.items.properties as Record<string, any>)) {
191
+ for (const [k, v] of Object.entries(prop.items.properties)) {
190
192
  if (v.default !== undefined) exampleItem[k] = v.default
191
193
  else if (v.enum) exampleItem[k] = v.enum[0]
192
194
  else if (v.type === 'string') exampleItem[k] = k
@@ -194,7 +196,7 @@ function FieldInput({
194
196
  else if (v.type === 'boolean') exampleItem[k] = false
195
197
  else if (v.anyOf) {
196
198
  // Pick the simplest type from anyOf
197
- const stringType = v.anyOf.find((a: any) => a.type === 'string')
199
+ const stringType = v.anyOf.find((a) => a.type === 'string')
198
200
  if (stringType) exampleItem[k] = k
199
201
  else exampleItem[k] = null
200
202
  }
@@ -237,15 +239,16 @@ export default function IntegrationTestPage() {
237
239
  const [formValues, setFormValues] = useState<Record<string, unknown>>({})
238
240
  const [rawMode, setRawMode] = useState(false)
239
241
  const [rawBody, setRawBody] = useState('{}')
240
- const [result, setResult] = useState<any>(null)
242
+ const [result, setResult] = useState<IntegrationResponse | null>(null)
241
243
  const [loading, setLoading] = useState(false)
242
244
  const [error, setError] = useState<string | null>(null)
243
245
 
244
246
  // Fetch catalog on mount
245
247
  useEffect(() => {
246
- integration.get<{ integrations: Catalog }>('').then((res) => {
248
+ integration.get<{ integrations?: Catalog } & Catalog>('').then((res) => {
247
249
  if (res.success && res.data) {
248
- setCatalog((res.data as any).integrations ?? res.data)
250
+ const { integrations, ...rest } = res.data
251
+ setCatalog(integrations ?? (rest as Catalog))
249
252
  }
250
253
  })
251
254
  }, [])
@@ -276,8 +279,8 @@ export default function IntegrationTestPage() {
276
279
  const body = rawMode ? JSON.parse(rawBody) : formValues
277
280
  const res = await integration.post(endpoint, body)
278
281
  setResult(res)
279
- } catch (err: any) {
280
- setError(err.message)
282
+ } catch (err: unknown) {
283
+ setError(err instanceof Error ? err.message : String(err))
281
284
  } finally {
282
285
  setLoading(false)
283
286
  }
@@ -339,7 +342,7 @@ export default function IntegrationTestPage() {
339
342
  onClick={() => {
340
343
  setRawMode(!rawMode)
341
344
  if (!rawMode) setRawBody(JSON.stringify(formValues, null, 2))
342
- else try { setFormValues(JSON.parse(rawBody)) } catch {}
345
+ else try { setFormValues(JSON.parse(rawBody)) } catch { /* invalid JSON: keep current form values */ }
343
346
  }}
344
347
  className="ml-3 mt-5 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
345
348
  >
@@ -396,7 +399,7 @@ export default function IntegrationTestPage() {
396
399
  {result && !result.success && result.issues && (
397
400
  <div className="rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm space-y-1">
398
401
  <div className="font-medium text-destructive">{result.error}</div>
399
- {result.issues.map((issue: any, i: number) => (
402
+ {result.issues.map((issue, i) => (
400
403
  <div key={i} className="text-destructive/80">
401
404
  <span className="font-mono">{issue.path?.join('.')}</span>: {issue.message}
402
405
  </div>
@@ -121,7 +121,7 @@ export function hasSeenLanding(): boolean {
121
121
  }
122
122
 
123
123
  export function markLandingSeen(): void {
124
- try { localStorage.setItem(LANDING_SEEN_KEY, 'true') } catch {}
124
+ try { localStorage.setItem(LANDING_SEEN_KEY, 'true') } catch { /* storage unavailable (private mode/quota): non-critical */ }
125
125
  }
126
126
 
127
127
  // ============================================================================
@@ -8,7 +8,6 @@ import { type ReactNode } from 'react'
8
8
  import { motion } from 'framer-motion'
9
9
  import { Zap, Shield, BarChart3, Sparkles, Check } from 'lucide-react'
10
10
  import {
11
- ScrollReveal,
12
11
  StaggerContainer,
13
12
  staggerChild,
14
13
  GlassCard,
@@ -77,26 +76,6 @@ const FEATURES: Array<{
77
76
  // Decorative Visuals
78
77
  // ============================================================================
79
78
 
80
- function SpeedPulseVisual() {
81
- return (
82
- <div className="relative h-24 w-full overflow-hidden rounded-lg bg-foreground/[0.02]">
83
- <div className="absolute inset-0 flex items-center justify-center gap-[3px]">
84
- {[0.3, 0.7, 0.5, 1, 0.6, 0.85, 0.45, 0.9, 0.35, 0.75, 0.55, 0.95, 0.4, 0.8, 0.65].map((h, i) => (
85
- <motion.div
86
- key={i}
87
- className="w-[4px] rounded-full bg-gradient-to-t from-amber-500/60 to-orange-400/30"
88
- initial={{ height: '10%' }}
89
- whileInView={{ height: `${h * 80}%` }}
90
- viewport={{ once: true }}
91
- transition={{ delay: i * 0.04, duration: 0.6, ease: 'easeOut' }}
92
- />
93
- ))}
94
- </div>
95
- <div className="absolute bottom-0 inset-x-0 h-8 bg-gradient-to-t from-background/90 to-transparent" />
96
- </div>
97
- )
98
- }
99
-
100
79
  function ShieldVisual() {
101
80
  const checkItems = ['Encryption', 'RBAC', 'Audit Log']
102
81
  return (
@@ -154,77 +133,6 @@ function BarChartVisual() {
154
133
  )
155
134
  }
156
135
 
157
- function SparkleVisual() {
158
- const nodes = [
159
- { x: 20, y: 30, delay: 0 },
160
- { x: 50, y: 15, delay: 0.1 },
161
- { x: 80, y: 35, delay: 0.2 },
162
- { x: 35, y: 65, delay: 0.15 },
163
- { x: 65, y: 55, delay: 0.25 },
164
- { x: 50, y: 80, delay: 0.3 },
165
- ]
166
- const connections = [
167
- [0, 1], [1, 2], [0, 3], [1, 4], [2, 4], [3, 5], [4, 5],
168
- ]
169
- return (
170
- <div className="relative h-24 w-full overflow-hidden rounded-lg bg-foreground/[0.02]">
171
- <svg className="absolute inset-0 w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
172
- {connections.map(([a, b], i) => (
173
- <motion.line
174
- key={i}
175
- x1={nodes[a].x}
176
- y1={nodes[a].y}
177
- x2={nodes[b].x}
178
- y2={nodes[b].y}
179
- stroke="url(#sparkleGrad)"
180
- strokeWidth="0.5"
181
- initial={{ opacity: 0 }}
182
- whileInView={{ opacity: 0.4 }}
183
- viewport={{ once: true }}
184
- transition={{ delay: 0.2 + i * 0.05, duration: 0.5 }}
185
- />
186
- ))}
187
- <defs>
188
- <linearGradient id="sparkleGrad" x1="0%" y1="0%" x2="100%" y2="100%">
189
- <stop offset="0%" stopColor="rgb(139,92,246)" stopOpacity="0.6" />
190
- <stop offset="100%" stopColor="rgb(168,85,247)" stopOpacity="0.3" />
191
- </linearGradient>
192
- </defs>
193
- {nodes.map((node, i) => (
194
- <motion.circle
195
- key={i}
196
- cx={node.x}
197
- cy={node.y}
198
- r="3"
199
- fill="rgba(139,92,246,0.4)"
200
- stroke="rgba(139,92,246,0.6)"
201
- strokeWidth="0.5"
202
- initial={{ scale: 0, opacity: 0 }}
203
- whileInView={{ scale: 1, opacity: 1 }}
204
- viewport={{ once: true }}
205
- transition={{ delay: node.delay + 0.1, duration: 0.4, ease: 'easeOut' }}
206
- />
207
- ))}
208
- {nodes.map((node, i) => (
209
- <motion.circle
210
- key={`glow-${i}`}
211
- cx={node.x}
212
- cy={node.y}
213
- r="5"
214
- fill="none"
215
- stroke="rgba(139,92,246,0.15)"
216
- strokeWidth="1"
217
- initial={{ scale: 0 }}
218
- whileInView={{ scale: [1, 1.8, 1] }}
219
- viewport={{ once: true }}
220
- transition={{ delay: node.delay + 0.5, duration: 2, repeat: Infinity, ease: 'easeInOut' }}
221
- />
222
- ))}
223
- </svg>
224
- </div>
225
- )
226
- }
227
-
228
136
  // ============================================================================
229
137
  // Feature Card
230
138
  // ============================================================================
@@ -37,8 +37,13 @@ export default function ChatPage({
37
37
  }, [channelId, isMember, markAsRead])
38
38
 
39
39
  if (status !== 'ready' || !channelId) {
40
+ // data-testid lets tests (and humans reading a trace) distinguish "stuck
41
+ // waiting for the default channel" from "route never mounted".
40
42
  return (
41
- <div className={`flex items-center justify-center h-full ${className ?? ''}`}>
43
+ <div
44
+ data-testid="chat-page-loading"
45
+ className={`flex items-center justify-center h-full ${className ?? ''}`}
46
+ >
42
47
  <div className="text-muted-foreground text-sm">Loading chat...</div>
43
48
  </div>
44
49
  )
@@ -86,7 +86,11 @@ export function MessageItem({
86
86
  setIsEditing(true)
87
87
  setEditContent(message.data.content)
88
88
  }
89
- }, [forceEdit]) // eslint-disable-line react-hooks/exhaustive-deps
89
+ // Deps intentionally limited to [forceEdit]: this reacts only to the
90
+ // forceEdit trigger. Including isEditing/message.data.content would re-fire
91
+ // on every keystroke and clobber the in-progress edit buffer.
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot on forceEdit; adding isEditing/message.data.content would re-fire on keystrokes and clobber the edit buffer
93
+ }, [forceEdit])
90
94
 
91
95
  const timestamp = new Date(message.createdAt)
92
96
  const timeStr = timestamp.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
@@ -26,12 +26,24 @@ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
26
26
  const { channels, status, create } = useChannels()
27
27
  const hasInitialized = useRef(false)
28
28
 
29
- const defaultChannel = channels.find(
30
- (c: RecordData<Channel>) =>
31
- c.data.name === channelName &&
32
- c.data.type === 'public' &&
33
- !c.data.archived
34
- )
29
+ // Nothing enforces name uniqueness server-side, so two first visitors can
30
+ // race the one-shot init below and BOTH create the default channel. With a
31
+ // bare .find(), each client then resolves whichever duplicate its local
32
+ // snapshot orders first — split-brain: A and B post into different rooms
33
+ // and never see each other. Pick deterministically (oldest createdAt,
34
+ // recordId tie-break) so every client converges on the same channel once
35
+ // the duplicates sync.
36
+ const defaultChannel = channels
37
+ .filter(
38
+ (c: RecordData<Channel>) =>
39
+ c.data.name === channelName &&
40
+ c.data.type === 'public' &&
41
+ !c.data.archived,
42
+ )
43
+ .sort(
44
+ (a: RecordData<Channel>, b: RecordData<Channel>) =>
45
+ a.createdAt.localeCompare(b.createdAt) || a.recordId.localeCompare(b.recordId),
46
+ )[0]
35
47
 
36
48
  const { isMember, join, status: membersStatus } = useChannelMembers(defaultChannel?.recordId)
37
49
 
@@ -47,7 +59,11 @@ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
47
59
  description: 'Default chat channel',
48
60
  })
49
61
  }
50
- }, [user, status]) // eslint-disable-line react-hooks/exhaustive-deps
62
+ // Deps intentionally limited to [user, status]: this is a one-shot init
63
+ // guarded by hasInitialized. Including defaultChannel/create/channelName
64
+ // would re-run and defeat the create-once semantics.
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot init guarded by hasInitialized; adding defaultChannel/create/channelName would defeat create-once
66
+ }, [user, status])
51
67
 
52
68
  // Wait for the members query to be ready before auto-joining.
53
69
  useEffect(() => {
@@ -17,5 +17,6 @@
17
17
  },
18
18
  "requires": [],
19
19
  "instructions": [],
20
- "patterns": []
20
+ "patterns": [],
21
+ "internal": true
21
22
  }
@@ -379,7 +379,7 @@ function readCollapsed(): boolean {
379
379
  function writeCollapsed(value: boolean): void {
380
380
  try {
381
381
  localStorage.setItem(STORAGE_KEY, String(value))
382
- } catch {}
382
+ } catch { /* storage unavailable (private mode/quota): non-critical */ }
383
383
  }
384
384
 
385
385
  // ============================================================================
@@ -13,7 +13,7 @@ import { useUser } from 'deepspace'
13
13
  import { useUsers } from 'deepspace'
14
14
  import { useQuery } from 'deepspace'
15
15
  import { useMutations } from 'deepspace'
16
- import { Button, Modal, Badge, EmptyState, type BadgeProps } from '@/components/ui'
16
+ import { Button, Modal, Badge, EmptyState } from '@/components/ui'
17
17
  import { ROLES, type Role } from 'deepspace'
18
18
  import { DIFFICULTY, DIFFICULTY_CONFIG, GRADE, GRADE_CONFIG, type Difficulty, type Grade, type BadgeVariant } from '../components/tasks/tasks-constants'
19
19
 
@@ -45,5 +45,6 @@
45
45
  },
46
46
  "requires": [],
47
47
  "instructions": [],
48
- "patterns": []
48
+ "patterns": [],
49
+ "internal": true
49
50
  }
@@ -37,8 +37,8 @@ export default function TestPage() {
37
37
  try {
38
38
  const id = await createConfirmed({ title: 'Test Item', description: 'Created by test', status: 'draft', createdBy: user?.id ?? 'anonymous' })
39
39
  setLastResult(`created:${id}`)
40
- } catch (e: any) {
41
- setLastError(e.message ?? String(e))
40
+ } catch (e: unknown) {
41
+ setLastError(e instanceof Error ? e.message : String(e))
42
42
  }
43
43
  }
44
44
 
@@ -47,8 +47,8 @@ export default function TestPage() {
47
47
  try {
48
48
  const id = await createConfirmed({ title: 'Public Item', description: 'Visible to anonymous', status: 'published', createdBy: user?.id ?? 'anonymous' })
49
49
  setLastResult(`created:${id}`)
50
- } catch (e: any) {
51
- setLastError(e.message ?? String(e))
50
+ } catch (e: unknown) {
51
+ setLastError(e instanceof Error ? e.message : String(e))
52
52
  }
53
53
  }
54
54
 
@@ -57,8 +57,8 @@ export default function TestPage() {
57
57
  try {
58
58
  await putConfirmed(recordId, { title: 'Updated Title', description: 'Updated by test', status: 'draft', createdBy: user?.id ?? 'anonymous' })
59
59
  setLastResult(`updated:${recordId}`)
60
- } catch (e: any) {
61
- setLastError(e.message ?? String(e))
60
+ } catch (e: unknown) {
61
+ setLastError(e instanceof Error ? e.message : String(e))
62
62
  }
63
63
  }
64
64
 
@@ -67,8 +67,8 @@ export default function TestPage() {
67
67
  try {
68
68
  await removeConfirmed(recordId)
69
69
  setLastResult(`deleted:${recordId}`)
70
- } catch (e: any) {
71
- setLastError(e.message ?? String(e))
70
+ } catch (e: unknown) {
71
+ setLastError(e instanceof Error ? e.message : String(e))
72
72
  }
73
73
  }
74
74
 
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "deepspace",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
4
+ "engines": {
5
+ "node": ">=22.15.0"
6
+ },
4
7
  "description": "DeepSpace SDK — build real-time collaborative apps on Cloudflare Workers",
5
8
  "type": "module",
9
+ "sideEffects": false,
6
10
  "main": "./dist/index.js",
7
11
  "types": "./dist/index.d.ts",
8
12
  "bin": {
@@ -40,7 +44,7 @@
40
44
  },
41
45
  "dependencies": {
42
46
  "@clack/prompts": "^1.2.0",
43
- "better-auth": "1.5.6",
47
+ "better-auth": "1.6.23",
44
48
  "citty": "^0.2.0",
45
49
  "cross-spawn": "^7.0.6",
46
50
  "esbuild": "^0.25.0",
@@ -48,7 +52,7 @@
48
52
  "jose": "^6.1.0",
49
53
  "smol-toml": "^1.6.1",
50
54
  "yjs": "^13.6.0",
51
- "zustand": "^5.0.0"
55
+ "zod": "^4.3.6"
52
56
  },
53
57
  "peerDependencies": {
54
58
  "@ai-sdk/anthropic": "^2.0.0",
@@ -112,7 +116,7 @@
112
116
  "tailwind-merge": "^3.5.0",
113
117
  "tsup": "^8.3.0",
114
118
  "typescript": "^5.7.0",
115
- "vitest": "^2.1.8"
119
+ "vitest": "^4.1.0"
116
120
  },
117
121
  "keywords": [
118
122
  "deepspace",