From 56e717216aac04c4c33614fdc2194a606159ed3f Mon Sep 17 00:00:00 2001 From: bhlee Date: Wed, 12 Aug 2026 23:37:08 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A0=84=EC=97=AD=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=ED=8C=A8=EB=84=90=20=EB=B0=8F=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B7=B0=EC=96=B4=20UX=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - layout/page: 회사 뷰 전반에서 전역 워크스페이스 사이드 패널 노출 및 드래그 핸들 리사이즈 지원 - workspace-panel: 파일 패널 내 Markdown 및 Office 문서 미리보기 서빙 - codex/connect: Codex 크루 연동 브릿지 및 회사 MCP 활성화 --- app/c/[ws]/company-shell-context.jsx | 13 + app/c/[ws]/crew/[slug]/workspace-panel.jsx | 1082 ++++++++++++++++++++ app/c/[ws]/layout.jsx | 87 +- app/c/[ws]/page.jsx | 30 +- app/c/[ws]/settings/page.jsx | 2 +- app/globals.css | 376 ++++++- src/codex-crew-mcp.mjs | 70 ++ src/crew-actions.mjs | 202 ++++ src/runners/codex.mjs | 79 +- src/side-panel-layout.mjs | 41 + 10 files changed, 1959 insertions(+), 23 deletions(-) create mode 100644 app/c/[ws]/company-shell-context.jsx create mode 100644 app/c/[ws]/crew/[slug]/workspace-panel.jsx create mode 100644 src/codex-crew-mcp.mjs create mode 100644 src/crew-actions.mjs create mode 100644 src/side-panel-layout.mjs diff --git a/app/c/[ws]/company-shell-context.jsx b/app/c/[ws]/company-shell-context.jsx new file mode 100644 index 0000000..dec9d82 --- /dev/null +++ b/app/c/[ws]/company-shell-context.jsx @@ -0,0 +1,13 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +const CompanyShellContext = createContext(null); + +export function CompanyShellProvider({ value, children }) { + return {children}; +} + +export function useCompanyShell() { + return useContext(CompanyShellContext); +} diff --git a/app/c/[ws]/crew/[slug]/workspace-panel.jsx b/app/c/[ws]/crew/[slug]/workspace-panel.jsx new file mode 100644 index 0000000..0d645f2 --- /dev/null +++ b/app/c/[ws]/crew/[slug]/workspace-panel.jsx @@ -0,0 +1,1082 @@ +'use client'; + +// Codex식 우측 도구 작업영역 — 한 패널 안에서 파일 열기, 장기 실행 셸, 내장 브라우저를 탭으로 연다. +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { Icon, Markdown, Spinner, api } from '../../../../ui'; +import { useLang } from '../../../../i18n'; +import { + FILE_TREE_DEFAULT_WIDTH, + PANEL_DEFAULT_WIDTH, + clampWidth, + fileTreeWidthBounds, + panelWidthBounds, + widthFromLeftDrag, +} from '../../../../../src/side-panel-layout.mjs'; + +const TOOL_STORAGE_KEY = 'argo:crew-side-panel-tools:v1'; +const FILE_TREE_STORAGE_KEY = 'argo:crew-file-tree-width:v1'; +const TOOL_ORDER = ['files', 'terminal', 'browser']; +const TOOL_META = { + files: { icon: 'folder', label: 'crew.tools.files' }, + terminal: { icon: 'terminal', label: 'crew.tools.terminal' }, + browser: { icon: 'browser', label: 'crew.tools.browser' }, +}; + +const query = (ws, params) => { + const qs = new URLSearchParams(params); + return `/api/companies/${encodeURIComponent(ws)}/workspace?${qs}`; +}; + +async function saveWorkspaceMarkdown(ws, file, editor) { + const response = await fetch(`/api/companies/${encodeURIComponent(ws)}/workspace`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + root: file.rootId, + path: file.path, + content: editor.draft, + version: editor.version, + }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(data.error || `workspace-save-${response.status}`); + error.code = data.error || 'workspace-save-error'; + error.status = response.status; + throw error; + } + return data; +} + +/** 오른쪽 영역의 왼쪽 경계용 공통 포인터 리사이저. 포인터 캡처라 패널 밖으로 나가도 드래그가 이어진다. */ +function useLeftEdgeResize(value, onChange, getBounds) { + const drag = useRef(null); + + const finish = useCallback((event) => { + if (!drag.current || (event?.pointerId != null && drag.current.pointerId !== event.pointerId)) return; + drag.current = null; + document.documentElement.classList.remove('is-resizing-horizontal'); + if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, []); + + useEffect(() => () => { + drag.current = null; + document.documentElement.classList.remove('is-resizing-horizontal'); + }, []); + + return { + onPointerDown: (event) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.focus(); + drag.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: value }; + document.documentElement.classList.add('is-resizing-horizontal'); + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + onPointerMove: (event) => { + const current = drag.current; + if (!current || current.pointerId !== event.pointerId) return; + onChange(widthFromLeftDrag(current.startWidth, current.startX, event.clientX, getBounds())); + }, + onPointerUp: finish, + onPointerCancel: finish, + onLostPointerCapture: finish, + }; +} + +export default function WorkspacePanel({ ws, open = true, onClose, fileRequest = null, onWidthChange }) { + const { t } = useLang(); + const [tabs, setTabs] = useState(['files']); + const [active, setActive] = useState('files'); + const [launcherOpen, setLauncherOpen] = useState(false); + const [panelWidth, setPanelWidth] = useState(PANEL_DEFAULT_WIDTH); + const [panelBounds, setPanelBounds] = useState(() => panelWidthBounds(1920)); + const [hydrated, setHydrated] = useState(false); + const [filesDirty, setFilesDirty] = useState(false); + const updatePanelWidth = useCallback((next) => { + setPanelWidth((current) => { + const value = typeof next === 'function' ? next(current) : next; + onWidthChange?.(value); + return value; + }); + }, [onWidthChange]); + + useEffect(() => { + try { + const saved = JSON.parse(localStorage.getItem(TOOL_STORAGE_KEY) || '{}'); + const nextTabs = Array.isArray(saved.tabs) + ? saved.tabs.filter((tool, i, all) => TOOL_ORDER.includes(tool) && all.indexOf(tool) === i) + : []; + if (nextTabs.length) { + setTabs(nextTabs); + setActive(nextTabs.includes(saved.active) ? saved.active : nextTabs[0]); + } + const bounds = panelWidthBounds(window.innerWidth); + setPanelBounds(bounds); + updatePanelWidth(clampWidth(saved.panelWidth ?? PANEL_DEFAULT_WIDTH, bounds.min, bounds.max)); + } catch { /* 손상된 로컬 상태는 파일 탭 기본값으로 복구 */ } + setHydrated(true); + }, [updatePanelWidth]); + + useEffect(() => { + if (!hydrated) return; + try { localStorage.setItem(TOOL_STORAGE_KEY, JSON.stringify({ tabs, active, panelWidth })); } catch { /* 부가 상태 */ } + }, [hydrated, tabs, active, panelWidth]); + + const getPanelBounds = useCallback( + () => panelWidthBounds(typeof window === 'undefined' ? 1920 : window.innerWidth), + [], + ); + const panelResize = useLeftEdgeResize(panelWidth, updatePanelWidth, getPanelBounds); + useEffect(() => { + if (!fileRequest?.path) return; + setTabs((current) => current.includes('files') ? current : ['files', ...current]); + setActive('files'); + }, [fileRequest]); + useEffect(() => { + const fit = () => { + const bounds = getPanelBounds(); + setPanelBounds(bounds); + updatePanelWidth((width) => clampWidth(width, bounds.min, bounds.max)); + }; + window.addEventListener('resize', fit); + return () => window.removeEventListener('resize', fit); + }, [getPanelBounds, updatePanelWidth]); + + const resizePanelByKey = (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const bounds = getPanelBounds(); + const delta = event.key === 'ArrowLeft' ? 24 : -24; + updatePanelWidth((width) => clampWidth(width + delta, bounds.min, bounds.max)); + }; + + const openTool = (tool) => { + setTabs((current) => current.includes(tool) ? current : [...current, tool]); + setActive(tool); + setLauncherOpen(false); + }; + const closeTool = (tool) => { + if (tool === 'files' && filesDirty && !window.confirm(t('crew.tools.files.discardConfirm'))) return; + const index = tabs.indexOf(tool); + const next = tabs.filter((item) => item !== tool); + setTabs(next); + if (active === tool) setActive(next[Math.min(index, next.length - 1)] || ''); + }; + + return ( + + ); +} + +function ToolLauncher({ onOpen }) { + const { t } = useLang(); + return ( +
+ + {t('crew.tools.empty')} +
+ {TOOL_ORDER.map((tool) => ( + + ))} +
+
+ ); +} + +function FileTool({ ws, onDirtyChange, fileRequest }) { + const { t } = useLang(); + const [roots, setRoots] = useState([]); + const [rootId, setRootId] = useState('company'); + const [entriesByDir, setEntriesByDir] = useState({}); + const [expanded, setExpanded] = useState(new Set([''])); + const [loadingDirs, setLoadingDirs] = useState(new Set()); + const [openFiles, setOpenFiles] = useState([]); + const [activeFileKey, setActiveFileKey] = useState(''); + const [documents, setDocuments] = useState({}); + const [editors, setEditors] = useState({}); + const [filter, setFilter] = useState(''); + const [search, setSearch] = useState(null); + const [error, setError] = useState(''); + const [treeWidth, setTreeWidth] = useState(FILE_TREE_DEFAULT_WIDTH); + const [treeBounds, setTreeBounds] = useState(() => fileTreeWidthBounds(PANEL_DEFAULT_WIDTH)); + const [treeHydrated, setTreeHydrated] = useState(false); + const filesToolRef = useRef(null); + const documentsRef = useRef({}); + + const getTreeBounds = useCallback( + () => fileTreeWidthBounds(filesToolRef.current?.getBoundingClientRect().width || PANEL_DEFAULT_WIDTH), + [], + ); + const treeResize = useLeftEdgeResize(treeWidth, setTreeWidth, getTreeBounds); + + useEffect(() => { + try { + const saved = Number(localStorage.getItem(`${FILE_TREE_STORAGE_KEY}:${ws}`)); + const bounds = getTreeBounds(); + setTreeBounds(bounds); + setTreeWidth(clampWidth(saved || FILE_TREE_DEFAULT_WIDTH, bounds.min, bounds.max)); + } catch { /* 기본 폭 유지 */ } + setTreeHydrated(true); + }, [getTreeBounds, ws]); + + useEffect(() => { + if (!treeHydrated) return; + try { localStorage.setItem(`${FILE_TREE_STORAGE_KEY}:${ws}`, String(treeWidth)); } catch { /* 부가 상태 */ } + }, [treeHydrated, treeWidth, ws]); + + useEffect(() => { + const element = filesToolRef.current; + if (!element) return undefined; + const fit = () => { + const bounds = getTreeBounds(); + setTreeBounds(bounds); + setTreeWidth((width) => clampWidth(width, bounds.min, bounds.max)); + }; + fit(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', fit); + return () => window.removeEventListener('resize', fit); + } + const observer = new ResizeObserver(fit); + observer.observe(element); + return () => observer.disconnect(); + }, [getTreeBounds]); + + const resizeTreeByKey = (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const bounds = getTreeBounds(); + const delta = event.key === 'ArrowLeft' ? 20 : -20; + setTreeWidth((width) => clampWidth(width + delta, bounds.min, bounds.max)); + }; + + const activeRoot = roots.find((root) => root.id === rootId); + const activeFile = openFiles.find((file) => file.key === activeFileKey) || null; + const activeDocument = activeFile ? documents[activeFile.key] : null; + const activeEditor = activeFile ? editors[activeFile.key] : null; + const activeIsMarkdown = activeDocument?.status === 'ready' + && activeDocument.data?.kind === 'text' + && activeDocument.data.renderer === 'markdown'; + const hasUnsavedChanges = Object.values(editors).some((editor) => editor.dirty); + + useEffect(() => { documentsRef.current = documents; }, [documents]); + useEffect(() => { onDirtyChange?.(hasUnsavedChanges); }, [hasUnsavedChanges, onDirtyChange]); + useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]); + useEffect(() => { + if (!hasUnsavedChanges) return undefined; + const warn = (event) => { event.preventDefault(); event.returnValue = ''; }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [hasUnsavedChanges]); + + const loadDocument = useCallback(async (file, { force = false } = {}) => { + const current = documentsRef.current[file.key]; + if (!force && (current?.status === 'loading' || current?.status === 'ready')) return; + const loading = { status: 'loading', data: current?.data || null, error: '' }; + documentsRef.current = { ...documentsRef.current, [file.key]: loading }; + setDocuments((all) => ({ ...all, [file.key]: loading })); + try { + const data = await api(query(ws, { action: 'open', root: file.rootId, path: file.path })); + const ready = { status: 'ready', data, error: '', revision: Date.now() }; + documentsRef.current = { ...documentsRef.current, [file.key]: ready }; + setDocuments((all) => ({ ...all, [file.key]: ready })); + } catch (cause) { + const failed = { status: 'error', data: null, error: cause.message }; + documentsRef.current = { ...documentsRef.current, [file.key]: failed }; + setDocuments((all) => ({ ...all, [file.key]: failed })); + } + }, [ws]); + + const openFile = useCallback((path) => { + const file = { + key: `${rootId}:${path}`, + rootId, + rootLabel: activeRoot?.label || rootId, + rootLocation: activeRoot?.location || '', + path, + name: path.split('/').pop(), + }; + setOpenFiles((current) => current.some((item) => item.key === file.key) ? current : [...current, file]); + setActiveFileKey(file.key); + loadDocument(file); + }, [activeRoot, loadDocument, rootId]); + + const beginMarkdownEdit = () => { + if (!activeFile || !activeIsMarkdown) return; + setEditors((current) => { + const editor = current[activeFile.key]; + if (editor) { + return { ...current, [activeFile.key]: { ...editor, editing: true, error: '', saved: false } }; + } + return { + ...current, + [activeFile.key]: { + editing: true, + draft: activeDocument.data.content, + baseContent: activeDocument.data.content, + version: activeDocument.data.version, + dirty: false, + saving: false, + saved: false, + error: '', + }, + }; + }); + }; + + const updateMarkdownDraft = (content) => { + if (!activeFile || !activeEditor) return; + setEditors((current) => { + const editor = current[activeFile.key]; + if (!editor) return current; + return { + ...current, + [activeFile.key]: { + ...editor, + draft: content, + dirty: content !== editor.baseContent, + saved: false, + error: '', + }, + }; + }); + }; + + const showMarkdownPreview = () => { + if (!activeFile || !activeEditor) return; + setEditors((current) => ({ + ...current, + [activeFile.key]: { ...current[activeFile.key], editing: false }, + })); + }; + + const cancelMarkdownEdit = () => { + if (!activeFile || !activeEditor) return; + setEditors((current) => { + const editor = current[activeFile.key]; + return { + ...current, + [activeFile.key]: { + ...editor, + editing: false, + draft: editor.baseContent, + dirty: false, + saving: false, + saved: false, + error: '', + }, + }; + }); + }; + + const saveMarkdown = async () => { + if (!activeFile || !activeEditor?.dirty || activeEditor.saving) return; + const file = activeFile; + const editor = activeEditor; + setEditors((current) => ({ + ...current, + [file.key]: { ...current[file.key], saving: true, saved: false, error: '' }, + })); + try { + const data = await saveWorkspaceMarkdown(ws, file, editor); + const ready = { status: 'ready', data, error: '', revision: Date.now() }; + documentsRef.current = { ...documentsRef.current, [file.key]: ready }; + setDocuments((current) => ({ ...current, [file.key]: ready })); + setEditors((current) => ({ + ...current, + [file.key]: { + ...current[file.key], + editing: false, + draft: data.content, + baseContent: data.content, + version: data.version, + dirty: false, + saving: false, + saved: true, + error: '', + }, + })); + } catch (cause) { + setEditors((current) => ({ + ...current, + [file.key]: { + ...current[file.key], + saving: false, + saved: false, + error: cause.code || cause.message || 'workspace-save-error', + }, + })); + } + }; + + const closeFile = (key) => { + if (editors[key]?.saving) return; + if (editors[key]?.dirty && !window.confirm(t('crew.tools.files.discardConfirm'))) return; + const index = openFiles.findIndex((file) => file.key === key); + const next = openFiles.filter((file) => file.key !== key); + setOpenFiles(next); + setDocuments((current) => { + const copy = { ...current }; + delete copy[key]; + documentsRef.current = copy; + return copy; + }); + setEditors((current) => { + const copy = { ...current }; + delete copy[key]; + return copy; + }); + if (activeFileKey === key) setActiveFileKey(next[Math.min(index, next.length - 1)]?.key || ''); + }; + + const reloadActiveFile = () => { + if (!activeFile) return; + if (activeEditor?.dirty && !window.confirm(t('crew.tools.files.discardConfirm'))) return; + setEditors((current) => { + const copy = { ...current }; + delete copy[activeFile.key]; + return copy; + }); + loadDocument(activeFile, { force: true }); + }; + + const loadRoots = useCallback(async () => { + try { + const data = await api(query(ws, { action: 'roots' })); + setRoots(data.roots || []); + setRootId((current) => data.roots?.some((root) => root.id === current) ? current : (data.roots?.[0]?.id || 'company')); + } catch (e) { setError(e.message); } + }, [ws]); + + const loadDir = useCallback(async (path, { force = false } = {}) => { + if (!force && entriesByDir[path]) return; + setLoadingDirs((current) => new Set(current).add(path)); + try { + const data = await api(query(ws, { action: 'list', root: rootId, path })); + setEntriesByDir((current) => ({ ...current, [path]: data.entries || [] })); + setError(''); + } catch (e) { setError(e.message); } + finally { + setLoadingDirs((current) => { + const next = new Set(current); next.delete(path); return next; + }); + } + }, [entriesByDir, rootId, ws]); + + useEffect(() => { loadRoots(); }, [loadRoots]); + useEffect(() => { + setEntriesByDir({}); + setExpanded(new Set([''])); + setFilter(''); + setSearch(null); + if (rootId) { + api(query(ws, { action: 'list', root: rootId, path: '' })) + .then((data) => setEntriesByDir({ '': data.entries || [] })) + .catch((e) => setError(e.message)); + } + }, [rootId, ws]); + + // 채팅의 문서 링크가 패널을 열 때 파일 트리도 해당 문서까지 자동으로 이동한다. + // 루트 목록과 조상 디렉터리가 준비된 뒤에만 파일을 열어 race를 피한다. + useEffect(() => { + if (!fileRequest?.path || !roots.some((root) => root.id === (fileRequest.root || 'company'))) return; + const requestedRoot = fileRequest.root || 'company'; + if (rootId !== requestedRoot) { setRootId(requestedRoot); return; } + const path = String(fileRequest.path).replace(/^\/+|\/+$/g, ''); + if (!path || path.split('/').some((part) => !part || part === '.' || part === '..' || part.includes('\\'))) return; + let alive = true; + const parts = path.split('/'); + const ancestors = ['', ...parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/'))]; + setFilter(''); + setSearch(null); + setExpanded(new Set(ancestors)); + Promise.all(ancestors.map((dir) => loadDir(dir))).then(() => { + if (alive) openFile(path); + }).catch(() => {}); + return () => { alive = false; }; + }, [fileRequest, loadDir, openFile, rootId, roots]); + + useEffect(() => { + setOpenFiles([]); + setActiveFileKey(''); + setDocuments({}); + setEditors({}); + documentsRef.current = {}; + }, [ws]); + + useEffect(() => { + if (!filter.trim()) { setSearch(null); return undefined; } + let alive = true; + const timer = setTimeout(() => { + api(query(ws, { action: 'search', root: rootId, q: filter.trim() })) + .then((data) => { if (alive) setSearch(data); }) + .catch((e) => { if (alive) setError(e.message); }); + }, 220); + return () => { alive = false; clearTimeout(timer); }; + }, [filter, rootId, ws]); + + const toggleDir = (path) => { + const willOpen = !expanded.has(path); + setExpanded((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); else next.add(path); + return next; + }); + if (willOpen) loadDir(path); + }; + + const openSearchDirectory = async (path) => { + const parts = path.split('/'); + const ancestors = ['', ...parts.map((_, index) => parts.slice(0, index + 1).join('/'))]; + setFilter(''); + setExpanded(new Set(ancestors)); + await Promise.all(ancestors.slice(0, -1).map((dir) => loadDir(dir))); + await loadDir(path); + }; + + const tree = useMemo(() => { + const renderDir = (path, depth) => (entriesByDir[path] || []).map((entry) => ( +
+ + {entry.type === 'directory' && expanded.has(entry.path) && ( + loadingDirs.has(entry.path) + ?
+ : renderDir(entry.path, depth + 1) + )} +
+ )); + return renderDir('', 0); + // toggleDir is intentionally state-bound; recalculating the small visible tree keeps event closures current. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeFile, entriesByDir, expanded, loadingDirs, openFile, rootId]); + + const rawUrl = activeFile + ? query(ws, { action: 'raw', root: activeFile.rootId, path: activeFile.path }) + : ''; + const renderUrl = activeFile + ? query(ws, { action: 'render', root: activeFile.rootId, path: activeFile.path }) + : ''; + const breadcrumbParts = activeFile ? (() => { + const location = activeFile.rootLocation.replaceAll('\\', '/').split('/').filter(Boolean); + const rootTrail = location.length ? location.slice(-2) : [activeFile.rootLabel]; + return [...rootTrail, ...activeFile.path.split('/').filter(Boolean)]; + })() : []; + const markdownText = activeEditor?.draft ?? activeDocument?.data?.content ?? ''; + const editorError = activeEditor?.error === 'file-changed' + ? t('crew.tools.files.changed') + : activeEditor?.error === 'too-large' + ? t('crew.tools.files.tooLarge') + : activeEditor?.error + ? t('crew.tools.files.saveFailed') + : ''; + return ( +
+
+
+ + +
+ {activeRoot &&
{activeRoot.location}
} + +
+ {filter.trim() ? ( + search === null ?
{t('crew.tools.files.searching')}
+ : search.entries?.length ? search.entries.map((entry) => ( + + )) :
{t('crew.tools.files.noMatch')}
+ ) : tree} +
+ {roots.length === 1 && ( + + {t('crew.tools.files.connect')} + + )} +
+
+
+ {!activeFile &&
{t('crew.tools.files.pick')}
} + {activeFile && ( + <> +
+ {openFiles.map((file) => ( + + + + + ))} +
+
+ + {activeIsMarkdown && ( +
+ {activeEditor?.editing ? ( + + ) : ( + + )} + {activeEditor && ( + <> + {activeEditor.dirty && ( + + )} + + + )} + + {editorError || (activeEditor?.saved + ? t('common.saved') + : activeEditor?.dirty ? t('crew.tools.files.unsaved') : '')} + +
+ )} + + + + +
+
+ {activeDocument?.status === 'loading' &&
{t('common.loading')}
} + {activeDocument?.status === 'error' &&
{activeDocument.error}
} + {activeDocument?.status === 'ready' && activeDocument.data?.kind === 'text' + && activeDocument.data.renderer === 'markdown' && activeEditor?.editing && ( +