Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,9 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*

# Copy bundled server and frontend assets
# Vite outputs JS/CSS/HTML directly to dist/ (assetsDir: ".")
# Vite outputs to dist/renderer/ (assetsDir: ".")
COPY --from=builder /app/dist/server-bundle.js ./dist/
COPY --from=builder /app/dist/*.html ./dist/
COPY --from=builder /app/dist/*.js ./dist/
COPY --from=builder /app/dist/*.css ./dist/
COPY --from=builder /app/dist/renderer ./dist/renderer
COPY --from=builder /app/dist/static ./dist/static

# Copy only native modules needed at runtime (node-pty for terminal support)
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@
],
"asarUnpack": [
"dist/**/*.wasm",
"dist/**/*.map",
"**/node_modules/node-pty/build/**/*"
],
"mac": {
Expand Down
12 changes: 9 additions & 3 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { useResizableSidebar } from "@/browser/hooks/useResizableSidebar";
import {
shouldShowInterruptedBarrier,
mergeConsecutiveStreamErrors,
computeBashOutputGroupInfo,
precomputeBashOutputGroups,
getEditableUserMessageText,
} from "@/browser/utils/messages/messageUtils";
import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator";
Expand Down Expand Up @@ -207,6 +207,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
? transformedMessages
: deferredTransformedMessages;

// Precompute bash output group boundaries in O(n) instead of O(n²)
const bashOutputGroups = useMemo(
() => precomputeBashOutputGroups(deferredMessages),
[deferredMessages]
);

const autoCompactionResult = useMemo(
() => checkAutoCompaction(workspaceUsage, pendingModel, use1M, autoCompactionThreshold / 100),
[workspaceUsage, pendingModel, use1M, autoCompactionThreshold]
Expand Down Expand Up @@ -629,8 +635,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
) : (
<>
{deferredMessages.map((msg, index) => {
// Compute bash_output grouping at render-time
const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index);
// Look up precomputed bash_output group info (O(1) instead of O(n))
const bashOutputGroup = bashOutputGroups.get(index);

// For bash_output groups, use first message ID as expansion key
const groupKey = bashOutputGroup
Expand Down
210 changes: 145 additions & 65 deletions src/browser/components/Messages/Mermaid.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,44 @@
import type { CSSProperties, ReactNode } from "react";
import type { Mermaid as MermaidAPI } from "mermaid";
import React, { useContext, useEffect, useRef, useState } from "react";
import mermaid from "mermaid";
import { StreamingContext } from "./StreamingContext";
import { usePersistedState } from "@/browser/hooks/usePersistedState";

const MIN_HEIGHT = 300;
const MAX_HEIGHT = 1200;

// Initialize mermaid
mermaid.initialize({
startOnLoad: false,
theme: "dark",
layout: "elk",
securityLevel: "loose",
fontFamily: "var(--font-monospace)",
darkMode: true,
elk: {
nodePlacementStrategy: "LINEAR_SEGMENTS",
mergeEdges: true,
},
wrap: true,
markdownAutoWrap: true,
flowchart: {
nodeSpacing: 60,
curve: "linear",
defaultRenderer: "elk",
},
});
// SVG cache keyed by chart source to avoid re-rendering identical diagrams
const svgCache = new Map<string, string>();

// Mermaid instance loaded on-demand
let mermaidInstance: MermaidAPI | null = null;

async function getMermaid(): Promise<MermaidAPI> {
if (!mermaidInstance) {
const module = await import("mermaid");
mermaidInstance = module.default;
mermaidInstance.initialize({
startOnLoad: false,
theme: "dark",
layout: "elk",
securityLevel: "loose",
fontFamily: "var(--font-monospace)",
darkMode: true,
elk: {
nodePlacementStrategy: "LINEAR_SEGMENTS",
mergeEdges: true,
},
wrap: true,
markdownAutoWrap: true,
flowchart: {
nodeSpacing: 60,
curve: "linear",
defaultRenderer: "elk",
},
});
}
return mermaidInstance;
}

// Common button styles
const getButtonStyle = (disabled = false): CSSProperties => ({
Expand Down Expand Up @@ -109,9 +121,11 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {
const { isStreaming } = useContext(StreamingContext);
const containerRef = useRef<HTMLDivElement>(null);
const modalContainerRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [svg, setSvg] = useState<string>("");
const [isVisible, setIsVisible] = useState(false);
const [diagramMaxHeight, setDiagramMaxHeight] = usePersistedState(
"mermaid-diagram-max-height",
MIN_HEIGHT,
Expand All @@ -133,28 +147,75 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {
}
};

// Track visibility with IntersectionObserver - only render when visible
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;

const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setIsVisible(true);
// Once visible, stop observing (we keep it rendered)
observer.disconnect();
}
},
{ rootMargin: "100px" } // Start loading slightly before visible
);

observer.observe(sentinel);
return () => observer.disconnect();
}, []);

useEffect(() => {
// Don't render until visible
if (!isVisible) return;

let id: string | undefined;
let cancelled = false;

const renderDiagram = async () => {
// Check cache first
const cached = svgCache.get(chart);
if (cached) {
setSvg(cached);
if (containerRef.current) {
containerRef.current.innerHTML = cached;
}
return;
}

id = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
try {
setError(null);

const mermaid = await getMermaid();
if (cancelled) return;

// Parse first to validate syntax without rendering
await mermaid.parse(chart);
if (cancelled) return;

// If parse succeeds, render the diagram
const { svg: renderedSvg } = await mermaid.render(id, chart);
if (cancelled) return;

// Cache the result
svgCache.set(chart, renderedSvg);

setSvg(renderedSvg);
if (containerRef.current) {
containerRef.current.innerHTML = renderedSvg;
}
} catch (err) {
if (cancelled) return;

// Clean up any DOM elements mermaid might have created with our ID
const errorElement = document.getElementById(id);
if (errorElement) {
errorElement.remove();
if (id) {
const errorElement = document.getElementById(id);
if (errorElement) {
errorElement.remove();
}
}

setError(err instanceof Error ? err.message : "Failed to render diagram");
Expand All @@ -169,14 +230,15 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {

// Cleanup on unmount or when chart changes
return () => {
cancelled = true;
if (id) {
const element = document.getElementById(id);
if (element) {
element.remove();
}
}
};
}, [chart]);
}, [chart, isVisible]);

// Update modal container when opened
useEffect(() => {
Expand Down Expand Up @@ -218,57 +280,75 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {

return (
<>
{/* Sentinel for IntersectionObserver - placed before content for early detection */}
<div ref={sentinelRef} style={{ height: 1, marginBottom: -1 }} />
<div
style={{
position: "relative",
margin: "1em 0",
background: "var(--color-code-bg)",
borderRadius: "4px",
padding: "16px",
minHeight: isVisible ? undefined : "100px", // Reserve space while loading
}}
>
<div
style={{
position: "absolute",
top: "8px",
right: "8px",
display: "flex",
gap: "4px",
}}
>
<button
onClick={handleDecreaseHeight}
disabled={atMinHeight}
style={getButtonStyle(atMinHeight)}
title="Decrease diagram height"
>
−
</button>
<button
onClick={handleIncreaseHeight}
disabled={atMaxHeight}
style={getButtonStyle(atMaxHeight)}
title="Increase diagram height"
{isVisible && (
<div
style={{
position: "absolute",
top: "8px",
right: "8px",
display: "flex",
gap: "4px",
}}
>
+
</button>
<button
onClick={() => setIsModalOpen(true)}
style={getButtonStyle()}
title="Expand diagram"
<button
onClick={handleDecreaseHeight}
disabled={atMinHeight}
style={getButtonStyle(atMinHeight)}
title="Decrease diagram height"
>
−
</button>
<button
onClick={handleIncreaseHeight}
disabled={atMaxHeight}
style={getButtonStyle(atMaxHeight)}
title="Increase diagram height"
>
+
</button>
<button
onClick={() => setIsModalOpen(true)}
style={getButtonStyle()}
title="Expand diagram"
>
⤢
</button>
</div>
)}
{!isVisible ? (
<div
style={{
color: "var(--color-text-secondary)",
fontStyle: "italic",
textAlign: "center",
padding: "24px",
}}
>
⤢
</button>
</div>
<div
ref={containerRef}
className="mermaid-container"
style={{
maxWidth: "70%",
margin: "0 auto",
["--diagram-max-height" as string]: `${diagramMaxHeight}px`,
}}
/>
Loading diagram...
</div>
) : (
<div
ref={containerRef}
className="mermaid-container"
style={{
maxWidth: "70%",
margin: "0 auto",
["--diagram-max-height" as string]: `${diagramMaxHeight}px`,
}}
/>
)}
</div>
{isModalOpen && (
<DiagramModal onClose={() => setIsModalOpen(false)}>
Expand Down
17 changes: 10 additions & 7 deletions src/browser/components/TerminalView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useEffect, useState } from "react";
import { init, Terminal, FitAddon } from "ghostty-web";
import type { Terminal as GhosttyTerminal, FitAddon as GhosttyFitAddon } from "ghostty-web";
import { useTerminalSession } from "@/browser/hooks/useTerminalSession";
import { useAPI } from "@/browser/contexts/API";

Expand All @@ -11,8 +11,8 @@ interface TerminalViewProps {

export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const termRef = useRef<GhosttyTerminal | null>(null);
const fitAddonRef = useRef<GhosttyFitAddon | null>(null);
const [terminalError, setTerminalError] = useState<string | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
const [terminalSize, setTerminalSize] = useState<{ cols: number; rows: number } | null>(null);
Expand Down Expand Up @@ -74,19 +74,22 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr
return;
}

let terminal: Terminal | null = null;
let terminal: GhosttyTerminal | null = null;

const initTerminal = async () => {
try {
// Dynamically load ghostty-web (large WASM module - keep out of initial bundle)
const ghostty = await import("ghostty-web");

// Initialize ghostty-web WASM module (idempotent, safe to call multiple times)
await init();
await ghostty.init();

// Resolve CSS variables for xterm.js (canvas rendering doesn't support CSS vars)
const styles = getComputedStyle(document.documentElement);
const terminalBg = styles.getPropertyValue("--color-terminal-bg").trim() || "#1e1e1e";
const terminalFg = styles.getPropertyValue("--color-terminal-fg").trim() || "#d4d4d4";

terminal = new Terminal({
terminal = new ghostty.Terminal({
fontSize: 14,
fontFamily: "JetBrains Mono, Menlo, Monaco, monospace",
cursorBlink: true,
Expand All @@ -96,7 +99,7 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr
},
});

const fitAddon = new FitAddon();
const fitAddon = new ghostty.FitAddon();
terminal.loadAddon(fitAddon);

terminal.open(containerRef.current!);
Expand Down
Loading
Loading