diff --git a/docs/memory-audit-2026-08-16/ram-optimization-findings.md b/docs/memory-audit-2026-08-16/ram-optimization-findings.md index eb7f1b0178..0381d11700 100644 --- a/docs/memory-audit-2026-08-16/ram-optimization-findings.md +++ b/docs/memory-audit-2026-08-16/ram-optimization-findings.md @@ -521,3 +521,46 @@ multi-repo surface), browser-tab webview discarding (see 2.5 note above), chat-panel CLI terminal tabs (agent-owned, turn lifetime), i18n namespace deferral and lazy zod schemas (2.4), the 4 MB `App` static graph itself. + +### 2026-08-17 — heavy-component boundaries (branch `perf/heavy-component-leaks`, stacked on the above) + +Method: `src/test/staticImportGraph.ts` (regex import walker) run per lazy +chunk root (every dynamic-`import()` target in `src/`) reporting which heavy +packages are statically reachable. Before → after: + +| Surface (chunk root) | Before | After | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| `engines/ChatPanel/events/stream/agent-message` (every chat message) | xterm, CodeMirror + langs, sql-formatter, react-syntax-highlighter, highlight.js, recharts, @a2ui, framer-motion, mammoth, jszip (1 747 files) | none | +| `modules/MainApp/TeamInbox` | xterm, CodeMirror, sql-formatter, framer-motion | none | +| `modules/MainApp/Settings/SettingsSlot` | CodeMirror, sql-formatter | none | +| `modules/MainApp/AgentOrgs` | CodeMirror, sql-formatter | none | +| `modules/ProjectManager/{Projects,WorkItems,LinearProjects}` | xterm, CodeMirror, sql-formatter, framer-motion | none | +| `engines/Simulator/index` | xterm, CodeMirror, sql-formatter, framer-motion | none | +| `modules/WorkStation/shared/index.ts` (barrel, ~80 importers) | xterm, CodeMirror, framer-motion | none | + +Root causes and fixes: + +- `modules/WorkStation/shared/index.ts` re-exported `GitFileDiffSplit` (dead + code → all of `features/CodeMirror`), the `SidebarModules` block (module + evaluation registers the Terminal tab sidebar → xterm) and + `QuickActionsPanel` (framer-motion). Re-exports removed with explanatory + comments; `CodeEditor/index.tsx` imports `SidebarSlot` from + `../shared/SidebarModules` (the import that already carried the + registrations); `GitFileDiffSplit` deleted. +- Eager imports of on-demand views made lazy (`React.lazy` + `Suspense +fallback={null}`, matching neighbouring precedents): `SimulatorMessages` in + `agent-message`; transcript content in `SessionRawTranscriptDialog`; + `A2UIRenderer` (recharts/@a2ui) and `ReactArtifactRunner` (sucrase + + embedded React runtime) in `CanvasPreviewSurface`; `SkillEditorPanel` in + `SkillsCategoryView`; `CodeMirrorEditor` inside `MarkdownEditor`; the canvas + "source" tab viewer in `CanvasApp`. +- Editor-only consumers deep-import `@src/features/CodeMirror/Editor` instead + of the barrel (which also carries Diff, ConflictEditor, SqlEditor + + sql-formatter). +- Guard: `src/app/root/__tests__/featureBoundaries.test.ts` — nine surfaces + asserted free of the editor/terminal/highlighter/chart stacks; failures + print the import chain. + +Still statically reaching CodeMirror by design: `modules/WorkStation/index.tsx` +(the code editor), `engines/Simulator/apps/canvas/CanvasApp` only via the lazy +source viewer, and the editor-internal panes. diff --git a/src/app/root/__tests__/featureBoundaries.test.ts b/src/app/root/__tests__/featureBoundaries.test.ts new file mode 100644 index 0000000000..228ee88476 --- /dev/null +++ b/src/app/root/__tests__/featureBoundaries.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; + +import { + importersOfPackage, + walkStaticImports, +} from "@src/test/staticImportGraph"; + +/** + * Heavy-feature boundary guards for lazily loaded surfaces. + * + * Each root below is its own async chunk (a route page, workstation app, + * or event renderer). Before this guard existed, several of them statically + * reached the editor/terminal/highlighter/chart stacks through barrel + * re-exports (`modules/WorkStation/shared`, `features/CodeMirror`) or a + * single eager import — e.g. the agent-message renderer (every chat) pulled + * xterm, CodeMirror, react-syntax-highlighter, highlight.js, recharts, + * mammoth and jszip. See docs/memory-audit-2026-08-16 (Fix log). + * + * If a surface genuinely needs one of these packages, load it behind a + * `React.lazy` / dynamic `import()` at the point of use, or move the root + * to the allow-list below with a reason. + */ +const EDITOR_STACK = [ + "@codemirror/view", + "@codemirror/state", + "@uiw/react-codemirror", + "sql-formatter", +]; +const TERMINAL_STACK = ["@xterm/xterm", "@xterm/addon-webgl"]; +const HIGHLIGHTERS = ["react-syntax-highlighter", "refractor", "highlight.js"]; +const MISC_HEAVY = [ + "framer-motion", + "recharts", + "mermaid", + "mammoth", + "jszip", + "sucrase", +]; + +interface Boundary { + root: string; + forbidden: string[]; + reason: string; +} + +const BOUNDARIES: Boundary[] = [ + { + root: "engines/ChatPanel/events/stream/agent-message/index.tsx", + forbidden: [ + ...EDITOR_STACK, + ...TERMINAL_STACK, + ...HIGHLIGHTERS, + ...MISC_HEAVY, + ], + reason: + "renders every agent message; simulator/canvas/code surfaces are lazy", + }, + { + root: "engines/ChatPanel/events/stream/user-message/index.tsx", + forbidden: [ + ...EDITOR_STACK, + ...TERMINAL_STACK, + ...HIGHLIGHTERS, + ...MISC_HEAVY, + ], + reason: "renders every user message", + }, + { + root: "modules/MainApp/TeamInbox/index.ts", + forbidden: [ + ...EDITOR_STACK, + ...TERMINAL_STACK, + ...HIGHLIGHTERS, + "framer-motion", + ], + reason: "inbox list/detail; no editor or terminal", + }, + { + root: "modules/MainApp/Settings/SettingsSlot.tsx", + forbidden: [...EDITOR_STACK, ...TERMINAL_STACK, ...HIGHLIGHTERS], + reason: "settings; the skill/policy editors load CodeMirror lazily", + }, + { + root: "modules/MainApp/AgentOrgs/index.tsx", + forbidden: [...EDITOR_STACK, ...TERMINAL_STACK, ...HIGHLIGHTERS], + reason: "agent orgs; MarkdownEditor loads CodeMirror lazily", + }, + { + root: "modules/ProjectManager/Projects/index.tsx", + forbidden: [ + ...EDITOR_STACK, + ...TERMINAL_STACK, + ...HIGHLIGHTERS, + "framer-motion", + ], + reason: "project manager pages", + }, + { + root: "modules/ProjectManager/WorkItems/index.tsx", + forbidden: [ + ...EDITOR_STACK, + ...TERMINAL_STACK, + ...HIGHLIGHTERS, + "framer-motion", + ], + reason: "project manager pages", + }, + { + root: "engines/Simulator/index.ts", + forbidden: [...EDITOR_STACK, ...TERMINAL_STACK, ...HIGHLIGHTERS], + reason: "simulator shell; individual apps lazy-load their editors", + }, + { + root: "modules/WorkStation/shared/index.ts", + forbidden: [ + ...EDITOR_STACK, + ...TERMINAL_STACK, + ...HIGHLIGHTERS, + "framer-motion", + ], + reason: + "shared barrel imported by ~80 files; heavy components must not be re-exported here", + }, +]; + +describe("heavy-feature boundaries", () => { + for (const boundary of BOUNDARIES) { + it(`${boundary.root} stays free of heavy stacks (${boundary.reason})`, () => { + const graph = walkStaticImports([boundary.root]); + const present = boundary.forbidden.filter((pkg) => + graph.packages.has(pkg) + ); + const explanation = present.map( + (pkg) => + `${pkg}:\n ${importersOfPackage(graph, pkg).slice(0, 3).join("\n ")}` + ); + expect( + explanation, + "heavy package reachable (import chains shown)" + ).toEqual([]); + }); + } +}); diff --git a/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface.tsx b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface.tsx index 0e49e5b22c..65a41ff44f 100644 --- a/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface.tsx +++ b/src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface.tsx @@ -1,6 +1,8 @@ import { Layout, SquareArrowOutUpRight } from "lucide-react"; import React, { + Suspense, forwardRef, + lazy, useCallback, useEffect, useImperativeHandle, @@ -13,10 +15,8 @@ import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; import type { A2UIActionHandler } from "./A2UIActionContext"; -import A2UIRenderer, { type A2UIRendererHandle } from "./A2UIRenderer"; -import ReactArtifactRunner, { - type ReactArtifactError, -} from "./ReactArtifactRunner"; +import type { A2UIRendererHandle } from "./A2UIRenderer"; +import type { ReactArtifactError } from "./ReactArtifactRunner"; import { type CanvasPreviewPayload, type CanvasPreviewSurfaceVariant, @@ -29,6 +29,13 @@ import { sanitizeStaticHtmlBody, } from "./staticHtmlCanvas"; +// Lazy render kinds. CanvasInlineCard is reached from the agent-message +// renderer (every chat), but A2UI (recharts + @a2ui) and React artifacts +// (sucrase + the embedded React 18 runtime text) are only needed when a +// canvas of that kind is actually shown. +const A2UIRenderer = lazy(() => import("./A2UIRenderer")); +const ReactArtifactRunner = lazy(() => import("./ReactArtifactRunner")); + export interface CanvasPreviewSurfaceHandle { evalScript: (javascript: string) => void; } @@ -158,24 +165,28 @@ const CanvasPreviewSurface = forwardRef< a2uiLines.length === 0 ? ( <>{payload?.streaming ? loadingFallback : emptyFallback} ) : ( - + + + ); } else if (renderKind === "html" && payloadContent) { content = ; } else if (renderKind === "react" && payloadContent) { content = ( - + + + ); } else { content = emptyFallback; diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx index 0e121d3d4f..c3ac40f848 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx @@ -1,7 +1,7 @@ import React, { memo } from "react"; import { useTranslation } from "react-i18next"; -import { CodeMirrorEditor } from "@src/features/CodeMirror"; +import { CodeMirrorEditor } from "@src/features/CodeMirror/Editor"; export interface SessionRawTranscriptContentProps { error: string | null; diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx index 48a9fd6ae4..0eb2b89b57 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx @@ -1,13 +1,19 @@ import { Clipboard, RefreshCw } from "lucide-react"; -import React, { memo } from "react"; +import React, { Suspense, lazy, memo } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; import Modal from "@src/scaffold/ModalSystem"; -import SessionRawTranscriptContent from "./SessionRawTranscriptContent"; import { useSessionRawTranscript } from "./useSessionRawTranscript"; +// Lazy (same as SessionRawTranscriptView): the transcript content pulls +// CodeMirror, and this dialog is imported by the WorkStation TabBar — which +// every workstation surface renders — but only opens on demand. +const SessionRawTranscriptContent = lazy( + () => import("./SessionRawTranscriptContent") +); + export interface SessionRawTranscriptDialogProps { sessionId: string | null; visible: boolean; @@ -55,15 +61,17 @@ const SessionRawTranscriptDialog: React.FC = } >
- + + +
); diff --git a/src/engines/ChatPanel/events/stream/agent-message/index.tsx b/src/engines/ChatPanel/events/stream/agent-message/index.tsx index 43d1ec99cb..94397183f6 100644 --- a/src/engines/ChatPanel/events/stream/agent-message/index.tsx +++ b/src/engines/ChatPanel/events/stream/agent-message/index.tsx @@ -11,7 +11,7 @@ * is now `agent_message` to better reflect the actual purpose. */ import { useAtomValue } from "jotai"; -import React, { useMemo } from "react"; +import React, { Suspense, lazy, useMemo } from "react"; import { useTranslation } from "react-i18next"; import Markdown from "@src/components/MarkDown"; @@ -53,9 +53,16 @@ import { extractThinkContent, stripThinkTags, } from "@src/engines/SessionCore/sync/adapters/shared/streamingParsers"; -import { SimulatorMessages } from "@src/modules/WorkStation/Chat/Communication"; import { parseGitArtifactsFromText } from "@src/shared/git/sessionGitArtifacts"; +// Lazy (same as user-message / thinking): SimulatorMessages is only used by +// the simulator variant, but a static import here made every chat message +// renderer pull the whole Communication app — SessionReplay CodePanel, +// CodeMirror, react-syntax-highlighter, highlight.js, file previewers. +const LazySimulatorMessages = lazy( + () => import("@src/modules/WorkStation/Chat/Communication") +); + // ============================================ // Types // ============================================ @@ -282,11 +289,13 @@ const SimulatorVariant: React.FC = ({ const eventSessionId = (event as { event?: { sessionId?: string } })?.event?.sessionId ?? null; return ( - + + + ); }; diff --git a/src/engines/Simulator/apps/canvas/CanvasApp.tsx b/src/engines/Simulator/apps/canvas/CanvasApp.tsx index e7b4268d77..a82ec9a7cc 100644 --- a/src/engines/Simulator/apps/canvas/CanvasApp.tsx +++ b/src/engines/Simulator/apps/canvas/CanvasApp.tsx @@ -17,7 +17,7 @@ */ import { useAtomValue, useSetAtom } from "jotai"; import { Layout, PenTool, RefreshCw, Share2 } from "lucide-react"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { Suspense, lazy, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; @@ -37,7 +37,6 @@ import { useCanvasShareDialog, } from "@src/features/CanvasShare"; import { usePublishWorkstationTabHeader } from "@src/hooks/tabHost/useWorkstationTabHeader"; -import { SessionReplayCodeMirrorViewer } from "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer"; import { PrimarySidebarLayoutWithSections, SimulatorReplayChrome, @@ -72,6 +71,13 @@ import { import { projectLatestCanvasEvents } from "./canvasRevisionProjection"; import CanvasDesignSurface from "./design/CanvasDesignSurface"; +// Lazy: the "source" tab is the only CodeMirror user in the canvas app. +const SessionReplayCodeMirrorViewer = lazy(() => + import("@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer").then( + (mod) => ({ default: mod.SessionReplayCodeMirrorViewer }) + ) +); + // ─── types ──────────────────────────────────────────────────────────────────── interface CanvasPayload { @@ -827,15 +833,19 @@ const CanvasApp: React.FC = () => { ) : ( /* source tab */ - + + + )} ); diff --git a/src/features/BenchmarkPanel/index.tsx b/src/features/BenchmarkPanel/index.tsx index 742cf10001..0ae02d6e38 100644 --- a/src/features/BenchmarkPanel/index.tsx +++ b/src/features/BenchmarkPanel/index.tsx @@ -20,7 +20,7 @@ import ModelIcon from "@src/components/ModelIcon"; import TabPill from "@src/components/TabPill"; import { SURFACE_TOKENS } from "@src/config/surfaceTokens"; import BenchmarkTaskSelector from "@src/features/BenchmarkPanel/BenchmarkTaskSelector"; -import { CodeMirrorEditor } from "@src/features/CodeMirror"; +import { CodeMirrorEditor } from "@src/features/CodeMirror/Editor"; import { usePublishWorkstationTabHeader } from "@src/hooks/tabHost/useWorkstationTabHeader"; import { Placeholder, diff --git a/src/modules/MainApp/AgentOrgs/components/CliRawConfigFileEditor.tsx b/src/modules/MainApp/AgentOrgs/components/CliRawConfigFileEditor.tsx index e275d9e3cc..d596de4644 100644 --- a/src/modules/MainApp/AgentOrgs/components/CliRawConfigFileEditor.tsx +++ b/src/modules/MainApp/AgentOrgs/components/CliRawConfigFileEditor.tsx @@ -6,7 +6,7 @@ import { rpc } from "@src/api/tauri/rpc"; import type { AvailableAgent } from "@src/api/tauri/rpc/schemas/validation"; import Button from "@src/components/Button"; import Message from "@src/components/Message"; -import { CodeMirrorEditor } from "@src/features/CodeMirror"; +import { CodeMirrorEditor } from "@src/features/CodeMirror/Editor"; import { SECTION_ACTION_GAP_CLASSES, SECTION_PATH_TEXT_CLASSES, diff --git a/src/modules/MainApp/Integrations/Skills/SkillsCategoryView.tsx b/src/modules/MainApp/Integrations/Skills/SkillsCategoryView.tsx index a4ea918ffa..360b071202 100644 --- a/src/modules/MainApp/Integrations/Skills/SkillsCategoryView.tsx +++ b/src/modules/MainApp/Integrations/Skills/SkillsCategoryView.tsx @@ -1,9 +1,8 @@ import { useAtomValue } from "jotai"; -import React, { useMemo } from "react"; +import React, { Suspense, lazy, useMemo } from "react"; import type { CursorRepo } from "@src/hooks/policies"; import { DetailPanelContainer } from "@src/modules/shared/layouts/blocks"; -import SkillEditorPanel from "@src/scaffold/WizardSystem/variants/Skill/SkillEditorPanel"; import { reposAtom } from "@src/store/repo"; import { @@ -12,6 +11,13 @@ import { } from "../Tables"; import type { SkillEditorState, SkillsHubDetailState } from "./types"; +// Lazy: the skill editor embeds a CodeMirror editor. Settings/Integrations +// is reachable from every settings surface, but the editor only mounts once +// the user opens a skill for editing. +const SkillEditorPanel = lazy( + () => import("@src/scaffold/WizardSystem/variants/Skill/SkillEditorPanel") +); + export const SkillsCategoryView: React.FC<{ selectedId: string | null; skillsHub: SkillsHubDetailState; @@ -34,11 +40,13 @@ export const SkillsCategoryView: React.FC<{ if (skillEditor.editorMode) { return ( - + + + ); } const augmentedTableProps: CategoryTableContentProps = { diff --git a/src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer.tsx b/src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer.tsx index bd98160467..0fdb7321e7 100644 --- a/src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer.tsx +++ b/src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer.tsx @@ -8,7 +8,7 @@ import React, { memo, useCallback, useMemo, useState } from "react"; import { CodeMirrorEditor, type TextSelectionInfo, -} from "@src/features/CodeMirror"; +} from "@src/features/CodeMirror/Editor"; import { TextSelectionDropdown } from "@src/scaffold/ContextMenu/exports"; import { addToAgentAtom } from "@src/store/ui/addToAgentAtom"; import { getFileName } from "@src/util/file/pathUtils"; diff --git a/src/modules/WorkStation/CodeEditor/index.tsx b/src/modules/WorkStation/CodeEditor/index.tsx index 65d37c24b9..d7111ae383 100644 --- a/src/modules/WorkStation/CodeEditor/index.tsx +++ b/src/modules/WorkStation/CodeEditor/index.tsx @@ -27,14 +27,14 @@ import { } from "@src/store/workstation/tabs"; import { - SidebarSlot, WorkStationShell, buildPrimarySidebarConfig, buildSecondaryPanelConfig, } from "../shared"; -// Side-effect import: registers SourceControlTabSidebar into -// TAB_SIDEBAR_REGISTRY. -import "../shared/SidebarModules"; +// Imported from the SidebarModules entry (not the shared barrel): this +// module evaluation is also what registers the SourceControl / Terminal / +// Benchmark tab sidebars into TAB_SIDEBAR_REGISTRY. +import { SidebarSlot } from "../shared/SidebarModules"; import { EditorIntegrations } from "./EditorLayout/components/EditorIntegrations"; // Static imports — lazy loading added ~200-500ms of blank screen on first open // because Suspense fallback={null} shows nothing while the chunk loads. diff --git a/src/modules/WorkStation/shared/GitFileDiffSplit/index.tsx b/src/modules/WorkStation/shared/GitFileDiffSplit/index.tsx deleted file mode 100644 index 31837522d4..0000000000 --- a/src/modules/WorkStation/shared/GitFileDiffSplit/index.tsx +++ /dev/null @@ -1,385 +0,0 @@ -/** - * GitFileDiffSplit - * - * Reusable two-column "git changes" detail layout: - * - * ┌─ headerSlot (caller-supplied) ─────────────────────────────┐ - * ├─ GitFileList ─┬─ FileHeader + CodeMirrorDiff (selected) ───┤ - * │ src/foo.ts │ │ - * │ src/bar.ts │ │ - * │ … │ │ - * ├─ fileListFooterSlot (optional, pinned under the file list)─┤ - * └────────────────────────────────────────────────────────────┘ - * - * Used by: - * - GitCommitDetailContent (Code Editor git history) → `headerSlot` is the - * commit breadcrumb, `fetchFileDiff` reads parent_sha vs commit_sha. - * - * Selection persistence is intentionally pushed onto callers via - * `selectedFilePath` + `onSelectFile` so each surface can store the selection - * wherever it makes sense (e.g. commit-detail keeps it locally). - * - * `fetchFileDiff` lets callers keep their own ref-pair semantics (commit vs - * working tree) and decide whether to batch-load or fetch on demand. - */ -import { useAtom } from "jotai"; -import { ChevronRight } from "lucide-react"; -import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { CodeMirrorDiff } from "@src/features/CodeMirror"; -import FileHeader from "@src/modules/shared/components/FileHeader"; -import type { DiffViewMode } from "@src/modules/shared/components/FileHeader"; -import { Placeholder } from "@src/modules/shared/layouts/blocks"; -import { VerticalResizeHandle, useColumnResize } from "@src/scaffold/Resize"; -import type { GitFile } from "@src/types/git/types"; - -import GitFileList from "../GitFileList"; -import { - GIT_FILE_LIST_MAX_WIDTH, - GIT_FILE_LIST_MIN_WIDTH, - gitFileListWidthAtom, -} from "../GitFileList/widthAtom"; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * One file's diff payload, returned by `fetchFileDiff`. - * - * Callers decide whether `oldContent`/`newContent` are pulled from a batch - * fetch made earlier or fetched on demand here. Returning `null` means the - * fetch failed for that file; the surface renders an error placeholder. - */ -export interface GitFileDiffContent { - oldContent: string; - newContent: string; - isBinary: boolean; -} - -export type FileListLoadState = "loading" | "ready" | "error" | "no-files"; - -export interface GitFileDiffSplitProps { - /** - * Files to render in the left column. Pass an empty array to surface the - * "no changes" placeholder; pass `loadState: "loading"` to surface the - * loading placeholder while the file list is being fetched. - */ - files: GitFile[]; - /** File-list load state. Drives top-level loading / error / empty placeholders. */ - loadState: FileListLoadState; - /** Error message rendered with the loadState === "error" placeholder. */ - loadError?: string | null; - /** Optional retry handler for the error placeholder. */ - onRetryLoad?: () => void; - /** - * Currently selected file path (id). Caller manages the source of truth so - * selection survives tab switches and remounts. - */ - selectedFilePath: string | null; - /** Called when the user clicks a row in the file list. */ - onSelectFile: (filePath: string) => void; - /** - * Caller-supplied diff fetcher. Returning `null` triggers an error - * placeholder for that file. - */ - fetchFileDiff: ( - file: GitFile, - signal: AbortSignal - ) => Promise; - /** Repo root path — used by FileHeader breadcrumb dropdowns. */ - repoPath: string; - /** Optional content rendered above the split (commit info / commit box). */ - headerSlot?: React.ReactNode; - /** - * Optional content pinned to the bottom of the left (file list) column — - * intended for action surfaces like the commit textarea + buttons that - * should sit next to the file selection rather than floating above the - * diff. - */ - fileListFooterSlot?: React.ReactNode; - /** Optional title for the file list section header (defaults to "Changed files"). */ - fileListTitle?: string; - /** Empty-state title when `files` is empty and loadState === "no-files". */ - emptyTitle?: string; - /** Empty-state subtitle. */ - emptySubtitle?: string; - /** Forwarded to FileHeader.onFileSelect (e.g. open a tab on breadcrumb click). */ - onFileHeaderSelect?: (filePath: string) => void; -} - -// ============================================================================ -// Component -// ============================================================================ - -const GitFileDiffSplit: React.FC = ({ - files, - loadState, - loadError, - onRetryLoad, - selectedFilePath, - onSelectFile, - fetchFileDiff, - repoPath, - headerSlot, - fileListFooterSlot, - fileListTitle, - emptyTitle, - emptySubtitle, - onFileHeaderSelect, -}) => { - const { t } = useTranslation(); - - const [fileListCollapsed, setFileListCollapsed] = useState(false); - const [viewMode, setViewMode] = useState("unified"); - const [fileListWidth, setFileListWidth] = useAtom(gitFileListWidthAtom); - const { columnRef: fileListRef, handleMouseDown: handleFileListResize } = - useColumnResize({ - width: fileListWidth, - setWidth: setFileListWidth, - min: GIT_FILE_LIST_MIN_WIDTH, - max: GIT_FILE_LIST_MAX_WIDTH, - }); - - // ── Per-selection diff content fetch ──────────────────────────────────── - const [oldContent, setOldContent] = useState(""); - const [newContent, setNewContent] = useState(""); - const [isBinary, setIsBinary] = useState(false); - const [fileLoadState, setFileLoadState] = useState< - "idle" | "loading" | "ready" | "error" - >("idle"); - const [fileError, setFileError] = useState(null); - const [fileReloadKey, setFileReloadKey] = useState(0); - - const reloadFile = useCallback(() => { - setFileReloadKey((k) => k + 1); - }, []); - - const selectedFile = useMemo(() => { - if (!selectedFilePath) return null; - return files.find((file) => file.path === selectedFilePath) ?? null; - }, [files, selectedFilePath]); - - // GitFileList uses `file.id` for selection highlighting; translate back. - const selectedFileId = selectedFile?.id ?? null; - - useEffect(() => { - if (!selectedFile) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setFileLoadState("idle"); - // eslint-disable-next-line react-hooks/set-state-in-effect - setFileError(null); - // eslint-disable-next-line react-hooks/set-state-in-effect - setOldContent(""); - // eslint-disable-next-line react-hooks/set-state-in-effect - setNewContent(""); - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsBinary(false); - return; - } - - const controller = new AbortController(); - let cancelled = false; - // eslint-disable-next-line react-hooks/set-state-in-effect - setFileLoadState("loading"); - // eslint-disable-next-line react-hooks/set-state-in-effect - setFileError(null); - - fetchFileDiff(selectedFile, controller.signal) - .then((result) => { - if (cancelled) return; - if (!result) { - setFileLoadState("error"); - setFileError(selectedFile.path); - return; - } - setOldContent(result.oldContent); - setNewContent(result.newContent); - setIsBinary(result.isBinary); - setFileLoadState("ready"); - }) - .catch((err: unknown) => { - if (cancelled || controller.signal.aborted) return; - setFileLoadState("error"); - setFileError(err instanceof Error ? err.message : selectedFile.path); - }); - - return () => { - cancelled = true; - controller.abort(); - }; - }, [selectedFile, fetchFileDiff, fileReloadKey]); - - const handleFileSelectInList = useCallback( - (fileId: string) => { - // GitFileList passes `file.id` (which may be a composite key like - // `repoId:path-index`). We resolve back to `file.path` so that - // `selectedFilePath` always holds a plain path, matching the lookup - // in `selectedFile` (`files.find(f => f.path === selectedFilePath)`). - const matched = files.find((f) => f.id === fileId); - onSelectFile(matched ? matched.path : fileId); - }, - [files, onSelectFile] - ); - - const toggleFileList = useCallback(() => { - setFileListCollapsed((prev) => !prev); - }, []); - - // ── Top-level placeholders ───────────────────────────────────────────── - if (loadState === "loading") { - return ( -
- {headerSlot} - -
- ); - } - - if (loadState === "error") { - return ( -
- {headerSlot} - -
- ); - } - - if (loadState === "no-files" || files.length === 0) { - return ( -
- {headerSlot} - -
- ); - } - - // ── Ready: split layout ──────────────────────────────────────────────── - return ( -
- {headerSlot} - -
- {/* Left: file list (with optional footer slot pinned at bottom) */} - {!fileListCollapsed && ( - <> -
-
- -
- {fileListFooterSlot && ( -
- {fileListFooterSlot} -
- )} -
- - - )} - - {/* Collapse toggle (when the list is hidden) */} - {fileListCollapsed && ( - - )} - - {/* Right: selected file diff */} -
- {selectedFile ? ( - <> - -
- {fileLoadState === "loading" ? ( - - ) : fileLoadState === "error" ? ( - - ) : isBinary ? ( - - ) : ( - - )} -
- - ) : ( - - )} -
-
-
- ); -}; - -export default memo(GitFileDiffSplit); diff --git a/src/modules/WorkStation/shared/index.ts b/src/modules/WorkStation/shared/index.ts index 88814d260a..86e618809b 100644 --- a/src/modules/WorkStation/shared/index.ts +++ b/src/modules/WorkStation/shared/index.ts @@ -85,19 +85,13 @@ export type { PrimarySidebarTab, } from "./PrimarySidebarLayout"; -// Reusable sidebar modules (tab-specific sidebar substrate) -export { - SourceControlTabSidebar, - registerTabSidebar, - getTabSidebarDescriptor, - hasTabSidebar, - SidebarSlot, - useTabSidebar, - type TabSidebarComponent, - type TabSidebarDescriptor, - type TabSidebarProps, - type TabSidebarRuntimeContext, -} from "./SidebarModules"; +// Reusable sidebar modules (tab-specific sidebar substrate) are NOT +// re-exported here on purpose: `./SidebarModules/index.ts` evaluates the +// Terminal/Benchmark/SourceControl tab sidebars (module-side-effect +// registrations), which pulls xterm + engines/TerminalCore into every +// consumer of this barrel. Hosts import from +// `@src/modules/WorkStation/shared/SidebarModules` directly (see +// CodeEditor/index.tsx, which also carries the side-effect import). // Property editor components export { @@ -149,15 +143,6 @@ export { GIT_FILE_LIST_MIN_WIDTH, } from "./GitFileList/widthAtom"; -// Reusable two-column "git changes" detail layout (file list + selected diff) -// Used by My Station's GitCommitDetailContent and Control Tower's Git tab. -export { default as GitFileDiffSplit } from "./GitFileDiffSplit"; -export type { - GitFileDiffContent, - GitFileDiffSplitProps, - FileListLoadState, -} from "./GitFileDiffSplit"; - // Resize handles export { HorizontalResizeHandle, @@ -171,8 +156,9 @@ export type { UnsavedChangesBarProps, } from "./UnsavedChangesBar"; -// Quick actions panel -export { QuickActionsPanel } from "./QuickActionsPanel"; +// Quick actions panel — types only. The component (framer-motion) is not +// re-exported: nothing imports it through this barrel, and a value export +// here would drag the animation stack into every barrel consumer. export type { QuickAction, QuickActionsPanelProps } from "./QuickActionsPanel"; // No tabs placeholder (with quick actions) diff --git a/src/modules/shared/components/MarkdownEditor/index.tsx b/src/modules/shared/components/MarkdownEditor/index.tsx index 471b5dbf0a..3239d82526 100644 --- a/src/modules/shared/components/MarkdownEditor/index.tsx +++ b/src/modules/shared/components/MarkdownEditor/index.tsx @@ -1,5 +1,7 @@ import React, { + Suspense, forwardRef, + lazy, useCallback, useEffect, useImperativeHandle, @@ -11,10 +13,14 @@ import { useTranslation } from "react-i18next"; import Markdown from "@src/components/MarkDown"; import TabPill from "@src/components/TabPill"; -import { CodeMirrorEditor } from "@src/features/CodeMirror"; import "./index.scss"; +// Lazy: MarkdownEditor is mounted by the Settings/Integrations wizards and +// the AgentOrgs configuration surfaces; loading CodeMirror only when the +// edit tab actually renders keeps those routes light until then. +const CodeMirrorEditor = lazy(() => import("@src/features/CodeMirror/Editor")); + export interface MarkdownEditorRef { getText: () => string; getMarkdown: () => string; @@ -247,18 +253,20 @@ const MarkdownEditor = forwardRef( style={contentStyle} onMouseDown={handleEditorChromeClick} > - + + + {value.trim().length === 0 && placeholder && (
{placeholder}
)} diff --git a/src/scaffold/WizardSystem/variants/Skill/SkillEditorBlocks.tsx b/src/scaffold/WizardSystem/variants/Skill/SkillEditorBlocks.tsx index 5493654801..f2836ba6c8 100644 --- a/src/scaffold/WizardSystem/variants/Skill/SkillEditorBlocks.tsx +++ b/src/scaffold/WizardSystem/variants/Skill/SkillEditorBlocks.tsx @@ -15,7 +15,7 @@ import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; import Input from "@src/components/Input"; import Switch from "@src/components/Switch"; -import { CodeMirrorEditor } from "@src/features/CodeMirror"; +import { CodeMirrorEditor } from "@src/features/CodeMirror/Editor"; import type { UseSkillEditorReturn } from "@src/hooks/skills/useSkillEditor"; import type { BundledFileDraft, diff --git a/src/scaffold/WizardSystem/variants/Skill/SkillEditorPanel.tsx b/src/scaffold/WizardSystem/variants/Skill/SkillEditorPanel.tsx index 90e6b3f3c5..baa8d91273 100644 --- a/src/scaffold/WizardSystem/variants/Skill/SkillEditorPanel.tsx +++ b/src/scaffold/WizardSystem/variants/Skill/SkillEditorPanel.tsx @@ -17,7 +17,7 @@ import type { RadioValue } from "@src/components/Radio"; import Switch from "@src/components/Switch"; import TabPill from "@src/components/TabPill"; import type { TabPillItem } from "@src/components/TabPill"; -import { CodeMirrorEditor } from "@src/features/CodeMirror"; +import { CodeMirrorEditor } from "@src/features/CodeMirror/Editor"; import type { UseSkillEditorReturn } from "@src/hooks/skills/useSkillEditor"; import { SKILL_SCOPE,