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
@@ -45,6 +45,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"@zumer/snapdom": "^2.12.8",
|
||||||
"bun-pty": "^0.4.5",
|
"bun-pty": "^0.4.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.12.1",
|
"version": "1.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/web": "workspace:*",
|
"@openchamber/web": "workspace:*",
|
||||||
"electron-context-menu": "^4.1.2",
|
"electron-context-menu": "^4.1.2",
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.12.1",
|
"version": "1.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
@@ -172,6 +173,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-drawio": "1.0.7",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
@@ -213,7 +215,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.12.1",
|
"version": "1.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.16.0",
|
"@opencode-ai/sdk": "^1.16.0",
|
||||||
@@ -236,7 +238,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.12.1",
|
"version": "1.12.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -1408,7 +1410,7 @@
|
|||||||
|
|
||||||
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
|
"@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=="],
|
"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-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-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=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"@zumer/snapdom": "^2.12.8",
|
||||||
"bun-pty": "^0.4.5",
|
"bun-pty": "^0.4.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-drawio": "1.0.7",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { openExternalUrl } from '@/lib/url';
|
import { openExternalUrl } from '@/lib/url';
|
||||||
|
import { isDrawioFile } from '@/lib/toolHelpers';
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
@@ -551,6 +552,7 @@ interface FilePart {
|
|||||||
url?: string;
|
url?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
source?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
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) => {
|
export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }: MessageFilesDisplayProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url));
|
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 (
|
return (
|
||||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||||
<TooltipTrigger asChild>
|
<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 { getToolIcon } from './toolPresentation';
|
||||||
import { useDurationTickerNow } from './useDurationTicker';
|
import { useDurationTickerNow } from './useDurationTicker';
|
||||||
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
||||||
|
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { getDiffPatchEntries, getPatchText } from './toolDiffUtils';
|
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 taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i);
|
||||||
const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i);
|
const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i);
|
||||||
const candidate = taskMatch?.[1] ?? sessionMatch?.[1];
|
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[] => {
|
const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
|
||||||
|
|||||||
@@ -60,25 +60,32 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
|
|||||||
hasRetried = false,
|
hasRetried = false,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
if (!isTaskTool || isTaskFinalized || !parentSessionId || typeof taskStartTime !== 'number') {
|
if (!isTaskTool || !parentSessionId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
|
// Filter candidate sessions: parentID matches the current session.
|
||||||
const latestAllowed = taskStartTime + windowMs;
|
let candidates = sessions.filter((session) => {
|
||||||
|
|
||||||
// Filter candidate sessions: parentID matches and created shortly after task start.
|
|
||||||
const candidates = sessions.filter((session) => {
|
|
||||||
if (!session?.id || session.parentID !== parentSessionId) {
|
if (!session?.id || session.parentID !== parentSessionId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const created = session.time?.created;
|
return true;
|
||||||
if (typeof created !== 'number') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return created >= taskStartTime && created <= latestAllowed;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) {
|
if (candidates.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -98,6 +105,18 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
|
|||||||
return liveCandidates[0].id;
|
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
|
// Ambiguous — do not guess
|
||||||
return undefined;
|
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: 'files', label: t('layout.mainTab.files'), icon: "folder-6" },
|
||||||
{ id: 'terminal', label: t('layout.mainTab.terminal'), icon: "terminal-box" },
|
{ id: 'terminal', label: t('layout.mainTab.terminal'), icon: "terminal-box" },
|
||||||
{ id: 'context', label: t('layout.mainTab.context'), icon: "file-list-2" },
|
{ id: 'context', label: t('layout.mainTab.context'), icon: "file-list-2" },
|
||||||
|
{ id: 'diagram', label: t('layout.mainTab.diagram'), icon: 'file' },
|
||||||
);
|
);
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { PlanView } from '@/components/views/PlanView';
|
|||||||
|
|
||||||
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
||||||
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
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 SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||||
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||||
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
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>;
|
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
|
||||||
case 'context':
|
case 'context':
|
||||||
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
|
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
|
||||||
|
case 'diagram':
|
||||||
|
return <React.Suspense fallback={null}><DiagramView /></React.Suspense>;
|
||||||
default:
|
default:
|
||||||
return null;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,10 +35,11 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
||||||
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile } from '@/lib/toolHelpers';
|
||||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||||
|
import { DiagramEditor } from '@/components/diagram';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
@@ -695,9 +696,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [mdViewMode, setMdViewMode] = React.useState<PreviewViewMode>('edit');
|
const [mdViewMode, setMdViewMode] = React.useState<PreviewViewMode>('edit');
|
||||||
const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree');
|
const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree');
|
||||||
const [htmlViewMode, setHtmlViewMode] = React.useState<PreviewViewMode>('edit');
|
const [htmlViewMode, setHtmlViewMode] = React.useState<PreviewViewMode>('edit');
|
||||||
|
const [drawioViewMode, setDrawioViewMode] = React.useState<PreviewViewMode>('preview');
|
||||||
|
const [drawioRemountNonce, setDrawioRemountNonce] = React.useState(0);
|
||||||
const textViewModeByPathRef = React.useRef<Record<string, TextViewMode>>({});
|
const textViewModeByPathRef = React.useRef<Record<string, TextViewMode>>({});
|
||||||
const mdViewModeByPathRef = React.useRef<Record<string, PreviewViewMode>>({});
|
const mdViewModeByPathRef = React.useRef<Record<string, PreviewViewMode>>({});
|
||||||
const htmlViewModeByPathRef = React.useRef<Record<string, PreviewViewMode>>({});
|
const htmlViewModeByPathRef = React.useRef<Record<string, PreviewViewMode>>({});
|
||||||
|
const drawioViewModeByPathRef = React.useRef<Record<string, PreviewViewMode>>({});
|
||||||
|
|
||||||
const lightTheme = React.useMemo(
|
const lightTheme = React.useMemo(
|
||||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
||||||
@@ -797,9 +801,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState<FileLineEnding>('\n');
|
const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState<FileLineEnding>('\n');
|
||||||
const dialogInputRef = React.useRef<HTMLInputElement>(null);
|
const dialogInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const diagramAutoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const diagramXmlRef = React.useRef('');
|
||||||
|
const diagramSavedXmlRef = React.useRef('');
|
||||||
|
const pendingDrawioPreviewFrameRef = React.useRef<number | null>(null);
|
||||||
|
const diagramEditorRef = React.useRef<React.ComponentRef<typeof DiagramEditor>>(null);
|
||||||
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||||
const activeFileLoadIdRef = React.useRef(0);
|
const activeFileLoadIdRef = React.useRef(0);
|
||||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||||
|
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
||||||
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
|
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
|
||||||
|
|
||||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||||
@@ -1484,6 +1494,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) {
|
||||||
|
console.warn(
|
||||||
|
`[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` +
|
||||||
|
'The file may have been read during a concurrent write (O_TRUNC race). ' +
|
||||||
|
'Try again after content finishes loading if the save was intentional.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1494,6 +1513,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setFileContent(draftContent);
|
setFileContent(draftContent);
|
||||||
|
if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
|
||||||
|
diagramXmlRef.current = draftContent;
|
||||||
|
diagramSavedXmlRef.current = draftContent;
|
||||||
|
}
|
||||||
// Refresh stat after write so polling doesn't see a stale metadata change.
|
// Refresh stat after write so polling doesn't see a stale metadata change.
|
||||||
void readFileStat(selectedFile.path)
|
void readFileStat(selectedFile.path)
|
||||||
.then((stat) => {
|
.then((stat) => {
|
||||||
@@ -1509,7 +1532,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [draftContent, files, isDirty, loadedFileLineEnding, readFileStat, selectedFile, t]);
|
}, [draftContent, fileContent, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isDirty) {
|
if (!isDirty) {
|
||||||
@@ -1667,6 +1690,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const editorContent = normalizeEditorLineEndings(content);
|
const editorContent = normalizeEditorLineEndings(content);
|
||||||
setLoadedFileLineEnding(detectFileLineEnding(content));
|
setLoadedFileLineEnding(detectFileLineEnding(content));
|
||||||
setFileContent(editorContent);
|
setFileContent(editorContent);
|
||||||
|
diagramXmlRef.current = editorContent;
|
||||||
|
diagramSavedXmlRef.current = editorContent;
|
||||||
setDraftContent(editorContent.length > MAX_VIEW_CHARS
|
setDraftContent(editorContent.length > MAX_VIEW_CHARS
|
||||||
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
||||||
: editorContent);
|
: editorContent);
|
||||||
@@ -1785,6 +1810,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setFileError(null);
|
setFileError(null);
|
||||||
setDesktopImageSrc('');
|
setDesktopImageSrc('');
|
||||||
setFileContent('');
|
setFileContent('');
|
||||||
|
diagramXmlRef.current = '';
|
||||||
|
diagramSavedXmlRef.current = '';
|
||||||
setDraftContent('');
|
setDraftContent('');
|
||||||
setLoadedFilePath(null);
|
setLoadedFilePath(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -2143,8 +2170,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
||||||
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
||||||
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
||||||
|
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path));
|
||||||
const isTextFile = Boolean(selectedFile && !isSelectedImage);
|
const isTextFile = Boolean(selectedFile && !isSelectedImage);
|
||||||
const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview');
|
const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
|
||||||
const staticLanguageExtension = React.useMemo(
|
const staticLanguageExtension = React.useMemo(
|
||||||
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
|
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
|
||||||
[selectedFilePath],
|
[selectedFilePath],
|
||||||
@@ -2214,6 +2242,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
// Ignore localStorage errors
|
// Ignore localStorage errors
|
||||||
}
|
}
|
||||||
setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault);
|
setHtmlViewMode(htmlViewModeByPathRef.current[selectedPath] ?? htmlDefault);
|
||||||
|
setDrawioViewMode(drawioViewModeByPathRef.current[selectedPath] ?? 'preview');
|
||||||
|
|
||||||
let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text';
|
let jsonDefault: 'tree' | 'text' = settingsDefaultFileViewerPreview ? 'tree' : 'text';
|
||||||
try {
|
try {
|
||||||
@@ -2274,6 +2303,104 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}
|
}
|
||||||
}, [selectedFile?.path]);
|
}, [selectedFile?.path]);
|
||||||
|
|
||||||
|
const saveDrawioViewMode = React.useCallback((mode: PreviewViewMode) => {
|
||||||
|
const selectedPath = selectedFile?.path;
|
||||||
|
if (selectedPath) {
|
||||||
|
drawioViewModeByPathRef.current[selectedPath] = mode;
|
||||||
|
}
|
||||||
|
if (diagramAutoSaveTimerRef.current) {
|
||||||
|
clearTimeout(diagramAutoSaveTimerRef.current);
|
||||||
|
diagramAutoSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingDrawioPreviewFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(pendingDrawioPreviewFrameRef.current);
|
||||||
|
pendingDrawioPreviewFrameRef.current = null;
|
||||||
|
}
|
||||||
|
if (mode === 'edit') {
|
||||||
|
setDraftContent(diagramXmlRef.current || fileContent);
|
||||||
|
setDrawioViewMode(mode);
|
||||||
|
} else {
|
||||||
|
diagramXmlRef.current = draftContent;
|
||||||
|
const pathAtToggle = selectedPath;
|
||||||
|
setDrawioViewMode('edit');
|
||||||
|
pendingDrawioPreviewFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
pendingDrawioPreviewFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
pendingDrawioPreviewFrameRef.current = null;
|
||||||
|
if (root && pathAtToggle && useFilesViewTabsStore.getState().byRoot[root]?.selectedPath !== pathAtToggle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDrawioRemountNonce((value) => value + 1);
|
||||||
|
setDrawioViewMode('preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [draftContent, fileContent, root, selectedFile?.path]);
|
||||||
|
|
||||||
|
const saveDiagramXml = React.useCallback(async (path: string, xml: string) => {
|
||||||
|
if (!files.writeFile || xml === diagramSavedXmlRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await files.writeFile(path, xml);
|
||||||
|
if (!result?.success) {
|
||||||
|
toast.error(t('filesView.toast.writeFileFailed'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
diagramXmlRef.current = xml;
|
||||||
|
diagramSavedXmlRef.current = xml;
|
||||||
|
setDraftContent(xml);
|
||||||
|
const stat = await readFileStat(path, selectedFileReadOptions).catch(() => null);
|
||||||
|
if (stat) {
|
||||||
|
lastLoadedFileStatRef.current = stat;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [files, readFileStat, selectedFileReadOptions, t]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (diagramAutoSaveTimerRef.current) {
|
||||||
|
clearTimeout(diagramAutoSaveTimerRef.current);
|
||||||
|
diagramAutoSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingDrawioPreviewFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(pendingDrawioPreviewFrameRef.current);
|
||||||
|
pendingDrawioPreviewFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [drawioViewMode, selectedFile?.path]);
|
||||||
|
|
||||||
|
const handleDiagramChange = React.useCallback((xml: string) => {
|
||||||
|
diagramXmlRef.current = xml;
|
||||||
|
if (!selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagramAutoSaveTimerRef.current) {
|
||||||
|
clearTimeout(diagramAutoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = selectedFile.path;
|
||||||
|
diagramAutoSaveTimerRef.current = setTimeout(() => {
|
||||||
|
diagramAutoSaveTimerRef.current = null;
|
||||||
|
void saveDiagramXml(path, xml).then((saved) => {
|
||||||
|
if (!saved) return;
|
||||||
|
setDiagramSaved(true);
|
||||||
|
setTimeout(() => setDiagramSaved(false), 1500);
|
||||||
|
}).catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed'));
|
||||||
|
});
|
||||||
|
}, AUTO_SAVE_DELAY);
|
||||||
|
}, [drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]);
|
||||||
|
|
||||||
|
const diagramEditorXml = React.useMemo(() => {
|
||||||
|
if (!isDrawio) {
|
||||||
|
return fileContent;
|
||||||
|
}
|
||||||
|
return diagramXmlRef.current || draftContent || fileContent;
|
||||||
|
}, [draftContent, fileContent, isDrawio]);
|
||||||
|
|
||||||
const getHtmlViewMode = React.useCallback((): PreviewViewMode => {
|
const getHtmlViewMode = React.useCallback((): PreviewViewMode => {
|
||||||
return htmlViewMode;
|
return htmlViewMode;
|
||||||
}, [htmlViewMode]);
|
}, [htmlViewMode]);
|
||||||
@@ -2949,6 +3076,41 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isDrawio && (
|
||||||
|
<>
|
||||||
|
<PreviewToggleButton
|
||||||
|
currentMode={drawioViewMode}
|
||||||
|
onToggle={() => saveDrawioViewMode(drawioViewMode === 'preview' ? 'edit' : 'preview')}
|
||||||
|
/>
|
||||||
|
{drawioViewMode === 'preview' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const xml = diagramEditorRef.current?.getXml();
|
||||||
|
if (diagramAutoSaveTimerRef.current) {
|
||||||
|
clearTimeout(diagramAutoSaveTimerRef.current);
|
||||||
|
diagramAutoSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (selectedFile?.path && xml) {
|
||||||
|
const saved = await saveDiagramXml(selectedFile.path, xml);
|
||||||
|
if (!saved) return;
|
||||||
|
setDiagramSaved(true);
|
||||||
|
setTimeout(() => setDiagramSaved(false), 1500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="size-6 p-0 text-foreground hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
|
||||||
|
title={t('filesView.diagram.saveDiagram')}
|
||||||
|
>
|
||||||
|
{diagramSaved ? (
|
||||||
|
<Icon name="check" className="size-4 text-[color:var(--status-success)]" />
|
||||||
|
) : (
|
||||||
|
<Icon name="save-3" className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isJson && (
|
{isJson && (
|
||||||
withTooltip(jsonViewMode === 'tree' ? t('filesView.editor.switchToTextView') : t('filesView.editor.switchToTreeView'),
|
withTooltip(jsonViewMode === 'tree' ? t('filesView.editor.switchToTextView') : t('filesView.editor.switchToTreeView'),
|
||||||
@@ -3317,6 +3479,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
|
||||||
|
<div className="h-full overflow-hidden" style={{ minHeight: '400px' }}>
|
||||||
|
<DiagramEditor
|
||||||
|
key={`${selectedFile.path}:${drawioRemountNonce}`}
|
||||||
|
ref={diagramEditorRef}
|
||||||
|
xml={diagramEditorXml}
|
||||||
|
onChange={handleDiagramChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : selectedFile && isJson && jsonViewMode === 'tree' ? (
|
) : selectedFile && isJson && jsonViewMode === 'tree' ? (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export function languageByExtension(filePath: string): Extension | null {
|
|||||||
return css();
|
return css();
|
||||||
case 'html':
|
case 'html':
|
||||||
case 'htm':
|
case 'htm':
|
||||||
|
case 'xml':
|
||||||
|
case 'drawio':
|
||||||
|
case 'dio':
|
||||||
return html();
|
return html();
|
||||||
case 'md':
|
case 'md':
|
||||||
case 'mdx':
|
case 'mdx':
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const dict = {
|
|||||||
'layout.mainTab.chat': 'Chat',
|
'layout.mainTab.chat': 'Chat',
|
||||||
'layout.mainTab.plan': 'Plan',
|
'layout.mainTab.plan': 'Plan',
|
||||||
'layout.mainTab.diff': 'Diff',
|
'layout.mainTab.diff': 'Diff',
|
||||||
|
'layout.mainTab.diagram': 'Diagram',
|
||||||
'layout.mainTab.files': 'Files',
|
'layout.mainTab.files': 'Files',
|
||||||
'layout.mainTab.terminal': 'Terminal',
|
'layout.mainTab.terminal': 'Terminal',
|
||||||
'layout.mainTab.context': 'Context',
|
'layout.mainTab.context': 'Context',
|
||||||
@@ -1120,6 +1121,8 @@ export const dict = {
|
|||||||
'filesView.error.switchToEditMode': 'Switch to edit mode to fix the issue.',
|
'filesView.error.switchToEditMode': 'Switch to edit mode to fix the issue.',
|
||||||
'filesView.error.readFileFailed': 'Failed to read file',
|
'filesView.error.readFileFailed': 'Failed to read file',
|
||||||
'filesView.editor.htmlPreviewTitle': 'HTML Preview',
|
'filesView.editor.htmlPreviewTitle': 'HTML Preview',
|
||||||
|
'filesView.diagram.closeDiagramView': 'Close diagram view',
|
||||||
|
'filesView.diagram.saveDiagram': 'Save diagram',
|
||||||
'contextUsage.aria.label': 'Context usage',
|
'contextUsage.aria.label': 'Context usage',
|
||||||
'contextUsage.mobile.title': 'Context Usage',
|
'contextUsage.mobile.title': 'Context Usage',
|
||||||
'contextUsage.mobile.usedTokens': 'Used tokens',
|
'contextUsage.mobile.usedTokens': 'Used tokens',
|
||||||
@@ -1565,6 +1568,7 @@ export const dict = {
|
|||||||
'chat.fileAttachment.activeEditor.addFile': 'Add file:{name} to context',
|
'chat.fileAttachment.activeEditor.addFile': 'Add file:{name} to context',
|
||||||
'chat.fileAttachment.activeEditor.pinSelection': 'Pin selection to context',
|
'chat.fileAttachment.activeEditor.pinSelection': 'Pin selection to context',
|
||||||
'chat.fileAttachment.activeEditor.remove': 'Remove from context',
|
'chat.fileAttachment.activeEditor.remove': 'Remove from context',
|
||||||
|
'chat.fileAttachment.openInDiagram': 'Open in diagram view',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} file',
|
'chat.pendingChanges.fileCountSingle': '{count} file',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} files',
|
'chat.pendingChanges.fileCountPlural': '{count} files',
|
||||||
'chat.pendingChanges.changedInWorkspace': 'changed in workspace',
|
'chat.pendingChanges.changedInWorkspace': 'changed in workspace',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"layout.mainTab.chat": "Chat",
|
"layout.mainTab.chat": "Chat",
|
||||||
"layout.mainTab.plan": "Plan",
|
"layout.mainTab.plan": "Plan",
|
||||||
"layout.mainTab.diff": "Diff",
|
"layout.mainTab.diff": "Diff",
|
||||||
|
"layout.mainTab.diagram": "Diagrama",
|
||||||
"layout.mainTab.files": "Archivos",
|
"layout.mainTab.files": "Archivos",
|
||||||
"layout.mainTab.terminal": "Terminal",
|
"layout.mainTab.terminal": "Terminal",
|
||||||
"layout.mainTab.context": "Contexto",
|
"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.switchToEditMode": "Cambia al modo de edición para resolver el problema.",
|
||||||
"filesView.error.readFileFailed": "No se pudo leer el archivo",
|
"filesView.error.readFileFailed": "No se pudo leer el archivo",
|
||||||
"filesView.editor.htmlPreviewTitle": "Vista previa HTML",
|
"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.aria.label": "Uso del contexto",
|
||||||
"contextUsage.mobile.title": "Uso del contexto",
|
"contextUsage.mobile.title": "Uso del contexto",
|
||||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
"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.addFile": "Agregar archivo:{name} al contexto",
|
||||||
"chat.fileAttachment.activeEditor.pinSelection": "Anclar selección al contexto",
|
"chat.fileAttachment.activeEditor.pinSelection": "Anclar selección al contexto",
|
||||||
"chat.fileAttachment.activeEditor.remove": "Quitar del contexto",
|
"chat.fileAttachment.activeEditor.remove": "Quitar del contexto",
|
||||||
|
"chat.fileAttachment.openInDiagram": "Abrir en vista de diagrama",
|
||||||
"chat.pendingChanges.fileCountSingle": "{count} archivo",
|
"chat.pendingChanges.fileCountSingle": "{count} archivo",
|
||||||
"chat.pendingChanges.fileCountPlural": "{count} archivos",
|
"chat.pendingChanges.fileCountPlural": "{count} archivos",
|
||||||
"chat.pendingChanges.changedInWorkspace": "modificado en el espacio de trabajo",
|
"chat.pendingChanges.changedInWorkspace": "modificado en el espacio de trabajo",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'layout.mainTab.chat': '채팅',
|
'layout.mainTab.chat': '채팅',
|
||||||
'layout.mainTab.plan': '계획',
|
'layout.mainTab.plan': '계획',
|
||||||
'layout.mainTab.diff': '변경사항',
|
'layout.mainTab.diff': '변경사항',
|
||||||
|
'layout.mainTab.diagram': '다이어그램',
|
||||||
'layout.mainTab.files': '파일',
|
'layout.mainTab.files': '파일',
|
||||||
'layout.mainTab.terminal': '터미널',
|
'layout.mainTab.terminal': '터미널',
|
||||||
'layout.mainTab.context': '컨텍스트',
|
'layout.mainTab.context': '컨텍스트',
|
||||||
@@ -1123,6 +1124,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.error.switchToEditMode': '문제를 수정하려면 편집 모드로 전환하세요.',
|
'filesView.error.switchToEditMode': '문제를 수정하려면 편집 모드로 전환하세요.',
|
||||||
'filesView.error.readFileFailed': '파일 읽기 실패',
|
'filesView.error.readFileFailed': '파일 읽기 실패',
|
||||||
'filesView.editor.htmlPreviewTitle': 'HTML 미리보기',
|
'filesView.editor.htmlPreviewTitle': 'HTML 미리보기',
|
||||||
|
'filesView.diagram.closeDiagramView': '다이어그램 보기 닫기',
|
||||||
|
'filesView.diagram.saveDiagram': '다이어그램 저장',
|
||||||
'contextUsage.aria.label': '컨텍스트 사용량',
|
'contextUsage.aria.label': '컨텍스트 사용량',
|
||||||
'contextUsage.mobile.title': '컨텍스트 사용량',
|
'contextUsage.mobile.title': '컨텍스트 사용량',
|
||||||
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
||||||
@@ -1567,6 +1570,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.fileAttachment.activeEditor.addFile': '컨텍스트에 파일 추가:{name}',
|
'chat.fileAttachment.activeEditor.addFile': '컨텍스트에 파일 추가:{name}',
|
||||||
'chat.fileAttachment.activeEditor.pinSelection': '컨텍스트에 선택 고정',
|
'chat.fileAttachment.activeEditor.pinSelection': '컨텍스트에 선택 고정',
|
||||||
'chat.fileAttachment.activeEditor.remove': '컨텍스트에서 제거',
|
'chat.fileAttachment.activeEditor.remove': '컨텍스트에서 제거',
|
||||||
|
'chat.fileAttachment.openInDiagram': '다이어그램 보기에서 열기',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} 파일',
|
'chat.pendingChanges.fileCountSingle': '{count} 파일',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} 파일',
|
'chat.pendingChanges.fileCountPlural': '{count} 파일',
|
||||||
'chat.pendingChanges.changedInWorkspace': '워크스페이스에서 변경됨',
|
'chat.pendingChanges.changedInWorkspace': '워크스페이스에서 변경됨',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'layout.mainTab.chat': 'Czat',
|
'layout.mainTab.chat': 'Czat',
|
||||||
'layout.mainTab.plan': 'Plan',
|
'layout.mainTab.plan': 'Plan',
|
||||||
'layout.mainTab.diff': 'Różnice',
|
'layout.mainTab.diff': 'Różnice',
|
||||||
|
'layout.mainTab.diagram': 'Diagram',
|
||||||
'layout.mainTab.files': 'Pliki',
|
'layout.mainTab.files': 'Pliki',
|
||||||
'layout.mainTab.terminal': 'Terminal',
|
'layout.mainTab.terminal': 'Terminal',
|
||||||
'layout.mainTab.context': 'Kontekst',
|
'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.addFile': 'Dodaj plik:{name} do kontekstu',
|
||||||
'chat.fileAttachment.activeEditor.pinSelection': 'Przypnij zaznaczenie do kontekstu',
|
'chat.fileAttachment.activeEditor.pinSelection': 'Przypnij zaznaczenie do kontekstu',
|
||||||
'chat.fileAttachment.activeEditor.remove': 'Usuń z kontekstu',
|
'chat.fileAttachment.activeEditor.remove': 'Usuń z kontekstu',
|
||||||
|
'chat.fileAttachment.openInDiagram': 'Otwórz w widoku diagramu',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} plik',
|
'chat.pendingChanges.fileCountSingle': '{count} plik',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} plików',
|
'chat.pendingChanges.fileCountPlural': '{count} plików',
|
||||||
'chat.pendingChanges.changedInWorkspace': 'zmienione w przestrzeni roboczej',
|
'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.fullscreen': 'Pełny ekran',
|
||||||
'filesView.editor.goToLine': 'Przejdź do linii',
|
'filesView.editor.goToLine': 'Przejdź do linii',
|
||||||
'filesView.editor.htmlPreviewTitle': 'Podgląd HTML',
|
'filesView.editor.htmlPreviewTitle': 'Podgląd HTML',
|
||||||
|
'filesView.diagram.closeDiagramView': 'Zamknij widok diagramu',
|
||||||
|
'filesView.diagram.saveDiagram': 'Zapisz diagram',
|
||||||
'filesView.editor.imageAltFallback': 'Obraz',
|
'filesView.editor.imageAltFallback': 'Obraz',
|
||||||
'filesView.editor.openFilesAria': 'Otwarte pliki',
|
'filesView.editor.openFilesAria': 'Otwarte pliki',
|
||||||
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
|
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"layout.mainTab.chat": "Chat",
|
"layout.mainTab.chat": "Chat",
|
||||||
"layout.mainTab.plan": "Plano",
|
"layout.mainTab.plan": "Plano",
|
||||||
"layout.mainTab.diff": "Diff",
|
"layout.mainTab.diff": "Diff",
|
||||||
|
"layout.mainTab.diagram": "Diagrama",
|
||||||
"layout.mainTab.files": "Arquivos",
|
"layout.mainTab.files": "Arquivos",
|
||||||
"layout.mainTab.terminal": "Terminal",
|
"layout.mainTab.terminal": "Terminal",
|
||||||
"layout.mainTab.context": "Contexto",
|
"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.switchToEditMode": "Alterne para o modo de edição para resolver o problema.",
|
||||||
"filesView.error.readFileFailed": "Não foi possível ler o arquivo",
|
"filesView.error.readFileFailed": "Não foi possível ler o arquivo",
|
||||||
"filesView.editor.htmlPreviewTitle": "Pré-visualização HTML",
|
"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.aria.label": "Uso do contexto",
|
||||||
"contextUsage.mobile.title": "Uso do contexto",
|
"contextUsage.mobile.title": "Uso do contexto",
|
||||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
"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.addFile": "Adicionar arquivo:{name} ao contexto",
|
||||||
"chat.fileAttachment.activeEditor.pinSelection": "Fixar seleção no contexto",
|
"chat.fileAttachment.activeEditor.pinSelection": "Fixar seleção no contexto",
|
||||||
"chat.fileAttachment.activeEditor.remove": "Remover do contexto",
|
"chat.fileAttachment.activeEditor.remove": "Remover do contexto",
|
||||||
|
"chat.fileAttachment.openInDiagram": "Abrir na visualização de diagrama",
|
||||||
"chat.pendingChanges.fileCountSingle": "{count} arquivo",
|
"chat.pendingChanges.fileCountSingle": "{count} arquivo",
|
||||||
"chat.pendingChanges.fileCountPlural": "{count} arquivos",
|
"chat.pendingChanges.fileCountPlural": "{count} arquivos",
|
||||||
"chat.pendingChanges.changedInWorkspace": "modificado no workspace",
|
"chat.pendingChanges.changedInWorkspace": "modificado no workspace",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"layout.mainTab.chat": "Чат",
|
"layout.mainTab.chat": "Чат",
|
||||||
"layout.mainTab.plan": "План",
|
"layout.mainTab.plan": "План",
|
||||||
"layout.mainTab.diff": "Diff",
|
"layout.mainTab.diff": "Diff",
|
||||||
|
"layout.mainTab.diagram": "Діаграма",
|
||||||
"layout.mainTab.files": "Файли",
|
"layout.mainTab.files": "Файли",
|
||||||
"layout.mainTab.terminal": "Термінал",
|
"layout.mainTab.terminal": "Термінал",
|
||||||
"layout.mainTab.context": "Контекст",
|
"layout.mainTab.context": "Контекст",
|
||||||
@@ -1086,6 +1087,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.error.switchToEditMode": "Перейдіть у режим редагування, щоб усунути проблему.",
|
"filesView.error.switchToEditMode": "Перейдіть у режим редагування, щоб усунути проблему.",
|
||||||
"filesView.error.readFileFailed": "Не вдалося прочитати файл",
|
"filesView.error.readFileFailed": "Не вдалося прочитати файл",
|
||||||
"filesView.editor.htmlPreviewTitle": "Попередній перегляд HTML",
|
"filesView.editor.htmlPreviewTitle": "Попередній перегляд HTML",
|
||||||
|
"filesView.diagram.closeDiagramView": "Закрити перегляд діаграми",
|
||||||
|
"filesView.diagram.saveDiagram": "Зберегти діаграму",
|
||||||
"contextUsage.aria.label": "Використання контексту",
|
"contextUsage.aria.label": "Використання контексту",
|
||||||
"contextUsage.mobile.title": "Використання контексту",
|
"contextUsage.mobile.title": "Використання контексту",
|
||||||
"contextUsage.mobile.usedTokens": "Використані токени",
|
"contextUsage.mobile.usedTokens": "Використані токени",
|
||||||
@@ -1531,6 +1534,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.fileAttachment.activeEditor.addFile": "Додати файл:{name} до контексту",
|
"chat.fileAttachment.activeEditor.addFile": "Додати файл:{name} до контексту",
|
||||||
"chat.fileAttachment.activeEditor.pinSelection": "Закріпити вибір у контексті",
|
"chat.fileAttachment.activeEditor.pinSelection": "Закріпити вибір у контексті",
|
||||||
"chat.fileAttachment.activeEditor.remove": "Видалити з контексту",
|
"chat.fileAttachment.activeEditor.remove": "Видалити з контексту",
|
||||||
|
"chat.fileAttachment.openInDiagram": "Відкрити в перегляді діаграми",
|
||||||
"chat.pendingChanges.fileCountSingle": "Файл: {count}",
|
"chat.pendingChanges.fileCountSingle": "Файл: {count}",
|
||||||
"chat.pendingChanges.fileCountPlural": "Файлів: {count}",
|
"chat.pendingChanges.fileCountPlural": "Файлів: {count}",
|
||||||
"chat.pendingChanges.changedInWorkspace": "змінено в гілці",
|
"chat.pendingChanges.changedInWorkspace": "змінено в гілці",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'layout.mainTab.chat': '聊天',
|
'layout.mainTab.chat': '聊天',
|
||||||
'layout.mainTab.plan': '计划',
|
'layout.mainTab.plan': '计划',
|
||||||
'layout.mainTab.diff': '差异',
|
'layout.mainTab.diff': '差异',
|
||||||
|
'layout.mainTab.diagram': '图表',
|
||||||
'layout.mainTab.files': '文件',
|
'layout.mainTab.files': '文件',
|
||||||
'layout.mainTab.terminal': '终端',
|
'layout.mainTab.terminal': '终端',
|
||||||
'layout.mainTab.context': '上下文',
|
'layout.mainTab.context': '上下文',
|
||||||
@@ -1086,6 +1087,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.error.switchToEditMode': '请切换到编辑模式修复问题。',
|
'filesView.error.switchToEditMode': '请切换到编辑模式修复问题。',
|
||||||
'filesView.error.readFileFailed': '读取文件失败',
|
'filesView.error.readFileFailed': '读取文件失败',
|
||||||
'filesView.editor.htmlPreviewTitle': 'HTML 预览',
|
'filesView.editor.htmlPreviewTitle': 'HTML 预览',
|
||||||
|
'filesView.diagram.closeDiagramView': '关闭图表视图',
|
||||||
|
'filesView.diagram.saveDiagram': '保存图表',
|
||||||
'contextUsage.aria.label': '上下文用量',
|
'contextUsage.aria.label': '上下文用量',
|
||||||
'contextUsage.mobile.title': '上下文用量',
|
'contextUsage.mobile.title': '上下文用量',
|
||||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||||
@@ -1531,6 +1534,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.fileAttachment.activeEditor.addFile': '将文件添加到上下文:{name}',
|
'chat.fileAttachment.activeEditor.addFile': '将文件添加到上下文:{name}',
|
||||||
'chat.fileAttachment.activeEditor.pinSelection': '将选择固定到上下文',
|
'chat.fileAttachment.activeEditor.pinSelection': '将选择固定到上下文',
|
||||||
'chat.fileAttachment.activeEditor.remove': '从上下文中移除',
|
'chat.fileAttachment.activeEditor.remove': '从上下文中移除',
|
||||||
|
'chat.fileAttachment.openInDiagram': '在图表视图中打开',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} 个文件',
|
'chat.pendingChanges.fileCountSingle': '{count} 个文件',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} 个文件',
|
'chat.pendingChanges.fileCountPlural': '{count} 个文件',
|
||||||
'chat.pendingChanges.changedInWorkspace': '工作区中有变更',
|
'chat.pendingChanges.changedInWorkspace': '工作区中有变更',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'layout.mainTab.chat': '聊天',
|
'layout.mainTab.chat': '聊天',
|
||||||
'layout.mainTab.plan': '計畫',
|
'layout.mainTab.plan': '計畫',
|
||||||
'layout.mainTab.diff': '差異',
|
'layout.mainTab.diff': '差異',
|
||||||
|
'layout.mainTab.diagram': '圖表',
|
||||||
'layout.mainTab.files': '檔案',
|
'layout.mainTab.files': '檔案',
|
||||||
'layout.mainTab.terminal': '終端機',
|
'layout.mainTab.terminal': '終端機',
|
||||||
'layout.mainTab.context': '上下文',
|
'layout.mainTab.context': '上下文',
|
||||||
@@ -1096,6 +1097,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.error.switchToEditMode': '請切換到編輯模式修復問題。',
|
'filesView.error.switchToEditMode': '請切換到編輯模式修復問題。',
|
||||||
'filesView.error.readFileFailed': '讀取檔案失敗',
|
'filesView.error.readFileFailed': '讀取檔案失敗',
|
||||||
'filesView.editor.htmlPreviewTitle': 'HTML 預覽',
|
'filesView.editor.htmlPreviewTitle': 'HTML 預覽',
|
||||||
|
'filesView.diagram.closeDiagramView': '關閉圖表檢視',
|
||||||
|
'filesView.diagram.saveDiagram': '儲存圖表',
|
||||||
'contextUsage.aria.label': '上下文用量',
|
'contextUsage.aria.label': '上下文用量',
|
||||||
'contextUsage.mobile.title': '上下文用量',
|
'contextUsage.mobile.title': '上下文用量',
|
||||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||||
@@ -1535,6 +1538,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.fileAttachment.activeEditor.addFile': '將檔案加入上下文:{name}',
|
'chat.fileAttachment.activeEditor.addFile': '將檔案加入上下文:{name}',
|
||||||
'chat.fileAttachment.activeEditor.pinSelection': '將選擇釘選到上下文',
|
'chat.fileAttachment.activeEditor.pinSelection': '將選擇釘選到上下文',
|
||||||
'chat.fileAttachment.activeEditor.remove': '從上下文中移除',
|
'chat.fileAttachment.activeEditor.remove': '從上下文中移除',
|
||||||
|
'chat.fileAttachment.openInDiagram': '在圖表檢視中開啟',
|
||||||
'chat.pendingChanges.fileCountSingle': '{count} 個檔案',
|
'chat.pendingChanges.fileCountSingle': '{count} 個檔案',
|
||||||
'chat.pendingChanges.fileCountPlural': '{count} 個檔案',
|
'chat.pendingChanges.fileCountPlural': '{count} 個檔案',
|
||||||
'chat.pendingChanges.changedInWorkspace': '工作區中有變更',
|
'chat.pendingChanges.changedInWorkspace': '工作區中有變更',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface RouterContext {
|
|||||||
/**
|
/**
|
||||||
* Valid main tab values for URL routing.
|
* 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.
|
* Valid settings section values for URL routing.
|
||||||
|
|||||||
@@ -679,6 +679,13 @@ export function getLanguageFromExtension(filePath: string): string | null {
|
|||||||
return languageMap[ext || ''] || 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'];
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||||
|
|
||||||
export function isImageFile(filePath: string): boolean {
|
export function isImageFile(filePath: string): boolean {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
|
|||||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
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 RightSidebarTab = 'git' | 'files' | 'context';
|
||||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
|
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
|
||||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||||
@@ -519,6 +519,7 @@ interface UIStore {
|
|||||||
sidebarOpenBeforeFullscreenTab: boolean | null;
|
sidebarOpenBeforeFullscreenTab: boolean | null;
|
||||||
pendingDiffFile: string | null;
|
pendingDiffFile: string | null;
|
||||||
pendingDiffStaged: boolean;
|
pendingDiffStaged: boolean;
|
||||||
|
pendingDiagramFile: string | null;
|
||||||
pendingFileNavigation: PendingFileNavigation | null;
|
pendingFileNavigation: PendingFileNavigation | null;
|
||||||
pendingFileFocusPath: string | null;
|
pendingFileFocusPath: string | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
@@ -658,10 +659,13 @@ interface UIStore {
|
|||||||
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
|
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
|
||||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||||
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
|
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
|
||||||
|
setPendingDiagramFile: (filePath: string | null) => void;
|
||||||
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
||||||
setPendingFileFocusPath: (path: string | null) => void;
|
setPendingFileFocusPath: (path: string | null) => void;
|
||||||
navigateToDiff: (filePath: string, staged?: boolean) => void;
|
navigateToDiff: (filePath: string, staged?: boolean) => void;
|
||||||
consumePendingDiffFile: () => string | null;
|
consumePendingDiffFile: () => string | null;
|
||||||
|
navigateToDiagram: (filePath: string) => void;
|
||||||
|
consumePendingDiagramFile: () => string | null;
|
||||||
setIsMobile: (isMobile: boolean) => void;
|
setIsMobile: (isMobile: boolean) => void;
|
||||||
toggleCommandPalette: () => void;
|
toggleCommandPalette: () => void;
|
||||||
setCommandPaletteOpen: (open: boolean) => void;
|
setCommandPaletteOpen: (open: boolean) => void;
|
||||||
@@ -799,6 +803,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
sidebarOpenBeforeFullscreenTab: null,
|
sidebarOpenBeforeFullscreenTab: null,
|
||||||
pendingDiffFile: null,
|
pendingDiffFile: null,
|
||||||
pendingDiffStaged: false,
|
pendingDiffStaged: false,
|
||||||
|
pendingDiagramFile: null,
|
||||||
pendingFileNavigation: null,
|
pendingFileNavigation: null,
|
||||||
pendingFileFocusPath: null,
|
pendingFileFocusPath: null,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
@@ -1383,6 +1388,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
|
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPendingDiagramFile: (filePath) => {
|
||||||
|
set({ pendingDiagramFile: filePath });
|
||||||
|
},
|
||||||
|
|
||||||
setPendingFileNavigation: (navigation) => {
|
setPendingFileNavigation: (navigation) => {
|
||||||
set({ pendingFileNavigation: navigation });
|
set({ pendingFileNavigation: navigation });
|
||||||
},
|
},
|
||||||
@@ -1407,6 +1416,22 @@ export const useUIStore = create<UIStore>()(
|
|||||||
return pendingDiffFile;
|
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) => {
|
setIsMobile: (isMobile) => {
|
||||||
set({ isMobile });
|
set({ isMobile });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -167,8 +167,21 @@ function resolveEventDirectory(event: unknown, payload: Event): string {
|
|||||||
? (payload.properties as Record<string, unknown>)
|
? (payload.properties as Record<string, unknown>)
|
||||||
: null
|
: null
|
||||||
const propertyDirectory = typeof properties?.directory === "string" ? properties.directory : 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 {
|
function resolveEventPayload(payload: unknown): Event | null {
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const ACTIVE_SESSION_WATCHDOG_INTERVAL_MS = 5_000
|
|||||||
const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000
|
const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000
|
||||||
const ACTIVE_SESSION_STALE_EVENT_MS = 20_000
|
const ACTIVE_SESSION_STALE_EVENT_MS = 20_000
|
||||||
const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000
|
const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000
|
||||||
|
const CHILD_SESSION_DISCOVERY_INTERVAL_MS = 15_000
|
||||||
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
||||||
if (!items || items.length === 0) return ""
|
if (!items || items.length === 0) return ""
|
||||||
return items
|
return items
|
||||||
@@ -1559,6 +1560,7 @@ export function SyncProvider(props: {
|
|||||||
const lastActiveEventAtByDirectoryRef = useRef(new Map<string, number>())
|
const lastActiveEventAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
const lastStatusPollAtByDirectoryRef = useRef(new Map<string, number>())
|
const lastStatusPollAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
const lastFullResyncAtByDirectoryRef = useRef(new Map<string, number>())
|
const lastFullResyncAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
|
const lastChildDiscoveryAtByDirectoryRef = useRef(new Map<string, number>())
|
||||||
const resyncingDirectoriesRef = useRef(new Set<string>())
|
const resyncingDirectoriesRef = useRef(new Set<string>())
|
||||||
const statusPollingDirectoriesRef = useRef(new Set<string>())
|
const statusPollingDirectoriesRef = useRef(new Set<string>())
|
||||||
const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null)
|
const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null)
|
||||||
@@ -1619,7 +1621,7 @@ export function SyncProvider(props: {
|
|||||||
providers: globalState.providers,
|
providers: globalState.providers,
|
||||||
},
|
},
|
||||||
loadSessions: (dir) => retry(async () => {
|
loadSessions: (dir) => retry(async () => {
|
||||||
const sessions = (await listGlobalSessionPages(props.sdk, {
|
const rootSessions = (await listGlobalSessionPages(props.sdk, {
|
||||||
directory: dir,
|
directory: dir,
|
||||||
archived: false,
|
archived: false,
|
||||||
roots: true,
|
roots: true,
|
||||||
@@ -1627,6 +1629,30 @@ export function SyncProvider(props: {
|
|||||||
}))
|
}))
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
|
|
||||||
|
// Also load child sessions (sub-agent delegations) so they
|
||||||
|
// appear in the sidebar immediately instead of relying on
|
||||||
|
// the async global session store.
|
||||||
|
let allSessions: typeof rootSessions = []
|
||||||
|
try {
|
||||||
|
const allResult = await props.sdk.session.list({
|
||||||
|
directory: dir,
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
const allError = (allResult as { error?: unknown }).error
|
||||||
|
if (!allError) {
|
||||||
|
allSessions = ((allResult as { data?: unknown }).data ?? []) as typeof rootSessions
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Child load is best-effort; fall back to roots only
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: keep root sessions from the first query (for accurate
|
||||||
|
// sessionTotal), plus any child sessions from the broader query.
|
||||||
|
const rootIds = new Set(rootSessions.map((s: { id: string }) => s.id))
|
||||||
|
const childSessions = allSessions.filter((s: { id: string; parentID?: string | null }) => s?.id && !rootIds.has(s.id) && s.parentID)
|
||||||
|
|
||||||
|
const sessions = rootSessions.concat(childSessions)
|
||||||
// Race guard: if the list came back empty but event pipeline
|
// Race guard: if the list came back empty but event pipeline
|
||||||
// already populated the store, don't clobber. OpenCode can
|
// already populated the store, don't clobber. OpenCode can
|
||||||
// answer HTTP with empty sessions while WS delivers session
|
// answer HTTP with empty sessions while WS delivers session
|
||||||
@@ -1638,7 +1664,7 @@ export function SyncProvider(props: {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) })
|
store.setState({ session: sessions, sessionTotal: rootSessions.length, limit: Math.max(sessions.length, 50) })
|
||||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -1767,6 +1793,53 @@ export function SyncProvider(props: {
|
|||||||
let stopped = false
|
let stopped = false
|
||||||
let running = false
|
let running = false
|
||||||
|
|
||||||
|
const discoverChildSessions = async (
|
||||||
|
directory: string,
|
||||||
|
store: StoreApi<DirectoryStore>,
|
||||||
|
parentSessionIds: string[],
|
||||||
|
) => {
|
||||||
|
if (parentSessionIds.length === 0) return
|
||||||
|
try {
|
||||||
|
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||||
|
const result = await scopedClient.session.list({ directory, limit: 200 })
|
||||||
|
const allSessions = ((result as { data?: unknown }).data ?? []) as Session[]
|
||||||
|
const state = store.getState()
|
||||||
|
const existingIds = new Set(state.session.map((s) => s.id))
|
||||||
|
const parentIdSet = new Set(parentSessionIds)
|
||||||
|
const newChildSessions: Session[] = []
|
||||||
|
for (const session of allSessions) {
|
||||||
|
if (
|
||||||
|
session?.id
|
||||||
|
&& !existingIds.has(session.id)
|
||||||
|
&& (session as { parentID?: string | null }).parentID
|
||||||
|
&& parentIdSet.has((session as { parentID: string }).parentID)
|
||||||
|
) {
|
||||||
|
newChildSessions.push(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newChildSessions.length === 0) return
|
||||||
|
// Collect unique parent IDs for materialization
|
||||||
|
const parentIdsForMaterialization = new Set<string>()
|
||||||
|
for (const session of newChildSessions) {
|
||||||
|
const pid = (session as { parentID?: string | null }).parentID
|
||||||
|
if (pid) parentIdsForMaterialization.add(pid)
|
||||||
|
}
|
||||||
|
store.setState((state: DirectoryStore) => {
|
||||||
|
const sessions = [...state.session, ...newChildSessions].sort((a, b) =>
|
||||||
|
a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
||||||
|
)
|
||||||
|
return { session: sessions, limit: Math.max(sessions.length, 50) }
|
||||||
|
})
|
||||||
|
// Trigger parent session materialization so the task tool part
|
||||||
|
// state (metadata, sessionId, output) is refreshed.
|
||||||
|
for (const pid of parentIdsForMaterialization) {
|
||||||
|
enqueueSessionMaterialization(directory, pid, childStores)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort — next tick will retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pollDirectoryStatuses = async (
|
const pollDirectoryStatuses = async (
|
||||||
directory: string,
|
directory: string,
|
||||||
store: StoreApi<DirectoryStore>,
|
store: StoreApi<DirectoryStore>,
|
||||||
@@ -1826,6 +1899,14 @@ export function SyncProvider(props: {
|
|||||||
pipelineReconnectRef.current?.("active_stream_stale")
|
pipelineReconnectRef.current?.("active_stream_stale")
|
||||||
triggerDirectoryResync(directory)
|
triggerDirectoryResync(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discover child sessions created by other OpenCode instances
|
||||||
|
// that didn't broadcast a session.created event on this stream.
|
||||||
|
const lastChildDiscoveryAt = lastChildDiscoveryAtByDirectoryRef.current.get(directory) ?? 0
|
||||||
|
if (now - lastChildDiscoveryAt >= CHILD_SESSION_DISCOVERY_INTERVAL_MS) {
|
||||||
|
lastChildDiscoveryAtByDirectoryRef.current.set(directory, now)
|
||||||
|
void discoverChildSessions(directory, store, candidateSessionIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export function parseSseEventEnvelope(block) {
|
|||||||
? parsed.directory
|
? parsed.directory
|
||||||
: typeof parsed?.properties?.directory === 'string' && parsed.properties.directory.length > 0
|
: typeof parsed?.properties?.directory === 'string' && parsed.properties.directory.length > 0
|
||||||
? parsed.properties.directory
|
? parsed.properties.directory
|
||||||
: null;
|
: typeof parsed?.properties?.info?.directory === 'string' && parsed.properties.info.directory.length > 0
|
||||||
|
? parsed.properties.info.directory
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eventId,
|
eventId,
|
||||||
|
|||||||
@@ -668,7 +668,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
|||||||
return res.status(400).json({ error: 'Specified path is not a file' });
|
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);
|
return res.type('text/plain').send(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error;
|
const err = error;
|
||||||
@@ -785,7 +796,19 @@ export const registerFsRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
|
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 });
|
return res.json({ success: true, path: resolved.resolved });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error;
|
const err = error;
|
||||||
|
|||||||
+14
-54
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { existsSync, rmSync } from 'node:fs';
|
import { existsSync, rmSync } from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -8,39 +8,15 @@ import { fileURLToPath } from 'node:url';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
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 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 = {}) {
|
function run(label, command, args, env = {}, options = {}) {
|
||||||
const resolvedCommand = resolveWindowsCommand(command);
|
return spawn(command, args, {
|
||||||
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, {
|
|
||||||
cwd: options.cwd || repoRoot,
|
cwd: options.cwd || repoRoot,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, ...env },
|
env: { ...process.env, ...env },
|
||||||
detached: useDetachedChildren,
|
detached: useDetachedChildren,
|
||||||
windowsVerbatimArguments: isWindowsCommandScript,
|
|
||||||
}).on('error', (error) => {
|
}).on('error', (error) => {
|
||||||
console.error(`[dev:web:hmr] Failed to start ${label}:`, 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) {
|
function signalChild(child, signal) {
|
||||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||||
return;
|
return;
|
||||||
@@ -105,11 +70,6 @@ async function stopChildTree(child) {
|
|||||||
signalChild(child, 'SIGINT');
|
signalChild(child, 'SIGINT');
|
||||||
await waitForExit(child, 2500);
|
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) {
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
signalChild(child, 'SIGTERM');
|
signalChild(child, 'SIGTERM');
|
||||||
await waitForExit(child, 2500);
|
await waitForExit(child, 2500);
|
||||||
@@ -152,15 +112,9 @@ function clearViteCache() {
|
|||||||
|
|
||||||
clearViteCache();
|
clearViteCache();
|
||||||
|
|
||||||
const api = run(
|
const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], {
|
||||||
'api',
|
OPENCHAMBER_PORT: backendPort,
|
||||||
'bun',
|
});
|
||||||
['x', 'nodemon', '--watch', 'server', '--ext', 'js', '--exec', `bun server/index.js --port ${backendPort}`],
|
|
||||||
{
|
|
||||||
OPENCHAMBER_PORT: backendPort,
|
|
||||||
},
|
|
||||||
{ cwd: webRoot },
|
|
||||||
);
|
|
||||||
const vite = run(
|
const vite = run(
|
||||||
'vite',
|
'vite',
|
||||||
'bun',
|
'bun',
|
||||||
@@ -172,10 +126,9 @@ const vite = run(
|
|||||||
{ cwd: webRoot },
|
{ 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}`);
|
console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);
|
||||||
if (hmrHost === '0.0.0.0' || hmrHost === '::') {
|
if (hmrHost === '0.0.0.0' || hmrHost === '::') {
|
||||||
|
const lanAddresses = getLanAddresses();
|
||||||
if (lanAddresses.length > 0) {
|
if (lanAddresses.length > 0) {
|
||||||
for (const address of lanAddresses) {
|
for (const address of lanAddresses) {
|
||||||
console.log(`[dev:web:hmr] LAN/mobile UI: http://${address}:${uiPort}`);
|
console.log(`[dev:web:hmr] LAN/mobile UI: http://${address}:${uiPort}`);
|
||||||
@@ -193,6 +146,13 @@ async function shutdown(exitCode = 0) {
|
|||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
await Promise.all([stopChildTree(api), stopChildTree(vite)]);
|
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);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user