deepspace 0.18.0 → 0.19.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.
@@ -118,11 +118,11 @@ export default function AdminPage() {
118
118
  interface UsersPanelProps {
119
119
  users: Array<{
120
120
  id: string
121
- email: string
121
+ email?: string
122
122
  name: string
123
123
  imageUrl?: string
124
124
  role: string
125
- lastSeenAt: string
125
+ lastSeenAt?: string
126
126
  }>
127
127
  currentUserId?: string
128
128
  onSetRole: (userId: string, role: string) => void
@@ -132,7 +132,7 @@ function UsersPanel({ users, currentUserId, onSetRole }: UsersPanelProps) {
132
132
  // Sort users by last seen
133
133
  const sortedUsers = useMemo(() => {
134
134
  return [...users].sort((a, b) =>
135
- new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime()
135
+ new Date(b.lastSeenAt ?? '').getTime() - new Date(a.lastSeenAt ?? '').getTime()
136
136
  )
137
137
  }, [users])
138
138
 
@@ -163,7 +163,7 @@ function UsersPanel({ users, currentUserId, onSetRole }: UsersPanelProps) {
163
163
 
164
164
  <div className="flex items-center gap-3">
165
165
  <p className="text-xs text-muted-foreground">
166
- Last seen: {new Date(user.lastSeenAt).toLocaleDateString()}
166
+ Last seen: {user.lastSeenAt ? new Date(user.lastSeenAt).toLocaleDateString() : '—'}
167
167
  </p>
168
168
 
169
169
  <div className="flex items-center gap-2">
@@ -59,7 +59,8 @@
59
59
  "The feature is served at /docs on the app's existing hostname.",
60
60
  "A hostname listed in documentation.json domains serves the same release at that hostname's root after you explicitly attach it to the app.",
61
61
  "Documentation builds only when documentation.json exists; apps without this feature are unaffected.",
62
- "MDX may import app components directly. documentation.tsx is the single optional shell customization point."
62
+ "MDX may import app components directly. documentation.tsx is the single optional shell customization point.",
63
+ "Agent discovery is available at /docs/llms.txt, /docs/llms-full.txt, /docs/skill.md, /docs/.well-known/mcp, and /docs/mcp."
63
64
  ],
64
65
  "agentNotes": [
65
66
  "Treat documentation.json, documentation/**/*.md, documentation/**/*.mdx, and optional documentation.tsx as one app-owned documentation feature.",
@@ -3,7 +3,7 @@
3
3
  "name": "Documents",
4
4
  "category": "data",
5
5
  "description": "Real-time collaborative document editing with Tiptap + Yjs",
6
- "details": "Document library with folders & favorites. Collaborative editor built on Tiptap (ProseMirror) with Y.XmlFragment via @tiptap/extension-collaboration and live remote cursors via @tiptap/extension-collaboration-caret — buttery typing, no innerHTML rebuilds, no contenteditable quirks. Invite-only documents with viewer/editor roles: owner shares via an in-app dialog that resolves an email to a DeepSpace user. RBAC is enforced by the documents schema and the starter template's built-in Yjs route. Includes a one-shot migration shim that converts documents created with the legacy contenteditable editor (HTML stored in Y.Text('content')) into Tiptap on first open.",
6
+ "details": "Document library with folders & favorites. Collaborative editor built on Tiptap (ProseMirror) with Y.XmlFragment via @tiptap/extension-collaboration and live remote cursors via @tiptap/extension-collaboration-caret — buttery typing, no innerHTML rebuilds, no contenteditable quirks. Invite-only documents with viewer/editor roles: owner shares via an in-app dialog that resolves an email to a DeepSpace user. RBAC is enforced by the documents schema and the starter template's built-in Yjs route.",
7
7
  "files": [
8
8
  {
9
9
  "src": "src/DocumentsListPage.tsx",
@@ -86,7 +86,8 @@
86
86
  "@tiptap/extension-task-item": "^3.22.0"
87
87
  },
88
88
  "instructions": [
89
- "Run `npm install` (or your package manager) to install the Tiptap dependencies that were just added to package.json — the editor imports @tiptap/react and friends, so the dev server will fail to resolve them otherwise."
89
+ "Run `npm install` (or your package manager) to install the Tiptap dependencies that were just added to package.json — the editor imports @tiptap/react and friends, so the dev server will fail to resolve them otherwise.",
90
+ "Before sharing a document by email, ask that person to sign in to the deployed app once. The privacy-preserving invite lookup uses the app-local users collection, not a global user directory."
90
91
  ],
91
92
  "patterns": []
92
93
  }
@@ -6,11 +6,6 @@
6
6
  * applied as granular PM transactions (no `innerHTML` swap), the local
7
7
  * caret is migrated through concurrent edits by PM's position mapping,
8
8
  * and remote cursors are painted by `@tiptap/extension-collaboration-caret`.
9
- *
10
- * Legacy documents created with the old editor stored HTML in Y.Text('content').
11
- * On first open by an editor/owner we migrate that into the new XmlFragment
12
- * via `editor.commands.setContent(html)` and clear the legacy field. The
13
- * migration is a single Yjs transaction so all peers converge.
14
9
  */
15
10
 
16
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -22,10 +17,14 @@ import { type Editor, useEditorState } from '@tiptap/react'
22
17
  import { useDocEditor } from './editor/useDocEditor'
23
18
  import { DocEditorSurface, PAGE_HEIGHT_PX, PAGE_WIDTH_PX } from './editor/DocEditorSurface'
24
19
  import { DocumentsTiptapToolbar } from './editor/DocumentsTiptapToolbar'
25
- import { DocumentsOutlinePanel, DOCUMENT_OUTLINE_WIDTH_PX, type OutlineEntry } from './DocumentsOutlinePanel'
20
+ import {
21
+ DocumentsOutlinePanel,
22
+ DOCUMENT_OUTLINE_WIDTH_PX,
23
+ type OutlineEntry,
24
+ } from './DocumentsOutlinePanel'
26
25
  import { DocumentsPresence } from './DocumentsPresence'
27
26
  import { InviteDialog } from './InviteDialog'
28
- import type { DocumentsDocumentFields } from './documents-library-types'
27
+ import { recordsReadyForMutation, type DocumentsDocumentFields } from './documents-library-types'
29
28
  import {
30
29
  useDocumentsEditorPresence,
31
30
  useDocumentsPresenceAccess,
@@ -180,7 +179,10 @@ function AccessChangedOverlay({
180
179
  <AlertTriangle className="h-4 w-4" strokeWidth={2} />
181
180
  </span>
182
181
  <div className="min-w-0 flex-1">
183
- <h2 id="documents-access-change-title" className="text-base font-semibold tracking-tight">
182
+ <h2
183
+ id="documents-access-change-title"
184
+ className="text-base font-semibold tracking-tight"
185
+ >
184
186
  {title}
185
187
  </h2>
186
188
  <p className="mt-1.5 text-sm" style={{ color: 'var(--documents-el-muted)' }}>
@@ -284,22 +286,15 @@ export default function DocumentsEditorPage() {
284
286
  [documents, docId],
285
287
  )
286
288
 
287
- const { put } = useMutations<DocumentsDocumentFields>('documents')
289
+ const { put, ready: documentsMutationsReady } = useMutations<DocumentsDocumentFields>('documents')
290
+ const documentWritesReady = recordsReadyForMutation(status, documentsMutationsReady)
288
291
 
289
- /**
290
- * `'content'` is kept as the field name only so legacy-doc migration can
291
- * read the old HTML out of Y.Text('content') below. The Tiptap editor
292
- * itself binds to a separate Y.XmlFragment('default') inside the same
293
- * Y.Doc — they don't collide.
294
- */
295
- const {
296
- doc,
297
- text: legacyText,
298
- synced,
299
- canWrite,
300
- writeAuthResolved,
301
- awareness,
302
- } = useYjsRoom(docId ?? 'noop', 'content')
292
+ // The text helper remains available to consumers of useYjsRoom; this editor
293
+ // binds Tiptap directly to Y.XmlFragment('default') on the returned document.
294
+ const { doc, synced, connected, canWrite, writeAuthResolved, awareness } = useYjsRoom(
295
+ docId ?? 'noop',
296
+ 'content',
297
+ )
303
298
 
304
299
  const presenceAccess = useDocumentsPresenceAccess({
305
300
  docId,
@@ -388,37 +383,6 @@ export default function DocumentsEditorPage() {
388
383
  placeholder: showReadOnlyDocUx ? 'View only' : 'Start typing — toolbar above for formatting…',
389
384
  })
390
385
 
391
- // Legacy migration: if doc.getXmlFragment('default') is empty but the
392
- // legacy Y.Text('content') field has HTML, parse it once into Tiptap.
393
- // Only the first writer to open does this; viewers never see partial state
394
- // because both the wipe and the parse run inside a single Yjs transaction.
395
- const migratedRef = useRef(false)
396
- useEffect(() => {
397
- if (migratedRef.current) return
398
- if (!editor || !synced || !effectiveCanWrite) return
399
- const fragment = doc.getXmlFragment('default')
400
- if (fragment.length > 0) {
401
- migratedRef.current = true
402
- return
403
- }
404
- const yText = doc.getText('content')
405
- const legacy = (legacyText ?? yText.toString()).trim()
406
- if (!legacy) {
407
- migratedRef.current = true
408
- return
409
- }
410
- migratedRef.current = true
411
- /**
412
- * Tiptap parses the HTML against its schema, then writes the resulting
413
- * nodes into the bound Y.XmlFragment. We then clear the legacy field
414
- * inside the same transaction so peers see one atomic step.
415
- */
416
- doc.transact(() => {
417
- editor.commands.setContent(legacy, { emitUpdate: true })
418
- if (yText.length > 0) yText.delete(0, yText.length)
419
- })
420
- }, [editor, synced, effectiveCanWrite, doc, legacyText])
421
-
422
386
  const { participants: presenceParticipants, typingNames } = useDocumentsEditorPresence({
423
387
  editor,
424
388
  document: selectedDoc,
@@ -441,10 +405,10 @@ export default function DocumentsEditorPage() {
441
405
  const docTitle = selectedDoc?.data.title?.trim() || 'Untitled Document'
442
406
  const handleTitleSave = useCallback(
443
407
  async (next: string) => {
444
- if (!selectedDoc) return
408
+ if (!documentWritesReady || !selectedDoc) return
445
409
  await put(selectedDoc.recordId, { ...selectedDoc.data, title: next }).catch(() => {})
446
410
  },
447
- [put, selectedDoc],
411
+ [documentWritesReady, put, selectedDoc],
448
412
  )
449
413
 
450
414
  /**
@@ -494,7 +458,11 @@ export default function DocumentsEditorPage() {
494
458
  <ArrowLeft className="h-4 w-4" strokeWidth={2} />
495
459
  </button>
496
460
 
497
- <InlineTitle title={docTitle} canEdit={isOwner ?? false} onSave={handleTitleSave} />
461
+ <InlineTitle
462
+ title={docTitle}
463
+ canEdit={Boolean(isOwner && documentWritesReady)}
464
+ onSave={handleTitleSave}
465
+ />
498
466
 
499
467
  <DocumentsPresence participants={presenceParticipants} typingNames={typingNames} />
500
468
 
@@ -517,12 +485,15 @@ export default function DocumentsEditorPage() {
517
485
  ) : effectiveRole === 'editor' || effectiveRole === 'viewer' ? (
518
486
  <span
519
487
  className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
520
- style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-muted)' }}
488
+ style={{
489
+ borderColor: 'var(--documents-el-line)',
490
+ color: 'var(--documents-el-muted)',
491
+ }}
521
492
  >
522
493
  {effectiveRole === 'editor' ? 'Shared editor' : 'Shared viewer'}
523
494
  </span>
524
495
  ) : null}
525
- {synced ? (
496
+ {connected && synced ? (
526
497
  <span
527
498
  className="flex items-center gap-1.5 text-xs tabular-nums"
528
499
  style={{ color: 'var(--documents-el-muted)' }}
@@ -530,7 +501,7 @@ export default function DocumentsEditorPage() {
530
501
  <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden />
531
502
  Synced
532
503
  </span>
533
- ) : (
504
+ ) : connected ? (
534
505
  <span
535
506
  className="flex items-center gap-1.5 text-xs tabular-nums"
536
507
  style={{ color: 'var(--documents-el-muted)' }}
@@ -538,6 +509,14 @@ export default function DocumentsEditorPage() {
538
509
  <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" aria-hidden />
539
510
  Connecting…
540
511
  </span>
512
+ ) : (
513
+ <span
514
+ className="flex items-center gap-1.5 text-xs tabular-nums"
515
+ style={{ color: 'var(--documents-el-muted)' }}
516
+ >
517
+ <span className="h-1.5 w-1.5 rounded-full bg-slate-400" aria-hidden />
518
+ Offline
519
+ </span>
541
520
  )}
542
521
  {effectiveRole === 'owner' ? (
543
522
  <Badge variant="success">Owner</Badge>
@@ -675,7 +654,10 @@ export function ErrorBoundary() {
675
654
  >
676
655
  <div
677
656
  className="w-full max-w-md rounded-xl border p-6 shadow-md"
678
- style={{ borderColor: 'var(--documents-el-line)', backgroundColor: 'var(--documents-el-surface)' }}
657
+ style={{
658
+ borderColor: 'var(--documents-el-line)',
659
+ backgroundColor: 'var(--documents-el-surface)',
660
+ }}
679
661
  >
680
662
  <div className="mb-3 flex items-center justify-center">
681
663
  <span
@@ -690,7 +672,10 @@ export function ErrorBoundary() {
690
672
  </span>
691
673
  </div>
692
674
  <h1 className="text-base font-semibold tracking-tight">This document needs to reload</h1>
693
- <p className="mx-auto mt-1.5 max-w-xs text-sm" style={{ color: 'var(--documents-el-muted)' }}>
675
+ <p
676
+ className="mx-auto mt-1.5 max-w-xs text-sm"
677
+ style={{ color: 'var(--documents-el-muted)' }}
678
+ >
694
679
  Your access to this document just changed. Refresh to load the latest version.
695
680
  </p>
696
681
  <p
@@ -27,6 +27,7 @@ import {
27
27
  import { LibrarySidebar, readSidebarCollapsed, writeSidebarCollapsed } from './LibrarySidebar'
28
28
  import {
29
29
  parseDocumentsIdList,
30
+ recordsReadyForMutation,
30
31
  type DocFolderFields,
31
32
  type DocumentsDocumentFields,
32
33
  type LibraryNavSelection,
@@ -81,7 +82,7 @@ export default function DocumentsListPage() {
81
82
  const { user } = useUser()
82
83
  const navigate = useNavigate()
83
84
  const userRole = (user?.role ?? ROLES.VIEWER) as Role
84
- const canCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
85
+ const roleCanCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
85
86
 
86
87
  const [searchQuery, setSearchQuery] = useState('')
87
88
  const [sortBy, setSortBy] = useState<SortOption>('lastEdited')
@@ -101,17 +102,31 @@ export default function DocumentsListPage() {
101
102
  orderBy: 'createdAt',
102
103
  orderDir: 'desc',
103
104
  })
104
- const { create, remove, put } = useMutations<DocumentsDocumentFields>('documents')
105
-
106
- const { records: folderRecords } = useQuery<DocFolderFields>('doc_folders', {
107
- orderBy: 'createdAt',
108
- orderDir: 'asc',
109
- })
105
+ const {
106
+ createConfirmed,
107
+ remove,
108
+ put,
109
+ ready: documentsMutationsReady,
110
+ } = useMutations<DocumentsDocumentFields>('documents')
111
+
112
+ const { records: folderRecords, status: foldersStatus } = useQuery<DocFolderFields>(
113
+ 'doc_folders',
114
+ {
115
+ orderBy: 'createdAt',
116
+ orderDir: 'asc',
117
+ },
118
+ )
110
119
  const {
111
120
  create: createFolder,
112
121
  put: putFolder,
113
122
  remove: removeFolder,
123
+ ready: folderMutationsReady,
114
124
  } = useMutations<DocFolderFields>('doc_folders')
125
+ const documentWritesReady = recordsReadyForMutation(status, documentsMutationsReady)
126
+ const folderWritesReady = recordsReadyForMutation(foldersStatus, folderMutationsReady)
127
+ const canCreate = roleCanCreate && documentWritesReady
128
+ const canManageFolders = canCreate && folderWritesReady
129
+ const canMoveDocuments = canCreate && foldersStatus === 'ready'
115
130
 
116
131
  const myFolders = useMemo(
117
132
  () => (folderRecords ?? []).filter((f) => f.data.ownerId === user?.id),
@@ -218,7 +233,7 @@ export default function DocumentsListPage() {
218
233
 
219
234
  const handleNewDocument = useCallback(async () => {
220
235
  if (!user || !canCreate) return
221
- const id = await create({
236
+ const id = await createConfirmed({
222
237
  title: UNTITLED,
223
238
  ownerId: user.id,
224
239
  collaborators: '[]',
@@ -226,36 +241,42 @@ export default function DocumentsListPage() {
226
241
  folderId: folderIdForNew,
227
242
  })
228
243
  navigate(`/documents/${id}`)
229
- }, [canCreate, create, folderIdForNew, navigate, user])
244
+ }, [canCreate, createConfirmed, folderIdForNew, navigate, user])
230
245
 
231
246
  const handleDelete = useCallback(
232
247
  async (docId: string) => {
248
+ if (!canCreate) return
233
249
  if (confirm('Delete this document?')) await remove(docId)
234
250
  },
235
- [remove],
251
+ [canCreate, remove],
236
252
  )
237
253
 
238
254
  const handleRenameCommit = useCallback(
239
255
  async (docId: string) => {
256
+ if (!canCreate) {
257
+ setRenamingId(null)
258
+ return
259
+ }
240
260
  const rec = documents.find((d) => d.recordId === docId)
241
261
  const t = renameValue.trim()
242
262
  setRenamingId(null)
243
263
  if (!rec || !t || t === rec.data.title) return
244
264
  await put(docId, { ...rec.data, title: t }).catch(() => {})
245
265
  },
246
- [documents, put, renameValue],
266
+ [canCreate, documents, put, renameValue],
247
267
  )
248
268
 
249
269
  const handleCreateFolder = useCallback(
250
270
  async (name: string) => {
251
- if (!user) return
271
+ if (!user || !canManageFolders) return
252
272
  await createFolder({ name, ownerId: user.id })
253
273
  },
254
- [createFolder, user],
274
+ [canManageFolders, createFolder, user],
255
275
  )
256
276
 
257
277
  const handleDeleteFolder = useCallback(
258
278
  async (folderId: string) => {
279
+ if (!canManageFolders) return
259
280
  const owned = (documents ?? []).filter(
260
281
  (d) => d.data.ownerId === user?.id && (d.data.folderId ?? '') === folderId,
261
282
  )
@@ -266,7 +287,7 @@ export default function DocumentsListPage() {
266
287
  nav.kind === 'folder' && nav.folderId === folderId ? { kind: 'all' } : nav,
267
288
  )
268
289
  },
269
- [documents, put, removeFolder, user?.id],
290
+ [canManageFolders, documents, put, removeFolder, user?.id],
270
291
  )
271
292
 
272
293
  const commitRenameFolderRef = useRef<() => Promise<void>>(async () => {})
@@ -274,22 +295,23 @@ export default function DocumentsListPage() {
274
295
  const id = folderRenamingId
275
296
  const trimmed = folderRenameValue.trim()
276
297
  setFolderRenamingId(null)
277
- if (!id || !trimmed) return
298
+ if (!canManageFolders || !id || !trimmed) return
278
299
  const folder = sortedFolders.find((f) => f.recordId === id)
279
300
  if (!folder || trimmed === (folder.data.name ?? '').trim()) return
280
301
  await putFolder(id, { ...folder.data, name: trimmed }).catch(() => {})
281
- }, [folderRenameValue, folderRenamingId, putFolder, sortedFolders])
302
+ }, [canManageFolders, folderRenameValue, folderRenamingId, putFolder, sortedFolders])
282
303
 
283
304
  commitRenameFolderRef.current = commitRenameFolder
284
305
 
285
306
  const handleMoveDoc = useCallback(
286
307
  async (docId: string, folderId: string) => {
308
+ if (!canMoveDocuments) return
287
309
  const rec = documents.find((d) => d.recordId === docId)
288
310
  if (!rec) return
289
311
  await put(docId, { ...rec.data, folderId })
290
312
  setMoveMenuId(null)
291
313
  },
292
- [documents, put],
314
+ [canMoveDocuments, documents, put],
293
315
  )
294
316
 
295
317
  const isLoading = status === 'loading'
@@ -316,6 +338,7 @@ export default function DocumentsListPage() {
316
338
  selection={libraryNav}
317
339
  onSelect={setLibraryNav}
318
340
  folders={myFolders}
341
+ canManageFolders={canManageFolders}
319
342
  collapsed={sidebarCollapsed}
320
343
  onToggleCollapsed={toggleSidebar}
321
344
  onCreateFolder={handleCreateFolder}
@@ -342,7 +365,10 @@ export default function DocumentsListPage() {
342
365
  {greetingForTime()}, {displayFirstName}
343
366
  </h1>
344
367
  </div>
345
- <p className="mb-8 text-[13px] font-medium" style={{ color: 'var(--documents-el-muted)' }}>
368
+ <p
369
+ className="mb-8 text-[13px] font-medium"
370
+ style={{ color: 'var(--documents-el-muted)' }}
371
+ >
346
372
  {ownedDocumentCount} {ownedDocumentCount === 1 ? 'document' : 'documents'} in your
347
373
  documents.
348
374
  </p>
@@ -571,7 +597,10 @@ export default function DocumentsListPage() {
571
597
  aria-hidden
572
598
  />
573
599
  </div>
574
- <h3 className="mb-2 text-lg font-semibold" style={{ color: 'var(--documents-el-text)' }}>
600
+ <h3
601
+ className="mb-2 text-lg font-semibold"
602
+ style={{ color: 'var(--documents-el-text)' }}
603
+ >
575
604
  {searchQuery ? 'No matches' : 'No documents yet'}
576
605
  </h3>
577
606
  <p className="mb-6 max-w-sm text-sm" style={{ color: 'var(--documents-el-muted)' }}>
@@ -591,7 +620,8 @@ export default function DocumentsListPage() {
591
620
  className="group w-full cursor-pointer rounded-xl border-2 border-dashed p-4 text-left shadow-sm transition-all hover:shadow-md"
592
621
  style={{
593
622
  borderColor: 'var(--documents-el-line)',
594
- backgroundColor: 'color-mix(in srgb, var(--documents-el-surface) 20%, transparent)',
623
+ backgroundColor:
624
+ 'color-mix(in srgb, var(--documents-el-surface) 20%, transparent)',
595
625
  }}
596
626
  >
597
627
  <div
@@ -668,6 +698,7 @@ export default function DocumentsListPage() {
668
698
  <div className="min-w-0 flex-1">
669
699
  {renamingId === doc.recordId ? (
670
700
  <input
701
+ disabled={!canCreate}
671
702
  value={renameValue}
672
703
  onChange={(e) => setRenameValue(e.target.value)}
673
704
  onBlur={() => void handleRenameCommit(doc.recordId)}
@@ -676,7 +707,7 @@ export default function DocumentsListPage() {
676
707
  if (e.key === 'Escape') setRenamingId(null)
677
708
  }}
678
709
  onClick={(e) => e.stopPropagation()}
679
- className="mb-0.5 w-full rounded-md border px-2 py-1 text-[13px] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
710
+ className="mb-0.5 w-full rounded-md border px-2 py-1 text-[13px] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
680
711
  style={{
681
712
  borderColor: 'var(--documents-el-line)',
682
713
  backgroundColor: 'var(--documents-el-bg)',
@@ -724,6 +755,7 @@ export default function DocumentsListPage() {
724
755
  <button
725
756
  type="button"
726
757
  title="More"
758
+ disabled={!canCreate}
727
759
  onClick={(e) => {
728
760
  e.stopPropagation()
729
761
  setActionsMenuId((id) =>
@@ -731,12 +763,12 @@ export default function DocumentsListPage() {
731
763
  )
732
764
  setMoveMenuId(null)
733
765
  }}
734
- className="rounded-lg p-1.5"
766
+ className="rounded-lg p-1.5 disabled:cursor-not-allowed disabled:opacity-40"
735
767
  style={{ color: 'var(--documents-el-muted)' }}
736
768
  >
737
769
  <MoreVertical className="h-4 w-4" />
738
770
  </button>
739
- {actionsMenuId === doc.recordId ? (
771
+ {canCreate && actionsMenuId === doc.recordId ? (
740
772
  <>
741
773
  <button
742
774
  type="button"
@@ -786,7 +818,7 @@ export default function DocumentsListPage() {
786
818
  <button
787
819
  type="button"
788
820
  className="block w-full rounded px-2 py-1 text-left text-xs hover:bg-black/[0.04]"
789
- disabled={fid === ''}
821
+ disabled={!canMoveDocuments || fid === ''}
790
822
  onClick={() => void handleMoveDoc(doc.recordId, '')}
791
823
  >
792
824
  Uncategorized
@@ -795,7 +827,7 @@ export default function DocumentsListPage() {
795
827
  <button
796
828
  key={f.recordId}
797
829
  type="button"
798
- disabled={fid === f.recordId}
830
+ disabled={!canMoveDocuments || fid === f.recordId}
799
831
  className="block w-full rounded px-2 py-1 text-left text-xs hover:bg-black/[0.04] disabled:opacity-40"
800
832
  onClick={() =>
801
833
  void handleMoveDoc(doc.recordId, f.recordId)
@@ -931,11 +963,12 @@ export default function DocumentsListPage() {
931
963
  </button>
932
964
  <button
933
965
  type="button"
966
+ disabled={!canCreate}
934
967
  onClick={(e) => {
935
968
  e.stopPropagation()
936
969
  void handleDelete(doc.recordId)
937
970
  }}
938
- className="rounded-lg p-1.5 opacity-0 transition-opacity group-hover:opacity-100"
971
+ className="rounded-lg p-1.5 opacity-0 transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-0"
939
972
  style={{ color: 'var(--documents-el-muted)' }}
940
973
  >
941
974
  <Trash2 className="h-4 w-4" />