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:
nerdosaurus
2026-06-08 18:50:56 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9b52222ef1
commit d9b9b56599
31 changed files with 7102 additions and 6477 deletions
+11 -5
View File
@@ -45,6 +45,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.8",
"bun-pty": "^0.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -98,7 +99,7 @@
},
"packages/electron": {
"name": "@openchamber/electron",
"version": "1.12.1",
"version": "1.12.3",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
@@ -113,7 +114,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
"version": "1.12.1",
"version": "1.12.3",
"dependencies": {
"@base-ui/react": "^1.4.0",
"@codemirror/autocomplete": "^6.20.0",
@@ -172,6 +173,7 @@
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-drawio": "1.0.7",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
@@ -213,7 +215,7 @@
},
"packages/vscode": {
"name": "openchamber",
"version": "1.12.1",
"version": "1.12.3",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.16.0",
@@ -236,7 +238,7 @@
},
"packages/web": {
"name": "@openchamber/web",
"version": "1.12.1",
"version": "1.12.3",
"bin": {
"openchamber": "./bin/cli.js",
},
@@ -1408,7 +1410,7 @@
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
"@zumer/snapdom": ["@zumer/snapdom@2.12.0", "", {}, "sha512-TdLGu+1RkKI3JKfMFvn1gRPD/rl8hrAeN6aFjjd0w7S39nulbd94ChxSlfH7nSCm8kzuQkeWFxIsij4+Mk1RDg=="],
"@zumer/snapdom": ["@zumer/snapdom@2.12.8", "", {}, "sha512-dLX6ZMNjLveasn9yhcruOOfd8GBZBDp59F7iJoLlGf7BnGp0vfVsjxIZDIjN2UTZJN1KoJR/BXsxEnAyY7LuXA=="],
"abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
@@ -2750,6 +2752,8 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-drawio": ["react-drawio@1.0.7", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vl3Bk7hLOl3k2i317CtxlG8axqBsLJMsuY6Sw5sT1wpvaT48L7o0JZQ1TW65+YF1GA1PuYeI3W2xpSggcB//wg=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
@@ -3376,6 +3380,8 @@
"@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"@openchamber/ui/@zumer/snapdom": ["@zumer/snapdom@2.12.0", "", {}, "sha512-TdLGu+1RkKI3JKfMFvn1gRPD/rl8hrAeN6aFjjd0w7S39nulbd94ChxSlfH7nSCm8kzuQkeWFxIsij4+Mk1RDg=="],
"@openchamber/ui/ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+1
View File
@@ -102,6 +102,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"@zumer/snapdom": "^2.12.8",
"bun-pty": "^0.4.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+1
View File
@@ -68,6 +68,7 @@
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-drawio": "1.0.7",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
@@ -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
@@ -128,6 +128,9 @@ export function languageByExtension(filePath: string): Extension | null {
return css();
case 'html':
case 'htm':
case 'xml':
case 'drawio':
case 'dio':
return html();
case 'md':
case 'mdx':
+4
View File
@@ -23,6 +23,7 @@ export const dict = {
'layout.mainTab.chat': 'Chat',
'layout.mainTab.plan': 'Plan',
'layout.mainTab.diff': 'Diff',
'layout.mainTab.diagram': 'Diagram',
'layout.mainTab.files': 'Files',
'layout.mainTab.terminal': 'Terminal',
'layout.mainTab.context': 'Context',
@@ -1120,6 +1121,8 @@ export const dict = {
'filesView.error.switchToEditMode': 'Switch to edit mode to fix the issue.',
'filesView.error.readFileFailed': 'Failed to read file',
'filesView.editor.htmlPreviewTitle': 'HTML Preview',
'filesView.diagram.closeDiagramView': 'Close diagram view',
'filesView.diagram.saveDiagram': 'Save diagram',
'contextUsage.aria.label': 'Context usage',
'contextUsage.mobile.title': 'Context Usage',
'contextUsage.mobile.usedTokens': 'Used tokens',
@@ -1565,6 +1568,7 @@ export const dict = {
'chat.fileAttachment.activeEditor.addFile': 'Add file:{name} to context',
'chat.fileAttachment.activeEditor.pinSelection': 'Pin selection to context',
'chat.fileAttachment.activeEditor.remove': 'Remove from context',
'chat.fileAttachment.openInDiagram': 'Open in diagram view',
'chat.pendingChanges.fileCountSingle': '{count} file',
'chat.pendingChanges.fileCountPlural': '{count} files',
'chat.pendingChanges.changedInWorkspace': 'changed in workspace',
+4
View File
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
"layout.mainTab.chat": "Chat",
"layout.mainTab.plan": "Plan",
"layout.mainTab.diff": "Diff",
"layout.mainTab.diagram": "Diagrama",
"layout.mainTab.files": "Archivos",
"layout.mainTab.terminal": "Terminal",
"layout.mainTab.context": "Contexto",
@@ -1086,6 +1087,8 @@ export const dict: Record<I18nKey, string> = {
"filesView.error.switchToEditMode": "Cambia al modo de edición para resolver el problema.",
"filesView.error.readFileFailed": "No se pudo leer el archivo",
"filesView.editor.htmlPreviewTitle": "Vista previa HTML",
"filesView.diagram.closeDiagramView": "Cerrar vista de diagrama",
"filesView.diagram.saveDiagram": "Guardar diagrama",
"contextUsage.aria.label": "Uso del contexto",
"contextUsage.mobile.title": "Uso del contexto",
"contextUsage.mobile.usedTokens": "Tokens usados",
@@ -1531,6 +1534,7 @@ export const dict: Record<I18nKey, string> = {
"chat.fileAttachment.activeEditor.addFile": "Agregar archivo:{name} al contexto",
"chat.fileAttachment.activeEditor.pinSelection": "Anclar selección al contexto",
"chat.fileAttachment.activeEditor.remove": "Quitar del contexto",
"chat.fileAttachment.openInDiagram": "Abrir en vista de diagrama",
"chat.pendingChanges.fileCountSingle": "{count} archivo",
"chat.pendingChanges.fileCountPlural": "{count} archivos",
"chat.pendingChanges.changedInWorkspace": "modificado en el espacio de trabajo",
+4
View File
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.chat': '채팅',
'layout.mainTab.plan': '계획',
'layout.mainTab.diff': '변경사항',
'layout.mainTab.diagram': '다이어그램',
'layout.mainTab.files': '파일',
'layout.mainTab.terminal': '터미널',
'layout.mainTab.context': '컨텍스트',
@@ -1123,6 +1124,8 @@ export const dict: Record<I18nKey, string> = {
'filesView.error.switchToEditMode': '문제를 수정하려면 편집 모드로 전환하세요.',
'filesView.error.readFileFailed': '파일 읽기 실패',
'filesView.editor.htmlPreviewTitle': 'HTML 미리보기',
'filesView.diagram.closeDiagramView': '다이어그램 보기 닫기',
'filesView.diagram.saveDiagram': '다이어그램 저장',
'contextUsage.aria.label': '컨텍스트 사용량',
'contextUsage.mobile.title': '컨텍스트 사용량',
'contextUsage.mobile.usedTokens': '사용한 토큰',
@@ -1567,6 +1570,7 @@ export const dict: Record<I18nKey, string> = {
'chat.fileAttachment.activeEditor.addFile': '컨텍스트에 파일 추가:{name}',
'chat.fileAttachment.activeEditor.pinSelection': '컨텍스트에 선택 고정',
'chat.fileAttachment.activeEditor.remove': '컨텍스트에서 제거',
'chat.fileAttachment.openInDiagram': '다이어그램 보기에서 열기',
'chat.pendingChanges.fileCountSingle': '{count} 파일',
'chat.pendingChanges.fileCountPlural': '{count} 파일',
'chat.pendingChanges.changedInWorkspace': '워크스페이스에서 변경됨',
+4
View File
@@ -25,6 +25,7 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.chat': 'Czat',
'layout.mainTab.plan': 'Plan',
'layout.mainTab.diff': 'Różnice',
'layout.mainTab.diagram': 'Diagram',
'layout.mainTab.files': 'Pliki',
'layout.mainTab.terminal': 'Terminal',
'layout.mainTab.context': 'Kontekst',
@@ -541,6 +542,7 @@ export const dict: Record<I18nKey, string> = {
'chat.fileAttachment.activeEditor.addFile': 'Dodaj plik:{name} do kontekstu',
'chat.fileAttachment.activeEditor.pinSelection': 'Przypnij zaznaczenie do kontekstu',
'chat.fileAttachment.activeEditor.remove': 'Usuń z kontekstu',
'chat.fileAttachment.openInDiagram': 'Otwórz w widoku diagramu',
'chat.pendingChanges.fileCountSingle': '{count} plik',
'chat.pendingChanges.fileCountPlural': '{count} plików',
'chat.pendingChanges.changedInWorkspace': 'zmienione w przestrzeni roboczej',
@@ -1499,6 +1501,8 @@ export const dict: Record<I18nKey, string> = {
'filesView.editor.fullscreen': 'Pełny ekran',
'filesView.editor.goToLine': 'Przejdź do linii',
'filesView.editor.htmlPreviewTitle': 'Podgląd HTML',
'filesView.diagram.closeDiagramView': 'Zamknij widok diagramu',
'filesView.diagram.saveDiagram': 'Zapisz diagram',
'filesView.editor.imageAltFallback': 'Obraz',
'filesView.editor.openFilesAria': 'Otwarte pliki',
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
"layout.mainTab.chat": "Chat",
"layout.mainTab.plan": "Plano",
"layout.mainTab.diff": "Diff",
"layout.mainTab.diagram": "Diagrama",
"layout.mainTab.files": "Arquivos",
"layout.mainTab.terminal": "Terminal",
"layout.mainTab.context": "Contexto",
@@ -1086,6 +1087,8 @@ export const dict: Record<I18nKey, string> = {
"filesView.error.switchToEditMode": "Alterne para o modo de edição para resolver o problema.",
"filesView.error.readFileFailed": "Não foi possível ler o arquivo",
"filesView.editor.htmlPreviewTitle": "Pré-visualização HTML",
"filesView.diagram.closeDiagramView": "Fechar visualização de diagrama",
"filesView.diagram.saveDiagram": "Salvar diagrama",
"contextUsage.aria.label": "Uso do contexto",
"contextUsage.mobile.title": "Uso do contexto",
"contextUsage.mobile.usedTokens": "Tokens usados",
@@ -1531,6 +1534,7 @@ export const dict: Record<I18nKey, string> = {
"chat.fileAttachment.activeEditor.addFile": "Adicionar arquivo:{name} ao contexto",
"chat.fileAttachment.activeEditor.pinSelection": "Fixar seleção no contexto",
"chat.fileAttachment.activeEditor.remove": "Remover do contexto",
"chat.fileAttachment.openInDiagram": "Abrir na visualização de diagrama",
"chat.pendingChanges.fileCountSingle": "{count} arquivo",
"chat.pendingChanges.fileCountPlural": "{count} arquivos",
"chat.pendingChanges.changedInWorkspace": "modificado no workspace",
+4
View File
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
"layout.mainTab.chat": "Чат",
"layout.mainTab.plan": "План",
"layout.mainTab.diff": "Diff",
"layout.mainTab.diagram": "Діаграма",
"layout.mainTab.files": "Файли",
"layout.mainTab.terminal": "Термінал",
"layout.mainTab.context": "Контекст",
@@ -1086,6 +1087,8 @@ export const dict: Record<I18nKey, string> = {
"filesView.error.switchToEditMode": "Перейдіть у режим редагування, щоб усунути проблему.",
"filesView.error.readFileFailed": "Не вдалося прочитати файл",
"filesView.editor.htmlPreviewTitle": "Попередній перегляд HTML",
"filesView.diagram.closeDiagramView": "Закрити перегляд діаграми",
"filesView.diagram.saveDiagram": "Зберегти діаграму",
"contextUsage.aria.label": "Використання контексту",
"contextUsage.mobile.title": "Використання контексту",
"contextUsage.mobile.usedTokens": "Використані токени",
@@ -1531,6 +1534,7 @@ export const dict: Record<I18nKey, string> = {
"chat.fileAttachment.activeEditor.addFile": "Додати файл:{name} до контексту",
"chat.fileAttachment.activeEditor.pinSelection": "Закріпити вибір у контексті",
"chat.fileAttachment.activeEditor.remove": "Видалити з контексту",
"chat.fileAttachment.openInDiagram": "Відкрити в перегляді діаграми",
"chat.pendingChanges.fileCountSingle": "Файл: {count}",
"chat.pendingChanges.fileCountPlural": "Файлів: {count}",
"chat.pendingChanges.changedInWorkspace": "змінено в гілці",
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.chat': '聊天',
'layout.mainTab.plan': '计划',
'layout.mainTab.diff': '差异',
'layout.mainTab.diagram': '图表',
'layout.mainTab.files': '文件',
'layout.mainTab.terminal': '终端',
'layout.mainTab.context': '上下文',
@@ -1086,6 +1087,8 @@ export const dict: Record<I18nKey, string> = {
'filesView.error.switchToEditMode': '请切换到编辑模式修复问题。',
'filesView.error.readFileFailed': '读取文件失败',
'filesView.editor.htmlPreviewTitle': 'HTML 预览',
'filesView.diagram.closeDiagramView': '关闭图表视图',
'filesView.diagram.saveDiagram': '保存图表',
'contextUsage.aria.label': '上下文用量',
'contextUsage.mobile.title': '上下文用量',
'contextUsage.mobile.usedTokens': '已用 Token',
@@ -1531,6 +1534,7 @@ export const dict: Record<I18nKey, string> = {
'chat.fileAttachment.activeEditor.addFile': '将文件添加到上下文:{name}',
'chat.fileAttachment.activeEditor.pinSelection': '将选择固定到上下文',
'chat.fileAttachment.activeEditor.remove': '从上下文中移除',
'chat.fileAttachment.openInDiagram': '在图表视图中打开',
'chat.pendingChanges.fileCountSingle': '{count} 个文件',
'chat.pendingChanges.fileCountPlural': '{count} 个文件',
'chat.pendingChanges.changedInWorkspace': '工作区中有变更',
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
'layout.mainTab.chat': '聊天',
'layout.mainTab.plan': '計畫',
'layout.mainTab.diff': '差異',
'layout.mainTab.diagram': '圖表',
'layout.mainTab.files': '檔案',
'layout.mainTab.terminal': '終端機',
'layout.mainTab.context': '上下文',
@@ -1096,6 +1097,8 @@ export const dict: Record<I18nKey, string> = {
'filesView.error.switchToEditMode': '請切換到編輯模式修復問題。',
'filesView.error.readFileFailed': '讀取檔案失敗',
'filesView.editor.htmlPreviewTitle': 'HTML 預覽',
'filesView.diagram.closeDiagramView': '關閉圖表檢視',
'filesView.diagram.saveDiagram': '儲存圖表',
'contextUsage.aria.label': '上下文用量',
'contextUsage.mobile.title': '上下文用量',
'contextUsage.mobile.usedTokens': '已用 Token',
@@ -1535,6 +1538,7 @@ export const dict: Record<I18nKey, string> = {
'chat.fileAttachment.activeEditor.addFile': '將檔案加入上下文:{name}',
'chat.fileAttachment.activeEditor.pinSelection': '將選擇釘選到上下文',
'chat.fileAttachment.activeEditor.remove': '從上下文中移除',
'chat.fileAttachment.openInDiagram': '在圖表檢視中開啟',
'chat.pendingChanges.fileCountSingle': '{count} 個檔案',
'chat.pendingChanges.fileCountPlural': '{count} 個檔案',
'chat.pendingChanges.changedInWorkspace': '工作區中有變更',
+1 -1
View File
@@ -29,7 +29,7 @@ export interface RouterContext {
/**
* Valid main tab values for URL routing.
*/
export const VALID_TABS: readonly MainTab[] = ['chat', 'git', 'diff', 'terminal', 'files'] as const;
export const VALID_TABS: readonly MainTab[] = ['chat', 'git', 'diff', 'terminal', 'files', 'diagram'] as const;
/**
* Valid settings section values for URL routing.
+7
View File
@@ -679,6 +679,13 @@ export function getLanguageFromExtension(filePath: string): string | null {
return languageMap[ext || ''] || null;
}
const DIAGRAM_EXTENSIONS = ['drawio', 'dio'];
export function isDrawioFile(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase();
return DIAGRAM_EXTENSIONS.includes(ext || '');
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
export function isImageFile(filePath: string): boolean {
+26 -1
View File
@@ -9,7 +9,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getRuntimeKey } from '@/lib/runtime-switch';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type RightSidebarTab = 'git' | 'files' | 'context';
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
export type MermaidRenderingMode = 'svg' | 'ascii';
@@ -519,6 +519,7 @@ interface UIStore {
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
pendingDiffStaged: boolean;
pendingDiagramFile: string | null;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
@@ -658,10 +659,13 @@ interface UIStore {
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
setPendingDiagramFile: (filePath: string | null) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
navigateToDiff: (filePath: string, staged?: boolean) => void;
consumePendingDiffFile: () => string | null;
navigateToDiagram: (filePath: string) => void;
consumePendingDiagramFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
toggleCommandPalette: () => void;
setCommandPaletteOpen: (open: boolean) => void;
@@ -799,6 +803,7 @@ export const useUIStore = create<UIStore>()(
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
pendingDiffStaged: false,
pendingDiagramFile: null,
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
@@ -1383,6 +1388,10 @@ export const useUIStore = create<UIStore>()(
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
},
setPendingDiagramFile: (filePath) => {
set({ pendingDiagramFile: filePath });
},
setPendingFileNavigation: (navigation) => {
set({ pendingFileNavigation: navigation });
},
@@ -1407,6 +1416,22 @@ export const useUIStore = create<UIStore>()(
return pendingDiffFile;
},
navigateToDiagram: (filePath) => {
const guard = get().mainTabGuard;
if (guard && !guard('diagram')) {
return;
}
set({ pendingDiagramFile: filePath, activeMainTab: 'diagram' });
},
consumePendingDiagramFile: () => {
const { pendingDiagramFile } = get();
if (pendingDiagramFile) {
set({ pendingDiagramFile: null });
}
return pendingDiagramFile;
},
setIsMobile: (isMobile) => {
set({ isMobile });
},
+14 -1
View File
@@ -167,8 +167,21 @@ function resolveEventDirectory(event: unknown, payload: Event): string {
? (payload.properties as Record<string, unknown>)
: null
const propertyDirectory = typeof properties?.directory === "string" ? properties.directory : null
if (propertyDirectory && propertyDirectory.length > 0) {
return propertyDirectory
}
return propertyDirectory && propertyDirectory.length > 0 ? propertyDirectory : "global"
// session.created / session.updated carry directory inside properties.info
const info =
typeof properties?.info === "object" && properties.info !== null
? (properties.info as Record<string, unknown>)
: null
const infoDirectory = typeof info?.directory === "string" ? info.directory : null
if (infoDirectory && infoDirectory.length > 0) {
return infoDirectory
}
return "global"
}
function resolveEventPayload(payload: unknown): Event | null {
File diff suppressed because it is too large Load Diff
@@ -56,7 +56,9 @@ export function parseSseEventEnvelope(block) {
? parsed.directory
: typeof parsed?.properties?.directory === 'string' && parsed.properties.directory.length > 0
? parsed.properties.directory
: null;
: typeof parsed?.properties?.info?.directory === 'string' && parsed.properties.info.directory.length > 0
? parsed.properties.info.directory
: null;
return {
eventId,
+25 -2
View File
@@ -668,7 +668,18 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const content = await fsPromises.readFile(canonicalPath, 'utf8');
let content = await fsPromises.readFile(canonicalPath, 'utf8');
// Retry empty reads — concurrent writer may have truncated the file
// between our stat and read (O_TRUNC window). If the file existed with
// content at stat time but we read nothing, the writer hasn't finished
// writing yet.
if (content.length === 0 && stats.size > 0) {
for (let attempt = 0; attempt < 3; attempt++) {
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
content = await fsPromises.readFile(canonicalPath, 'utf8');
if (content.length > 0) break;
}
}
return res.type('text/plain').send(content);
} catch (error) {
const err = error;
@@ -785,7 +796,19 @@ export const registerFsRoutes = (app, dependencies) => {
}
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
await fsPromises.writeFile(resolved.resolved, content, 'utf8');
// Atomic write: write to temp then rename to avoid concurrent readers
// seeing an empty file during the O_TRUNC window of direct writeFile.
const tmp = `${resolved.resolved}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let tmpExists = false;
try {
await fsPromises.writeFile(tmp, content, 'utf8');
tmpExists = true;
await fsPromises.rename(tmp, resolved.resolved);
} catch (error) {
if (tmpExists) await fsPromises.unlink(tmp).catch(() => {});
throw error;
}
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
+14 -54
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -8,39 +8,15 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const useDetachedChildren = process.platform === 'darwin' || process.platform === 'linux';
const useDetachedChildren = process.platform === 'darwin';
const webRoot = path.join(repoRoot, 'packages/web');
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
function resolveWindowsCommand(command) {
if (process.platform !== 'win32' || path.isAbsolute(command)) {
return command;
}
const result = spawnSync('where.exe', [command], { encoding: 'utf8', windowsHide: true });
if (result.error || result.status !== 0) {
return command;
}
const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0] || command;
}
function run(label, command, args, env = {}, options = {}) {
const resolvedCommand = resolveWindowsCommand(command);
const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand);
const spawnCommand = isWindowsCommandScript ? (process.env.ComSpec || 'cmd.exe') : resolvedCommand;
const spawnArgs = isWindowsCommandScript
? ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(resolvedCommand), ...args.map(quoteWindowsCommandArg)].join(' ')]
: args;
return spawn(spawnCommand, spawnArgs, {
return spawn(command, args, {
cwd: options.cwd || repoRoot,
stdio: 'inherit',
env: { ...process.env, ...env },
detached: useDetachedChildren,
windowsVerbatimArguments: isWindowsCommandScript,
}).on('error', (error) => {
console.error(`[dev:web:hmr] Failed to start ${label}:`, error);
});
@@ -67,17 +43,6 @@ function waitForExit(child, timeoutMs) {
});
}
function killWindowsProcessTree(pid) {
if (!pid) return;
try {
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {
}
}
function signalChild(child, signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
@@ -105,11 +70,6 @@ async function stopChildTree(child) {
signalChild(child, 'SIGINT');
await waitForExit(child, 2500);
if (process.platform === 'win32' && child.exitCode === null && child.signalCode === null) {
killWindowsProcessTree(child.pid);
await waitForExit(child, 1000);
}
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGTERM');
await waitForExit(child, 2500);
@@ -152,15 +112,9 @@ function clearViteCache() {
clearViteCache();
const api = run(
'api',
'bun',
['x', 'nodemon', '--watch', 'server', '--ext', 'js', '--exec', `bun server/index.js --port ${backendPort}`],
{
OPENCHAMBER_PORT: backendPort,
},
{ cwd: webRoot },
);
const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], {
OPENCHAMBER_PORT: backendPort,
});
const vite = run(
'vite',
'bun',
@@ -172,10 +126,9 @@ const vite = run(
{ cwd: webRoot },
);
const lanAddresses = hmrHost === '0.0.0.0' || hmrHost === '::' ? getLanAddresses() : [];
console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);
if (hmrHost === '0.0.0.0' || hmrHost === '::') {
const lanAddresses = getLanAddresses();
if (lanAddresses.length > 0) {
for (const address of lanAddresses) {
console.log(`[dev:web:hmr] LAN/mobile UI: http://${address}:${uiPort}`);
@@ -193,6 +146,13 @@ async function shutdown(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
await Promise.all([stopChildTree(api), stopChildTree(vite)]);
// Clean up orphaned OpenCode processes that weren't killed by
// the Express server's shutdown (e.g. when nodemon is killed first).
try {
spawnSync('pkill', ['-f', 'opencode serve'], { stdio: 'ignore' });
} catch {
// best-effort
}
process.exit(exitCode);
}