@xpert-ai/plugin-lucidchart 0.2.0 → 0.3.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.
@@ -3,8 +3,14 @@ import {
3
3
  Badge,
4
4
  Button,
5
5
  Check,
6
+ Dialog,
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
6
11
  Download,
7
12
  FileJson,
13
+ Image,
8
14
  Input,
9
15
  PanelLeftClose,
10
16
  PanelLeftOpen,
@@ -29,6 +35,10 @@ import {
29
35
  SidebarRail,
30
36
  SidebarTitle,
31
37
  SidebarTrigger,
38
+ Tabs,
39
+ TabsContent,
40
+ TabsList,
41
+ TabsTrigger,
32
42
  Textarea,
33
43
  Upload,
34
44
  installShadcnThemeVars
@@ -52,6 +62,7 @@ import {
52
62
  } from './runtime'
53
63
 
54
64
  type StatusFilter = '' | 'draft' | 'reviewed' | 'archived'
65
+ type DocumentKind = 'diagram' | 'flowchart' | 'architecture' | 'process' | 'wireframe' | 'orgchart' | 'network' | 'other'
55
66
  type DocumentRecord = Record<string, any>
56
67
  type DocumentVersion = Record<string, any>
57
68
  type DetailPayload = {
@@ -88,6 +99,10 @@ type StandardImportPreviewModel = {
88
99
  lines: PreviewLine[]
89
100
  viewBox: string
90
101
  }
102
+ type JsonParseResult = {
103
+ value: Record<string, unknown> | null
104
+ error: string | null
105
+ }
91
106
 
92
107
  const DEFAULT_MERMAID = `flowchart TD
93
108
  A[User Request] --> B[Agent Plans Lucidchart Draft]
@@ -118,6 +133,8 @@ const LUCIDCHART_MUTATION_TOOL_NAMES = new Set([
118
133
  'lucidchart_report_failure'
119
134
  ])
120
135
 
136
+ const DOCUMENT_KINDS: DocumentKind[] = ['diagram', 'flowchart', 'architecture', 'process', 'wireframe', 'orgchart', 'network', 'other']
137
+
121
138
  installShadcnThemeVars({ styleId: 'lucidchart-workbench-shadcn-ui-vars' })
122
139
  injectStyles()
123
140
 
@@ -130,8 +147,14 @@ function App() {
130
147
  const [status, setStatus] = React.useState<StatusFilter>('')
131
148
  const [busy, setBusy] = React.useState(false)
132
149
  const [dirty, setDirty] = React.useState(false)
150
+ const [newDialogOpen, setNewDialogOpen] = React.useState(false)
133
151
  const [newTitle, setNewTitle] = React.useState('')
134
152
  const [newDescription, setNewDescription] = React.useState('')
153
+ const [newKind, setNewKind] = React.useState<DocumentKind>('diagram')
154
+ const [metadataTitle, setMetadataTitle] = React.useState('')
155
+ const [metadataDescription, setMetadataDescription] = React.useState('')
156
+ const [metadataKind, setMetadataKind] = React.useState<DocumentKind>('diagram')
157
+ const [metadataDirty, setMetadataDirty] = React.useState(false)
135
158
  const [changeSummary, setChangeSummary] = React.useState('')
136
159
  const [assistantPrompt, setAssistantPrompt] = React.useState('')
137
160
  const [standardImportText, setStandardImportText] = React.useState(() => stringifyJson(createDefaultStandardImport('Untitled')))
@@ -140,8 +163,10 @@ function App() {
140
163
  const [lucidDocumentUrl, setLucidDocumentUrl] = React.useState('')
141
164
  const [embedUrl, setEmbedUrl] = React.useState('')
142
165
  const [previewUrl, setPreviewUrl] = React.useState('')
143
- const [leftPanelCollapsed, setLeftPanelCollapsed] = React.useState(true)
144
- const [rightPanelCollapsed, setRightPanelCollapsed] = React.useState(true)
166
+ const [mainTab, setMainTab] = React.useState('preview')
167
+ const [inspectorTab, setInspectorTab] = React.useState('info')
168
+ const [leftPanelCollapsed, setLeftPanelCollapsed] = React.useState(() => isCompactViewport())
169
+ const [rightPanelCollapsed, setRightPanelCollapsed] = React.useState(() => isCompactViewport())
145
170
  const fileInputRef = React.useRef<HTMLInputElement | null>(null)
146
171
  const contextRef = React.useRef<any>(null)
147
172
  const selectedIdRef = React.useRef('')
@@ -186,7 +211,7 @@ function App() {
186
211
  post('ready')
187
212
  }, [])
188
213
 
189
- React.useEffect(reportResize, [documents, detail, busy, dirty, leftPanelCollapsed, rightPanelCollapsed])
214
+ React.useEffect(reportResize, [documents, detail, busy, dirty, metadataDirty, mainTab, inspectorTab, leftPanelCollapsed, rightPanelCollapsed])
190
215
 
191
216
  function hydratePayload(payload: any) {
192
217
  if (!payload) {
@@ -212,6 +237,11 @@ function App() {
212
237
  setChangeSummary('')
213
238
  const version = payload.currentVersion || null
214
239
  const title = payload.item?.title || t('untitled')
240
+ const nextKind = normalizeDocumentKind(payload.item?.kind)
241
+ setMetadataTitle(title)
242
+ setMetadataDescription(typeof payload.item?.description === 'string' ? payload.item.description : '')
243
+ setMetadataKind(nextKind)
244
+ setMetadataDirty(false)
215
245
  const standardImport = isObject(version?.standardImport) ? version?.standardImport : createDefaultStandardImport(title)
216
246
  const nextText = stringifyJson(standardImport)
217
247
  setStandardImportText(nextText)
@@ -308,19 +338,40 @@ function App() {
308
338
  }
309
339
  }
310
340
 
341
+ function hasUnsavedChanges() {
342
+ return dirty || metadataDirty
343
+ }
344
+
345
+ function confirmDiscardUnsavedChanges() {
346
+ return !hasUnsavedChanges() || window.confirm(t('discardUnsavedChanges'))
347
+ }
348
+
349
+ async function selectDocumentWithGuard(documentId: string) {
350
+ if (documentId === selectedIdRef.current) {
351
+ return
352
+ }
353
+ if (!confirmDiscardUnsavedChanges()) {
354
+ return
355
+ }
356
+ await selectDocument(documentId)
357
+ }
358
+
311
359
  async function createDocument() {
312
360
  const title = newTitle.trim() || t('untitled')
313
361
  setBusy(true)
314
362
  try {
315
363
  const response = await executeAction('create_document', null, {
316
364
  title,
317
- description: newDescription
365
+ description: newDescription,
366
+ kind: newKind
318
367
  })
319
368
  const result = getResponsePayload(response)
320
369
  notify('success', resolveMessage(result?.message, contextRef.current?.locale) || t('documentCreated'))
321
370
  const documentId = result?.item?.id || result?.data?.item?.id
322
371
  setNewTitle('')
323
372
  setNewDescription('')
373
+ setNewKind('diagram')
374
+ setNewDialogOpen(false)
324
375
  setChangeSummary('')
325
376
  if (documentId) {
326
377
  await reloadList()
@@ -335,26 +386,53 @@ function App() {
335
386
  }
336
387
  }
337
388
 
338
- async function saveStandardImport() {
389
+ async function updateDocumentMetadata() {
339
390
  if (!selectedId) {
340
391
  notify('warning', t('noDocument'))
341
392
  return
342
393
  }
343
- let standardImport: Record<string, unknown>
394
+ const title = metadataTitle.trim()
395
+ if (!title) {
396
+ notify('warning', t('titleRequired'))
397
+ return
398
+ }
399
+ setBusy(true)
344
400
  try {
345
- standardImport = JSON.parse(standardImportText)
346
- if (!isObject(standardImport)) {
347
- throw new Error(t('invalidJson'))
348
- }
401
+ const response = await executeAction('update_document_metadata', selectedId, {
402
+ documentId: selectedId,
403
+ title,
404
+ description: metadataDescription,
405
+ kind: metadataKind,
406
+ changeSummary: changeSummary.trim() || undefined
407
+ })
408
+ const result = getResponsePayload(response)
409
+ notify('success', resolveMessage(result?.message, contextRef.current?.locale) || t('metadataSaved'))
410
+ setMetadataDirty(false)
411
+ setChangeSummary('')
412
+ await selectDocument(selectedId)
413
+ await reloadList()
349
414
  } catch (error) {
350
- notify('error', `${t('invalidJson')}: ${getErrorMessage(error)}`)
415
+ notify('error', getErrorMessage(error))
416
+ } finally {
417
+ setBusy(false)
418
+ }
419
+ }
420
+
421
+ async function saveStandardImport() {
422
+ if (!selectedId) {
423
+ notify('warning', t('noDocument'))
424
+ return
425
+ }
426
+ const parsed = parseStandardImportDocument(standardImportText)
427
+ if (parsed.error || !parsed.value) {
428
+ notify('error', `${t('invalidJson')}: ${parsed.error || t('unknownError')}`)
351
429
  return
352
430
  }
353
431
  setBusy(true)
354
432
  try {
355
433
  const response = await executeAction('save_standard_import_version', selectedId, {
356
434
  documentId: selectedId,
357
- standardImport,
435
+ standardImport: parsed.value,
358
436
  mermaidSource: mermaidSource.trim() || undefined,
359
437
  lucidDocumentId: lucidDocumentId.trim() || undefined,
360
438
  lucidDocumentUrl: lucidDocumentUrl.trim() || undefined,
@@ -385,8 +463,9 @@ function App() {
385
463
  try {
386
464
  const response = await executeAction('save_mermaid_draft', selectedId || null, {
387
465
  documentId: selectedId || undefined,
388
- title: newTitle.trim() || detail?.item?.title || t('untitled'),
389
- description: newDescription,
466
+ title: metadataTitle.trim() || detail?.item?.title || t('untitled'),
467
+ description: metadataDescription,
468
+ kind: metadataKind,
390
469
  mermaidSource: source,
391
470
  changeSummary: changeSummary.trim() || undefined
392
471
  })
@@ -406,7 +485,7 @@ function App() {
406
485
  }
407
486
 
408
487
  async function registerExternalDocument() {
409
- if (!selectedId && !newTitle.trim()) {
488
+ if (!selectedId && !metadataTitle.trim()) {
410
489
  notify('warning', t('noDocument'))
411
490
  return
412
491
  }
@@ -414,8 +493,9 @@ function App() {
414
493
  try {
415
494
  const response = await executeAction('register_external_document', selectedId || null, {
416
495
  documentId: selectedId || undefined,
417
- title: newTitle.trim() || detail?.item?.title || t('untitled'),
418
- description: newDescription,
496
+ title: metadataTitle.trim() || detail?.item?.title || t('untitled'),
497
+ description: metadataDescription,
498
+ kind: metadataKind,
419
499
  lucidDocumentId: lucidDocumentId.trim() || undefined,
420
500
  lucidDocumentUrl: lucidDocumentUrl.trim() || undefined,
421
501
  embedUrl: embedUrl.trim() || undefined,
@@ -442,6 +522,9 @@ function App() {
442
522
  if (!selectedId || !versionId) {
443
523
  return
444
524
  }
525
+ if (!confirmDiscardUnsavedChanges()) {
526
+ return
527
+ }
445
528
  setBusy(true)
446
529
  try {
447
530
  const response = await executeAction('restore_version', selectedId, {
@@ -464,6 +547,9 @@ function App() {
464
547
  if (!selectedId) {
465
548
  return
466
549
  }
550
+ if (!confirmDiscardUnsavedChanges() || !window.confirm(t('confirmArchive'))) {
551
+ return
552
+ }
467
553
  setBusy(true)
468
554
  try {
469
555
  await executeAction('archive_document', selectedId, { documentId: selectedId })
@@ -534,6 +620,12 @@ function App() {
534
620
  if (!file) {
535
621
  return
536
622
  }
623
+ if (hasUnsavedChanges() && !window.confirm(t('discardUnsavedChanges'))) {
624
+ if (fileInputRef.current) {
625
+ fileInputRef.current.value = ''
626
+ }
627
+ return
628
+ }
537
629
  setBusy(true)
538
630
  try {
539
631
  const response = await executeFileAction(
@@ -617,15 +709,52 @@ function App() {
617
709
  }
618
710
 
619
711
  function exportJson() {
620
- try {
621
- const parsed = JSON.parse(standardImportText)
622
- downloadBlob(
623
- new Blob([JSON.stringify(parsed, null, 2)], { type: 'application/json' }),
624
- `${detail?.item?.title || 'document'}.json`
625
- )
626
- } catch (error) {
627
- notify('error', `${t('invalidJson')}: ${getErrorMessage(error)}`)
712
+ const parsed = parseStandardImportDocument(standardImportText)
713
+ if (parsed.error || !parsed.value) {
714
+ notify('error', `${t('invalidJson')}: ${parsed.error || t('unknownError')}`)
715
+ return
628
716
  }
717
+ downloadBlob(
718
+ new Blob([JSON.stringify(parsed.value, null, 2)], { type: 'application/json' }),
719
+ `${detail?.item?.title || 'document'}.json`
720
+ )
721
+ }
722
+
723
+ function formatStandardImportJson() {
724
+ const parsed = parseStandardImportDocument(standardImportText)
725
+ if (parsed.error || !parsed.value) {
726
+ notify('error', `${t('invalidJson')}: ${parsed.error || t('unknownError')}`)
727
+ return
728
+ }
729
+ updateStandardImportText(stringifyJson(parsed.value))
730
+ }
731
+
732
+ function revertStandardImportJson() {
733
+ const title = detail?.item?.title || t('untitled')
734
+ const standardImport = isObject(currentVersion?.standardImport) ? currentVersion.standardImport : createDefaultStandardImport(title)
735
+ updateStandardImportText(stringifyJson(standardImport))
736
+ }
737
+
738
+ function updateMetadataTitle(nextTitle: string) {
739
+ setMetadataTitle(nextTitle)
740
+ setMetadataDirty(true)
741
+ }
742
+
743
+ function updateMetadataDescription(nextDescription: string) {
744
+ setMetadataDescription(nextDescription)
745
+ setMetadataDirty(true)
746
+ }
747
+
748
+ function updateMetadataKind(nextKind: DocumentKind) {
749
+ setMetadataKind(nextKind)
750
+ setMetadataDirty(true)
751
+ }
752
+
753
+ function openNewDocumentDialog() {
754
+ if (!confirmDiscardUnsavedChanges()) {
755
+ return
756
+ }
757
+ setNewDialogOpen(true)
629
758
  }
630
759
 
631
760
  const currentVersion = detail?.currentVersion || null
@@ -633,8 +762,11 @@ function App() {
633
762
  const embeddableUrl = embedUrl.trim()
634
763
  const imagePreviewUrl = previewUrl.trim()
635
764
  const lucidOpenUrl = embeddableUrl || lucidDocumentUrl.trim()
765
+ const standardImportParse = React.useMemo(() => parseStandardImportDocument(standardImportText), [standardImportText])
636
766
  const standardImportPreview = React.useMemo(() => createStandardImportPreview(standardImportText), [standardImportText])
637
- const canSave = Boolean(selectedId && dirty && !busy)
767
+ const isJsonValid = Boolean(standardImportParse.value && !standardImportParse.error)
768
+ const canSave = Boolean(selectedId && dirty && !busy && isJsonValid)
769
+ const currentTitle = detail?.item?.title || t('untitled')
638
770
  const shellClassName = `lw-shell ${leftPanelCollapsed ? 'left-collapsed' : ''} ${rightPanelCollapsed ? 'right-collapsed' : ''}`
639
771
  const previewBadge = embeddableUrl
640
772
  ? t('embedPreview')
@@ -646,6 +778,39 @@ function App() {
646
778
 
647
779
  return (
648
780
  <div className={shellClassName}>
781
+ <Dialog open={newDialogOpen} onOpenChange={setNewDialogOpen}>
782
+ <DialogContent className="lw-dialog">
783
+ <DialogHeader>
784
+ <DialogTitle>{t('newDocument')}</DialogTitle>
785
+ </DialogHeader>
786
+ <div className="lw-dialog-stack">
787
+ <Input value={newTitle} placeholder={t('title')} onChange={(event: any) => setNewTitle(event.target.value)} />
788
+ <Textarea value={newDescription} placeholder={t('description')} onChange={(event: any) => setNewDescription(event.target.value)} />
789
+ <Select value={newKind} onValueChange={(value: string) => setNewKind(normalizeDocumentKind(value))}>
790
+ <SelectTrigger aria-label={t('kind')}>
791
+ <SelectValue placeholder={t('kind')} />
792
+ </SelectTrigger>
793
+ <SelectContent>
794
+ {DOCUMENT_KINDS.map((kind) => (
795
+ <SelectItem value={kind} key={kind}>
796
+ {t(kind)}
797
+ </SelectItem>
798
+ ))}
799
+ </SelectContent>
800
+ </Select>
801
+ </div>
802
+ <DialogFooter>
803
+ <Button type="button" variant="outline" disabled={busy} onClick={() => setNewDialogOpen(false)}>
804
+ {t('cancel')}
805
+ </Button>
806
+ <Button type="button" disabled={busy} onClick={createDocument}>
807
+ <Plus className="lw-button-icon" aria-hidden="true" />
808
+ {t('create')}
809
+ </Button>
810
+ </DialogFooter>
811
+ </DialogContent>
812
+ </Dialog>
813
+
649
814
  <Sidebar className="lw-sidebar" side="left" collapsed={leftPanelCollapsed}>
650
815
  <SidebarHeader>
651
816
  <SidebarTrigger
@@ -703,10 +868,10 @@ function App() {
703
868
  <SidebarMenu>
704
869
  {documents.map((document) => (
705
870
  <SidebarMenuItem key={document.id}>
706
- <SidebarMenuButton type="button" active={document.id === selectedId} onClick={() => selectDocument(document.id)}>
871
+ <SidebarMenuButton type="button" active={document.id === selectedId} onClick={() => selectDocumentWithGuard(document.id)}>
707
872
  <span className="lw-item-title">{document.title || t('untitled')}</span>
708
873
  <span className="lw-item-meta">
709
- v{document.currentVersionNumber || 0} · {t((document.status || 'draft') as TranslationKey)}
874
+ v{document.currentVersionNumber || 0} · {t((document.status || 'draft') as TranslationKey)} · {t(normalizeDocumentKind(document.kind))}
710
875
  </span>
711
876
  </SidebarMenuButton>
712
877
  </SidebarMenuItem>
@@ -720,10 +885,22 @@ function App() {
720
885
  <main className="lw-main">
721
886
  <div className="lw-toolbar">
722
887
  <div className="lw-toolbar-title">
723
- <Input className="lw-title-input" value={newTitle} placeholder={t('title')} onChange={(event: any) => setNewTitle(event.target.value)} />
888
+ <div className="lw-title-text">{selectedId ? currentTitle : t('workbenchTitle')}</div>
889
+ <div className="lw-title-meta">
890
+ {selectedId ? (
891
+ <>
892
+ <Badge variant="secondary">{t(documentStatus as TranslationKey)}</Badge>
893
+ <Badge variant="secondary">v{detail?.item?.currentVersionNumber || 0}</Badge>
894
+ <Badge variant="secondary">{t(normalizeDocumentKind(detail?.item?.kind))}</Badge>
895
+ {currentVersion?.sourceType ? <Badge variant="secondary">{currentVersion.sourceType}</Badge> : null}
896
+ </>
897
+ ) : (
898
+ <span>{t('noDocument')}</span>
899
+ )}
900
+ </div>
724
901
  </div>
725
902
  <div className="lw-toolbar-actions">
726
- <Button type="button" variant="outline" size="sm" disabled={busy} onClick={createDocument}>
903
+ <Button type="button" variant="outline" size="sm" disabled={busy} onClick={openNewDocumentDialog}>
727
904
  <Plus className="lw-button-icon" aria-hidden="true" />
728
905
  {t('newDocument')}
729
906
  </Button>
@@ -735,7 +912,7 @@ function App() {
735
912
  <Upload className="lw-button-icon" aria-hidden="true" />
736
913
  {t('import')}
737
914
  </Button>
738
- <Button type="button" variant="outline" size="sm" disabled={!selectedId} onClick={exportJson}>
915
+ <Button type="button" variant="outline" size="sm" disabled={!selectedId || !isJsonValid} onClick={exportJson}>
739
916
  <Download className="lw-button-icon" aria-hidden="true" />
740
917
  {t('exportJson')}
741
918
  </Button>
@@ -744,7 +921,7 @@ function App() {
744
921
  {t('openLucid')}
745
922
  </Button>
746
923
  <Badge className="lw-status" variant={dirty ? 'warning' : 'secondary'}>
747
- {dirty ? t('dirty') : t('saved')}
924
+ {hasUnsavedChanges() ? t('dirty') : t('saved')}
748
925
  </Badge>
749
926
  </div>
750
927
  <input
@@ -758,27 +935,85 @@ function App() {
758
935
  <div className="lw-stage">
759
936
  {selectedId || detail?.item ? (
760
937
  <div className="lw-editor-pane">
761
- <div className="lw-editor-header">
762
- <Badge variant="secondary">{t('standardImport')}</Badge>
763
- {currentVersion?.sourceType ? <Badge variant="secondary">{currentVersion.sourceType}</Badge> : null}
764
- <Badge variant={embeddableUrl || imagePreviewUrl || standardImportPreview ? 'success' : 'secondary'}>{previewBadge}</Badge>
765
- </div>
766
- <div className="lw-visual-frame">
767
- {embeddableUrl ? (
768
- <iframe title="Lucidchart embed" src={embeddableUrl} />
769
- ) : imagePreviewUrl ? (
770
- <img src={imagePreviewUrl} alt={t('imagePreview')} />
771
- ) : standardImportPreview ? (
772
- <StandardImportPreview model={standardImportPreview} />
773
- ) : (
774
- <div className="lw-embed-empty">{t('previewUnavailable')}</div>
775
- )}
776
- </div>
777
- <Textarea
778
- className="lw-json-editor"
779
- value={standardImportText}
780
- onChange={(event: any) => updateStandardImportText(event.target.value)}
781
- />
938
+ <Tabs className="lw-tabs" value={mainTab} onValueChange={(value: string) => setMainTab(value)}>
939
+ <div className="lw-editor-header">
940
+ <TabsList>
941
+ <TabsTrigger value="preview">
942
+ <Image className="lw-button-icon" aria-hidden="true" />
943
+ {t('preview')}
944
+ </TabsTrigger>
945
+ <TabsTrigger value="json">
946
+ <FileJson className="lw-button-icon" aria-hidden="true" />
947
+ {t('json')}
948
+ </TabsTrigger>
949
+ <TabsTrigger value="mermaid">{t('mermaid')}</TabsTrigger>
950
+ <TabsTrigger value="links">{t('links')}</TabsTrigger>
951
+ </TabsList>
952
+ <Badge variant={embeddableUrl || imagePreviewUrl || standardImportPreview ? 'success' : 'secondary'}>{previewBadge}</Badge>
953
+ </div>
954
+
955
+ <TabsContent className="lw-tab-content" value="preview">
956
+ <div className="lw-visual-frame">
957
+ {embeddableUrl ? (
958
+ <iframe title="Lucidchart embed" src={embeddableUrl} />
959
+ ) : imagePreviewUrl ? (
960
+ <img src={imagePreviewUrl} alt={t('imagePreview')} />
961
+ ) : standardImportPreview ? (
962
+ <StandardImportPreview model={standardImportPreview} />
963
+ ) : (
964
+ <div className="lw-embed-empty">{t('previewUnavailable')}</div>
965
+ )}
966
+ </div>
967
+ </TabsContent>
968
+
969
+ <TabsContent className="lw-tab-content lw-json-tab" value="json">
970
+ <div className="lw-tab-toolbar">
971
+ <div className="lw-inline-badges">
972
+ <Badge variant={isJsonValid ? 'success' : 'warning'}>{isJsonValid ? t('jsonValid') : t('jsonInvalid')}</Badge>
973
+ {standardImportParse.error ? <span className="lw-muted">{standardImportParse.error}</span> : null}
974
+ </div>
975
+ <div className="lw-inline-actions">
976
+ <Button type="button" variant="outline" size="sm" disabled={!isJsonValid} onClick={formatStandardImportJson}>
977
+ {t('formatJson')}
978
+ </Button>
979
+ <Button type="button" variant="outline" size="sm" disabled={busy} onClick={revertStandardImportJson}>
980
+ <RotateCcw className="lw-button-icon" aria-hidden="true" />
981
+ {t('revertJson')}
982
+ </Button>
983
+ </div>
984
+ </div>
985
+ <Textarea className="lw-json-editor" value={standardImportText} onChange={(event: any) => updateStandardImportText(event.target.value)} />
986
+ </TabsContent>
987
+
988
+ <TabsContent className="lw-tab-content lw-form-tab" value="mermaid">
989
+ <section className="lw-section">
990
+ <div className="lw-section-title">{t('mermaid')}</div>
991
+ <Textarea className="lw-tall-textarea" value={mermaidSource} onChange={(event: any) => updateMermaidSource(event.target.value)} />
992
+ <div className="lw-muted">{t('standardImportNotice')}</div>
993
+ <div className="lw-inline-actions">
994
+ <Button type="button" disabled={busy || !mermaidSource.trim()} onClick={saveMermaidDraft}>
995
+ <Save className="lw-button-icon" aria-hidden="true" />
996
+ {t('saveMermaid')}
997
+ </Button>
998
+ </div>
999
+ </section>
1000
+ </TabsContent>
1001
+
1002
+ <TabsContent className="lw-tab-content lw-form-tab" value="links">
1003
+ <section className="lw-section">
1004
+ <div className="lw-section-title">{t('externalDocument')}</div>
1005
+ <Input value={lucidDocumentUrl} placeholder={t('lucidDocumentUrl')} onChange={(event: any) => updateLucidDocumentUrl(event.target.value)} />
1006
+ <Input value={embedUrl} placeholder={t('embedUrl')} onChange={(event: any) => updateEmbedUrl(event.target.value)} />
1007
+ <Input value={lucidDocumentId} placeholder={t('lucidDocumentId')} onChange={(event: any) => updateLucidDocumentId(event.target.value)} />
1008
+ <Input value={previewUrl} placeholder={t('previewUrl')} onChange={(event: any) => updatePreviewUrl(event.target.value)} />
1009
+ <div className="lw-inline-actions">
1010
+ <Button type="button" disabled={busy || (!lucidDocumentId.trim() && !lucidDocumentUrl.trim() && !embedUrl.trim())} onClick={registerExternalDocument}>
1011
+ {t('registerExternal')}
1012
+ </Button>
1013
+ </div>
1014
+ </section>
1015
+ </TabsContent>
1016
+ </Tabs>
782
1017
  </div>
783
1018
  ) : (
784
1019
  <div className="lw-empty">{t('noDocument')}</div>
@@ -789,28 +1024,7 @@ function App() {
789
1024
  <Sidebar className="lw-inspector" side="right" collapsed={rightPanelCollapsed}>
790
1025
  <SidebarHeader>
791
1026
  {!rightPanelCollapsed ? (
792
- <>
793
- <div className="lw-inspector-actions">
794
- {documentStatus === 'archived' ? (
795
- <Badge variant="secondary">{t('archived')}</Badge>
796
- ) : documentStatus === 'reviewed' ? (
797
- <Button type="button" variant="outline" size="sm" disabled={busy || !selectedId} onClick={() => setDocumentReviewStatus('draft')}>
798
- <RotateCcw className="lw-button-icon" aria-hidden="true" />
799
- {t('backToDraft')}
800
- </Button>
801
- ) : (
802
- <Button type="button" variant="outline" size="sm" disabled={busy || !selectedId} onClick={() => setDocumentReviewStatus('reviewed')}>
803
- <Check className="lw-button-icon" aria-hidden="true" />
804
- {t('markReviewed')}
805
- </Button>
806
- )}
807
- <Button type="button" variant="destructiveOutline" size="sm" disabled={busy || !selectedId || documentStatus === 'archived'} onClick={archiveDocument}>
808
- <Archive className="lw-button-icon" aria-hidden="true" />
809
- {t('archive')}
810
- </Button>
811
- </div>
812
- <SidebarTitle className="lw-sidebar-title-truncate">{detail?.item?.title || t('inspector')}</SidebarTitle>
813
- </>
1027
+ <SidebarTitle className="lw-sidebar-title-truncate">{detail?.item?.title || t('inspector')}</SidebarTitle>
814
1028
  ) : null}
815
1029
  <SidebarTrigger
816
1030
  className="lw-sidebar-trigger-right"
@@ -828,72 +1042,117 @@ function App() {
828
1042
  ) : (
829
1043
  <SidebarContent>
830
1044
  <ScrollArea className="lw-inspector-scroll">
831
- <div className="lw-inspector-stack">
832
- <section className="lw-section">
833
- <div className="lw-section-title">{t('changeSummary')}</div>
834
- <Input value={changeSummary} placeholder={t('changeSummary')} onChange={(event: any) => setChangeSummary(event.target.value)} />
835
- </section>
836
-
837
- <section className="lw-section">
838
- <div className="lw-section-title">{t('versions')}</div>
839
- {(detail?.versions || []).map((version) => (
840
- <div className="lw-version" key={version.id}>
841
- <div>
842
- <div>v{version.versionNumber}</div>
843
- <div className="lw-muted">{version.sourceType || 'workbench'}</div>
844
- </div>
845
- <Button
846
- className="lw-version-action"
847
- type="button"
848
- variant="outline"
849
- size="icon"
850
- title={t('restore')}
851
- aria-label={`${t('restore')} v${version.versionNumber}`}
852
- disabled={busy}
853
- onClick={() => restoreVersion(version.id)}
854
- >
855
- <RotateCcw className="lw-button-icon" aria-hidden="true" />
856
- </Button>
857
- </div>
858
- ))}
859
- </section>
1045
+ <Tabs className="lw-inspector-tabs" value={inspectorTab} onValueChange={(value: string) => setInspectorTab(value)}>
1046
+ <TabsList className="lw-inspector-tabs-list">
1047
+ <TabsTrigger value="info">{t('info')}</TabsTrigger>
1048
+ <TabsTrigger value="versions">{t('versions')}</TabsTrigger>
1049
+ <TabsTrigger value="activity">{t('activity')}</TabsTrigger>
1050
+ <TabsTrigger value="assistant">{t('assistant')}</TabsTrigger>
1051
+ </TabsList>
1052
+
1053
+ <TabsContent className="lw-inspector-stack" value="info">
1054
+ <section className="lw-section">
1055
+ <div className="lw-section-title">{t('documentInfo')}</div>
1056
+ <Input value={metadataTitle} placeholder={t('title')} onChange={(event: any) => updateMetadataTitle(event.target.value)} />
1057
+ <Textarea value={metadataDescription} placeholder={t('description')} onChange={(event: any) => updateMetadataDescription(event.target.value)} />
1058
+ <Select value={metadataKind} onValueChange={(value: string) => updateMetadataKind(normalizeDocumentKind(value))}>
1059
+ <SelectTrigger aria-label={t('kind')}>
1060
+ <SelectValue placeholder={t('kind')} />
1061
+ </SelectTrigger>
1062
+ <SelectContent>
1063
+ {DOCUMENT_KINDS.map((kind) => (
1064
+ <SelectItem value={kind} key={kind}>
1065
+ {t(kind)}
1066
+ </SelectItem>
1067
+ ))}
1068
+ </SelectContent>
1069
+ </Select>
1070
+ </section>
1071
+
1072
+ <section className="lw-section">
1073
+ <div className="lw-section-title">{t('changeSummary')}</div>
1074
+ <Input value={changeSummary} placeholder={t('changeSummary')} onChange={(event: any) => setChangeSummary(event.target.value)} />
1075
+ </section>
860
1076
 
861
- <section className="lw-section">
862
- <div className="lw-section-title">{t('mermaid')}</div>
863
- <Textarea value={mermaidSource} onChange={(event: any) => updateMermaidSource(event.target.value)} />
864
- <div className="lw-muted">{t('standardImportNotice')}</div>
865
1077
  <div className="lw-inline-actions">
866
- <Button type="button" size="sm" disabled={busy || !mermaidSource.trim()} onClick={saveMermaidDraft}>
867
- {t('saveMermaid')}
1078
+ <Button type="button" disabled={busy || !selectedId || !metadataDirty} onClick={updateDocumentMetadata}>
1079
+ <Save className="lw-button-icon" aria-hidden="true" />
1080
+ {t('saveMetadata')}
1081
+ </Button>
1082
+ {documentStatus === 'archived' ? (
1083
+ <Badge variant="secondary">{t('archived')}</Badge>
1084
+ ) : documentStatus === 'reviewed' ? (
1085
+ <Button type="button" variant="outline" disabled={busy || !selectedId} onClick={() => setDocumentReviewStatus('draft')}>
1086
+ <RotateCcw className="lw-button-icon" aria-hidden="true" />
1087
+ {t('backToDraft')}
1088
+ </Button>
1089
+ ) : (
1090
+ <Button type="button" variant="outline" disabled={busy || !selectedId} onClick={() => setDocumentReviewStatus('reviewed')}>
1091
+ <Check className="lw-button-icon" aria-hidden="true" />
1092
+ {t('markReviewed')}
1093
+ </Button>
1094
+ )}
1095
+ <Button type="button" variant="destructiveOutline" disabled={busy || !selectedId || documentStatus === 'archived'} onClick={archiveDocument}>
1096
+ <Archive className="lw-button-icon" aria-hidden="true" />
1097
+ {t('archive')}
868
1098
  </Button>
869
1099
  </div>
870
- </section>
871
-
872
- <section className="lw-section">
873
- <div className="lw-section-title">{t('externalDocument')}</div>
874
- <Input value={lucidDocumentUrl} placeholder={t('lucidDocumentUrl')} onChange={(event: any) => updateLucidDocumentUrl(event.target.value)} />
875
- <Input value={embedUrl} placeholder={t('embedUrl')} onChange={(event: any) => updateEmbedUrl(event.target.value)} />
876
- <Input value={lucidDocumentId} placeholder={t('lucidDocumentId')} onChange={(event: any) => updateLucidDocumentId(event.target.value)} />
877
- <Input value={previewUrl} placeholder={t('previewUrl')} onChange={(event: any) => updatePreviewUrl(event.target.value)} />
878
- <Button type="button" size="sm" disabled={busy || (!lucidDocumentId.trim() && !lucidDocumentUrl.trim() && !embedUrl.trim())} onClick={registerExternalDocument}>
879
- {t('registerExternal')}
880
- </Button>
881
- </section>
882
-
883
- <section className="lw-section">
884
- <div className="lw-section-title">{t('drawingRequest')}</div>
885
- <Textarea value={assistantPrompt} placeholder={t('drawingRequest')} onChange={(event: any) => setAssistantPrompt(event.target.value)} />
886
- <Button type="button" disabled={busy || !assistantPrompt.trim()} onClick={sendAssistantPrompt}>
887
- <Send className="lw-button-icon" aria-hidden="true" />
888
- {t('askAssistant')}
889
- </Button>
890
- </section>
891
-
892
- <section className="lw-section">
893
- <div className="lw-section-title">{t('description')}</div>
894
- <Textarea value={newDescription} placeholder={t('description')} onChange={(event: any) => setNewDescription(event.target.value)} />
895
- </section>
896
- </div>
1100
+ </TabsContent>
1101
+
1102
+ <TabsContent className="lw-inspector-stack" value="versions">
1103
+ {(detail?.versions || []).length ? (
1104
+ (detail?.versions || []).map((version) => (
1105
+ <div className="lw-version" key={version.id}>
1106
+ <div>
1107
+ <div>v{version.versionNumber}</div>
1108
+ <div className="lw-muted">{version.sourceType || 'workbench'}</div>
1109
+ {version.changeSummary ? <div className="lw-muted">{version.changeSummary}</div> : null}
1110
+ </div>
1111
+ <Button
1112
+ className="lw-version-action"
1113
+ type="button"
1114
+ variant="outline"
1115
+ size="icon"
1116
+ title={t('restore')}
1117
+ aria-label={`${t('restore')} v${version.versionNumber}`}
1118
+ disabled={busy}
1119
+ onClick={() => restoreVersion(version.id)}
1120
+ >
1121
+ <RotateCcw className="lw-button-icon" aria-hidden="true" />
1122
+ </Button>
1123
+ </div>
1124
+ ))
1125
+ ) : (
1126
+ <div className="lw-empty-state">{t('noVersions')}</div>
1127
+ )}
1128
+ </TabsContent>
1129
+
1130
+ <TabsContent className="lw-inspector-stack" value="activity">
1131
+ {(detail?.logs || []).length ? (
1132
+ (detail?.logs || []).map((log) => (
1133
+ <div className="lw-log" key={log.id || `${log.action}-${log.createdAt}`}>
1134
+ <div className="lw-log-title">{formatLogTitle(log)}</div>
1135
+ <div className="lw-muted">{formatDateTime(log.createdAt)}</div>
1136
+ {log.message ? <div className="lw-log-message">{log.message}</div> : null}
1137
+ {log.errorMessage ? <div className="lw-log-error">{log.errorMessage}</div> : null}
1138
+ </div>
1139
+ ))
1140
+ ) : (
1141
+ <div className="lw-empty-state">{t('noActivity')}</div>
1142
+ )}
1143
+ </TabsContent>
1144
+
1145
+ <TabsContent className="lw-inspector-stack" value="assistant">
1146
+ <section className="lw-section">
1147
+ <div className="lw-section-title">{t('drawingRequest')}</div>
1148
+ <Textarea className="lw-tall-textarea" value={assistantPrompt} placeholder={t('drawingRequest')} onChange={(event: any) => setAssistantPrompt(event.target.value)} />
1149
+ <Button type="button" disabled={busy || !assistantPrompt.trim()} onClick={sendAssistantPrompt}>
1150
+ <Send className="lw-button-icon" aria-hidden="true" />
1151
+ {t('askAssistant')}
1152
+ </Button>
1153
+ </section>
1154
+ </TabsContent>
1155
+ </Tabs>
897
1156
  </ScrollArea>
898
1157
  </SidebarContent>
899
1158
  )}
@@ -960,6 +1219,46 @@ function isObject(value: unknown): value is Record<string, unknown> {
960
1219
  return Boolean(value && typeof value === 'object' && !Array.isArray(value))
961
1220
  }
962
1221
 
1222
+ function parseStandardImportDocument(source: string): JsonParseResult {
1223
+ try {
1224
+ const parsed = JSON.parse(source)
1225
+ if (!isObject(parsed)) {
1226
+ return { value: null, error: 'Expected a JSON object.' }
1227
+ }
1228
+ return { value: parsed, error: null }
1229
+ } catch (error) {
1230
+ return {
1231
+ value: null,
1232
+ error: error instanceof Error && error.message ? error.message : 'Invalid JSON.'
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ function normalizeDocumentKind(value: unknown): DocumentKind {
1238
+ return DOCUMENT_KINDS.includes(value as DocumentKind) ? (value as DocumentKind) : 'diagram'
1239
+ }
1240
+
1241
+ function formatDateTime(value: unknown) {
1242
+ if (!value) {
1243
+ return ''
1244
+ }
1245
+ const date = new Date(String(value))
1246
+ if (Number.isNaN(date.getTime())) {
1247
+ return String(value)
1248
+ }
1249
+ return date.toLocaleString()
1250
+ }
1251
+
1252
+ function formatLogTitle(log: Record<string, unknown>) {
1253
+ const action = typeof log.action === 'string' && log.action.trim() ? log.action.trim() : 'activity'
1254
+ const actor = typeof log.actorType === 'string' && log.actorType.trim() ? log.actorType.trim() : ''
1255
+ return actor ? `${action} · ${actor}` : action
1256
+ }
1257
+
1258
+ function isCompactViewport() {
1259
+ return typeof window !== 'undefined' && window.innerWidth < 1040
1260
+ }
1261
+
963
1262
  function StandardImportPreview({ model }: { model: StandardImportPreviewModel }) {
964
1263
  return (
965
1264
  <div className="lw-standard-preview">