Diagram editor pr (#1432)
* feat: add draw.io diagram editor integration Embed draw.io editor via react-drawio (MIT, zero deps) for inline editing of .drawio files. Changes auto-save to disk. Includes inline editor in FilesView with Visual/Source toggle, dark mode support, template picker for new files, and chat file attachment integration. * fix: debounce diagram autosave to prevent reload loop * fix: ignore watcher-triggered xml prop changes to prevent reload loop * fix: remove auto-save-to-disk, add manual save button for diagrams Autosave writes triggered file watcher cascade that reloaded the draw.io iframe and reset zoom. Replaced with explicit Save button in the toolbar (floppy disk icon). Editor XML is stable on mount and ignores watcher-triggered prop changes. * fix: remove auto-save write from DiagramView, add save button * fix: hide draw.io save/exit buttons in editor * fix: also hide save-and-exit button * fix: brighten save button styling, add saved confirmation * fix: remove autoSaveStatus toggle on diagram save to prevent toolbar collapse * fix: add local save confirmation state for diagram button * fix: remount drawio iframe on theme change, persisting XML across mounts * fix: clear persisted xml on mount to prevent leaking between files * fix: initialize dark mode synchronously, preserve edits across theme remount * fix: auto-focus drawio iframe on mount/theme-change for keyboard shortcuts * fix: add diagram i18n keys to Traditional Chinese locale * fix: restore upstream HMR host and LAN address support * fix: load sub-agent sessions on bootstrap for sidebar visibility Two-phase session load: first fetch root sessions (for accurate sessionTotal), then fetch all sessions and include child sessions (sub-agent delegations). This ensures sub-agent sessions appear in the sidebar immediately instead of relying on the async global session store. * remove opencode-drawio from PR branch * fix: atomic file writes to prevent concurrent read/write truncation Three-layer defense against the O_TRUNC race: 1. Write side (server): replace direct writeFile with write-to-temp- then-rename. fs.rename is atomic on POSIX. 2. Read side (server): retry up to 3 times with 50ms backoff when readFile returns empty but stat reported non-zero size. 3. FilesView client: refuse to save empty draftContent when the original fileContent was non-empty. * fix(dev): clean up orphaned OpenCode processes on Ctrl+C * fix: allow empty file saves, log warning instead of blocking Replaces the hard block on saving empty content with a console.warn. The atomic write + read retry on the server side handle the O_TRUNC race properly. The previous guard caused a UX regression by silently preventing users from clearing a file and saving. * fix: remove time window from sub-agent fallback for live tasks While a task tool is active, the fallback now matches any session with the correct parentID regardless of creation time. This allows late-appearing child sessions to be found when the OpenCode server is slow or the SSE event pipeline is delayed. The time window is still applied once the task tool has completed, as a final sanity check. * fix: three diagram editor bugs from Greptile review 1. stableXmlRef now resets when xml prop changes — switching between .drawio files renders the correct content. 2. Focus effect only runs on mount, not on isDark changes — theme toggle no longer steals keyboard focus 600ms later. 3. saveDiagram updates xml state after writing — dirty-check guard works correctly for subsequent saves. * fix: route session.created SSE events to correct directory Three-layer fix for sub-agent sessions not appearing in sidebar and inline chat: 1. protocol.js: parseSseEventEnvelope now extracts directory from properties.info.directory (where session.created/updated events carry it) in addition to properties.directory. WS frames relayed to the browser now carry the real directory instead of 'global', so child sessions routed to the correct directory store. 2. event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth when SSE events bypass the WS relay. 3. resolveFallbackTaskSessionId.ts: time window lower bound now allows 2s grace before taskStartTime to accommodate server timing jitter (child session creation timestamps consistently precede the tool's recorded start by ~6-9ms), fixing the 'Open subtask' button not rendering in OpenChamber's inline chat. * fix: sub-agent sidebar visibility, file zeroing guard, inline badge fallback - Sync watchdog: periodic child session discovery poll (every 15s) detects sessions created by other OpenCode instances, triggers parent materialization - protocol.js: parseSseEventEnvelope extracts directory from properties.info.directory for session.created/updated events - event-pipeline.ts: same fallback in resolveEventDirectory for defense-in-depth - resolveFallbackTaskSessionId: don't require taskStartTime (cross-OpenCode); pick most recent child when multiple idle candidates exist - readTaskSessionIdFromOutput: parse <task id="ses_xxx"> format from output - FilesView: reinstate empty-draft guard (block save when draftContent='' but fileContent had content) to prevent file zeroing on tab switch * Fix diagram autosave reload loop * Highlight drawio files as XML * Use diff-compatible highlighting for drawio files * Restore drawio file icon mapping * Stabilize drawio source preview toggle --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
9b52222ef1
commit
d9b9b56599
@@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { isDrawioFile } from '@/lib/toolHelpers';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
@@ -551,6 +552,7 @@ interface FilePart {
|
||||
url?: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
source?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
||||
@@ -573,6 +575,7 @@ interface MessageFilesDisplayProps {
|
||||
}
|
||||
|
||||
export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url));
|
||||
|
||||
@@ -817,6 +820,41 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
);
|
||||
}
|
||||
|
||||
const source = file.source;
|
||||
const sourceType = typeof source?.type === 'string' ? source.type : undefined;
|
||||
const sourcePath = source && typeof (source as Record<string, unknown>).path === 'string' ? (source as Record<string, unknown>).path as string : undefined;
|
||||
const filePath = sourceType === 'file' && sourcePath ? sourcePath : (file.url || '');
|
||||
const isDrawio = filePath && isDrawioFile(filePath);
|
||||
|
||||
if (isDrawio) {
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useUIStore.getState().navigateToDiagram(filePath);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
|
||||
compact ? "text-xs" : "text-sm"
|
||||
)}
|
||||
>
|
||||
<Icon name="file" className={cn("text-muted-foreground shrink-0", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
<p className="text-xs text-status-info">{t('chat.fileAttachment.openInDiagram')}</p>
|
||||
</div>
|
||||
<Icon name="external-link" className={cn("text-muted-foreground shrink-0", compact ? "h-3 w-3" : "h-3.5 w-3.5")} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('chat.fileAttachment.openInDiagram')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
|
||||
describe('readTaskTagSessionIdFromOutput', () => {
|
||||
test('parses task tags without state attributes', () => {
|
||||
expect(readTaskTagSessionIdFromOutput('<task id="ses_abc123">')).toBe('ses_abc123');
|
||||
});
|
||||
|
||||
test('parses task tags with additional attributes', () => {
|
||||
expect(readTaskTagSessionIdFromOutput('<task id="ses_def456" state="completed">')).toBe('ses_def456');
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||
import { getToolIcon } from './toolPresentation';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getDiffPatchEntries, getPatchText } from './toolDiffUtils';
|
||||
@@ -957,7 +958,16 @@ const readTaskSessionIdFromOutput = (output: string | undefined): string | undef
|
||||
const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i);
|
||||
const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i);
|
||||
const candidate = taskMatch?.[1] ?? sessionMatch?.[1];
|
||||
return normalizeSessionIdCandidate(candidate);
|
||||
if (candidate) {
|
||||
return normalizeSessionIdCandidate(candidate);
|
||||
}
|
||||
|
||||
// OpenCode tool output may wrap child session id in <task id="ses_xxx">
|
||||
const taskTagSessionId = readTaskTagSessionIdFromOutput(output);
|
||||
if (taskTagSessionId) {
|
||||
return normalizeSessionIdCandidate(taskTagSessionId);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
|
||||
|
||||
@@ -1,103 +1,122 @@
|
||||
/**
|
||||
* resolveFallbackTaskSessionId — pure helper that resolves a pending task tool
|
||||
* to a child session from the directory session store when explicit taskSessionId
|
||||
* metadata is delayed.
|
||||
*
|
||||
* Conservative: only returns a session id when the match is unambiguous.
|
||||
*/
|
||||
|
||||
import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
/**
|
||||
* Fallback is intentionally narrow: only sessions created shortly after the
|
||||
* task started are eligible. This avoids binding to earlier or later sibling
|
||||
* subagent sessions when explicit task metadata is delayed.
|
||||
*/
|
||||
/**
|
||||
* Narrow initial window avoids binding to wrong sessions on first attempt.
|
||||
* Wide window on retry handles late-appearing child sessions under load.
|
||||
*/
|
||||
const TASK_SESSION_MATCH_WINDOW_MS = 3000;
|
||||
const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000;
|
||||
|
||||
const LIVE_STATUSES = new Set<string>(['busy', 'retry']);
|
||||
|
||||
export interface ResolveFallbackParams {
|
||||
/** True when this tool is a task tool */
|
||||
isTaskTool: boolean;
|
||||
/** The parent session id (current session) */
|
||||
parentSessionId: string | undefined;
|
||||
/** When the task tool started (ms timestamp) */
|
||||
taskStartTime: number | undefined;
|
||||
/** True when the task tool is finalized (completed/error/etc.) */
|
||||
isTaskFinalized?: boolean;
|
||||
/** Sessions from the directory store */
|
||||
sessions: Session[];
|
||||
/** Session status map from the sync store */
|
||||
sessionStatusMap?: Record<string, SessionStatus>;
|
||||
/** True when a previous resolution attempt has already failed (enables wider window) */
|
||||
hasRetried?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to resolve a child session id for a pending task tool by matching
|
||||
* against sessions in the directory store.
|
||||
*
|
||||
* Returns `undefined` when:
|
||||
* - Not a task tool
|
||||
* - Task is finalized
|
||||
* - Parent session is unknown
|
||||
* - No unambiguous match found
|
||||
*/
|
||||
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
|
||||
const {
|
||||
isTaskTool,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
isTaskFinalized = false,
|
||||
sessions,
|
||||
sessionStatusMap,
|
||||
hasRetried = false,
|
||||
} = params;
|
||||
|
||||
if (!isTaskTool || isTaskFinalized || !parentSessionId || typeof taskStartTime !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
|
||||
const latestAllowed = taskStartTime + windowMs;
|
||||
|
||||
// Filter candidate sessions: parentID matches and created shortly after task start.
|
||||
const candidates = sessions.filter((session) => {
|
||||
if (!session?.id || session.parentID !== parentSessionId) {
|
||||
return false;
|
||||
}
|
||||
const created = session.time?.created;
|
||||
if (typeof created !== 'number') {
|
||||
return false;
|
||||
}
|
||||
return created >= taskStartTime && created <= latestAllowed;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If exactly one candidate, return it regardless of status
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].id;
|
||||
}
|
||||
|
||||
// Multiple candidates: try to disambiguate by finding exactly one live (busy/retry)
|
||||
const liveCandidates = candidates.filter((session) => {
|
||||
const status = sessionStatusMap?.[session.id];
|
||||
return status != null && LIVE_STATUSES.has(status.type);
|
||||
});
|
||||
|
||||
if (liveCandidates.length === 1) {
|
||||
return liveCandidates[0].id;
|
||||
}
|
||||
|
||||
// Ambiguous — do not guess
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* resolveFallbackTaskSessionId — pure helper that resolves a pending task tool
|
||||
* to a child session from the directory session store when explicit taskSessionId
|
||||
* metadata is delayed.
|
||||
*
|
||||
* Conservative: only returns a session id when the match is unambiguous.
|
||||
*/
|
||||
|
||||
import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
/**
|
||||
* Fallback is intentionally narrow: only sessions created shortly after the
|
||||
* task started are eligible. This avoids binding to earlier or later sibling
|
||||
* subagent sessions when explicit task metadata is delayed.
|
||||
*/
|
||||
/**
|
||||
* Narrow initial window avoids binding to wrong sessions on first attempt.
|
||||
* Wide window on retry handles late-appearing child sessions under load.
|
||||
*/
|
||||
const TASK_SESSION_MATCH_WINDOW_MS = 3000;
|
||||
const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000;
|
||||
|
||||
const LIVE_STATUSES = new Set<string>(['busy', 'retry']);
|
||||
|
||||
export interface ResolveFallbackParams {
|
||||
/** True when this tool is a task tool */
|
||||
isTaskTool: boolean;
|
||||
/** The parent session id (current session) */
|
||||
parentSessionId: string | undefined;
|
||||
/** When the task tool started (ms timestamp) */
|
||||
taskStartTime: number | undefined;
|
||||
/** True when the task tool is finalized (completed/error/etc.) */
|
||||
isTaskFinalized?: boolean;
|
||||
/** Sessions from the directory store */
|
||||
sessions: Session[];
|
||||
/** Session status map from the sync store */
|
||||
sessionStatusMap?: Record<string, SessionStatus>;
|
||||
/** True when a previous resolution attempt has already failed (enables wider window) */
|
||||
hasRetried?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to resolve a child session id for a pending task tool by matching
|
||||
* against sessions in the directory store.
|
||||
*
|
||||
* Returns `undefined` when:
|
||||
* - Not a task tool
|
||||
* - Task is finalized
|
||||
* - Parent session is unknown
|
||||
* - No unambiguous match found
|
||||
*/
|
||||
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
|
||||
const {
|
||||
isTaskTool,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
isTaskFinalized = false,
|
||||
sessions,
|
||||
sessionStatusMap,
|
||||
hasRetried = false,
|
||||
} = params;
|
||||
|
||||
if (!isTaskTool || !parentSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Filter candidate sessions: parentID matches the current session.
|
||||
let candidates = sessions.filter((session) => {
|
||||
if (!session?.id || session.parentID !== parentSessionId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// When the task is still running, apply no time window — late-appearing
|
||||
// child sessions should still match. Once finalized, restrict to sessions
|
||||
// created within a generous window around the task start to avoid binding
|
||||
// to stale siblings. If taskStartTime is unavailable (cross-OpenCode
|
||||
// sessions), skip the time filter entirely.
|
||||
if (typeof taskStartTime === 'number' && isTaskFinalized) {
|
||||
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
|
||||
const latestAllowed = taskStartTime + windowMs;
|
||||
candidates = candidates.filter((session) => {
|
||||
const created = session.time?.created;
|
||||
return typeof created === 'number' && created >= taskStartTime - 2_000 && created <= latestAllowed;
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If exactly one candidate, return it regardless of status
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].id;
|
||||
}
|
||||
|
||||
// Multiple candidates: try to disambiguate by finding exactly one live (busy/retry)
|
||||
const liveCandidates = candidates.filter((session) => {
|
||||
const status = sessionStatusMap?.[session.id];
|
||||
return status != null && LIVE_STATUSES.has(status.type);
|
||||
});
|
||||
|
||||
if (liveCandidates.length === 1) {
|
||||
return liveCandidates[0].id;
|
||||
}
|
||||
|
||||
// All idle: pick the most recently created child session.
|
||||
// This handles the common case where a delegation completed and the
|
||||
// user is viewing the task tool result inline.
|
||||
if (liveCandidates.length === 0 && candidates.length > 1) {
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const aCreated = typeof a.time?.created === 'number' ? a.time.created : 0;
|
||||
const bCreated = typeof b.time?.created === 'number' ? b.time.created : 0;
|
||||
return bCreated - aCreated;
|
||||
});
|
||||
return sorted[0].id;
|
||||
}
|
||||
|
||||
// Ambiguous — do not guess
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const readTaskTagSessionIdFromOutput = (output: string): string | undefined => {
|
||||
const taskTagMatch = output.match(/<task\s+id="([^"]+)"(?:\s+[^>]*)?>/i);
|
||||
return taskTagMatch?.[1];
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { DrawIoEmbed } from 'react-drawio';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DiagramEditorHandle {
|
||||
getXml: () => string;
|
||||
}
|
||||
|
||||
export interface DiagramEditorProps {
|
||||
xml: string;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
onChange?: (xml: string) => void;
|
||||
}
|
||||
|
||||
const BLANK_XML = '<mxfile><diagram id="new" name="Page-1"><mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>';
|
||||
|
||||
function detectDark(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
return theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
|
||||
export const DiagramEditor = React.forwardRef<DiagramEditorHandle, DiagramEditorProps>(
|
||||
function DiagramEditor({ xml, readOnly, className, onChange }, ref) {
|
||||
const latestXmlRef = React.useRef(xml);
|
||||
const drawioRef = React.useRef<React.ComponentRef<typeof DrawIoEmbed>>(null);
|
||||
const hasShownTemplate = React.useRef(false);
|
||||
const [isDark, setIsDark] = React.useState(detectDark);
|
||||
const stableXmlRef = React.useRef(xml);
|
||||
|
||||
// When the parent switches files (xml prop changes), reset the stable
|
||||
// reference so the new content renders instead of the first file's content.
|
||||
const prevXmlRef = React.useRef(xml);
|
||||
if (prevXmlRef.current !== xml) {
|
||||
prevXmlRef.current = xml;
|
||||
stableXmlRef.current = xml;
|
||||
latestXmlRef.current = xml;
|
||||
}
|
||||
|
||||
const prevIsDark = React.useRef(isDark);
|
||||
if (prevIsDark.current !== isDark) {
|
||||
prevIsDark.current = isDark;
|
||||
stableXmlRef.current = latestXmlRef.current;
|
||||
}
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const check = () => setIsDark(detectDark());
|
||||
check();
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Focus the iframe once on mount so keyboard shortcuts work without
|
||||
// clicking the canvas. Intentionally not keyed on isDark — re-focusing
|
||||
// on every theme toggle would steal keyboard focus from the user.
|
||||
React.useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
containerRef.current?.querySelector<HTMLIFrameElement>('.diagrams-iframe')?.focus();
|
||||
}, 600);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getXml: () => latestXmlRef.current,
|
||||
}));
|
||||
|
||||
const handleLoad = React.useCallback(() => {
|
||||
if (!xml && !hasShownTemplate.current) {
|
||||
hasShownTemplate.current = true;
|
||||
setTimeout(() => {
|
||||
drawioRef.current?.template({});
|
||||
}, 500);
|
||||
}
|
||||
}, [xml]);
|
||||
|
||||
const handleAutoSave = React.useCallback((data: { xml: string }) => {
|
||||
latestXmlRef.current = data.xml;
|
||||
onChange?.(data.xml);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('h-full w-full', className)}>
|
||||
<DrawIoEmbed
|
||||
key={isDark ? 'dark' : 'light'}
|
||||
ref={drawioRef}
|
||||
xml={stableXmlRef.current || BLANK_XML}
|
||||
autosave
|
||||
urlParameters={{
|
||||
ui: readOnly ? 'simple' : isDark ? 'dark' : 'kennedy',
|
||||
spin: true,
|
||||
libraries: !readOnly,
|
||||
chrome: readOnly,
|
||||
nav: readOnly,
|
||||
layers: readOnly,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
saveAndExit: false,
|
||||
}}
|
||||
onLoad={handleLoad}
|
||||
onAutoSave={handleAutoSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { DiagramEditor, type DiagramEditorProps, type DiagramEditorHandle } from './DiagramEditor';
|
||||
@@ -1725,6 +1725,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
{ id: 'files', label: t('layout.mainTab.files'), icon: "folder-6" },
|
||||
{ id: 'terminal', label: t('layout.mainTab.terminal'), icon: "terminal-box" },
|
||||
{ id: 'context', label: t('layout.mainTab.context'), icon: "file-list-2" },
|
||||
{ id: 'diagram', label: t('layout.mainTab.diagram'), icon: 'file' },
|
||||
);
|
||||
|
||||
return base;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { PlanView } from '@/components/views/PlanView';
|
||||
|
||||
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
||||
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
||||
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
|
||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||
@@ -408,6 +409,8 @@ export const MainLayout: React.FC = () => {
|
||||
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
|
||||
case 'context':
|
||||
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
|
||||
case 'diagram':
|
||||
return <React.Suspense fallback={null}><DiagramView /></React.Suspense>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { DiagramEditor, type DiagramEditorHandle } from '@/components/diagram';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
export function DiagramView() {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
|
||||
const [filePath, setFilePath] = React.useState<string | null>(null);
|
||||
const [xml, setXml] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const editorRef = React.useRef<DiagramEditorHandle>(null);
|
||||
const pendingDiagramFile = useUIStore((state) => state.pendingDiagramFile);
|
||||
|
||||
const loadFile = React.useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
setFilePath(path);
|
||||
try {
|
||||
const result = await files?.readFile?.(path);
|
||||
if (result) {
|
||||
setXml(result.content);
|
||||
}
|
||||
} catch {
|
||||
setXml('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingDiagramFile) {
|
||||
return;
|
||||
}
|
||||
const pending = useUIStore.getState().consumePendingDiagramFile();
|
||||
if (pending) {
|
||||
void loadFile(pending);
|
||||
}
|
||||
}, [loadFile, pendingDiagramFile]);
|
||||
|
||||
const saveDiagram = React.useCallback(async () => {
|
||||
const newXml = editorRef.current?.getXml();
|
||||
if (filePath && files?.writeFile && newXml && newXml !== xml) {
|
||||
await files.writeFile(filePath, newXml);
|
||||
setXml(newXml);
|
||||
}
|
||||
}, [filePath, files, xml]);
|
||||
|
||||
const fileName = filePath ? filePath.split('/').pop() || filePath : '';
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="typography-ui text-muted-foreground">
|
||||
{t('filesView.editor.pickFileFromTree')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border/30 px-3 py-1.5">
|
||||
<Icon name="file" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="typography-ui text-muted-foreground truncate flex-1">{fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveDiagram()}
|
||||
className="size-6 flex items-center justify-center rounded-md text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={t('filesView.diagram.saveDiagram')}
|
||||
>
|
||||
<Icon name="save-3" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useUIStore.getState().setActiveMainTab('chat')}
|
||||
className="size-6 flex items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={t('filesView.diagram.closeDiagramView')}
|
||||
>
|
||||
<Icon name="close" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<DiagramEditor
|
||||
ref={editorRef}
|
||||
xml={xml}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user