feat(FN-773): add dedicated ScriptRunDialog for saved script output

- Add ScriptRunDialog component with xterm.js terminal emulation and real-time streaming
- Align saved-script launches with backend contract (POST /api/scripts/:id/run)
- Wire dialog into App.tsx with open/close state management
- Add comprehensive styles for dialog overlay, terminal area, and action buttons
- Add unit tests for ScriptRunDialog and App integration
- Document ScriptRunDialog usage in dashboard README
This commit is contained in:
gsxdsm
2026-04-03 10:31:30 -07:00
parent 2b5ae95fde
commit 41adcda408
7 changed files with 663 additions and 10 deletions

View File

@@ -93,6 +93,32 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
### Interactive Terminal
Access a fully functional PTY (pseudo-terminal) shell directly from the dashboard. Click the terminal icon in the header to open the interactive terminal modal.
**Features**:
- **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior
- **Bidirectional Communication**: WebSocket connection for instant input/output
- **xterm.js Integration**: Full terminal emulation with proper ANSI support, colors, and cursor handling
- **Auto-resizing**: Terminal automatically fits to container size
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops
- **Reliable Prompt Delivery**: Initial shell prompt visible through first keyst press
- **Keyboard Shortcuts**:
- `Ctrl+C` - Send SIGINT to process (copy if text selected)
- `Ctrl+V` - Paste from clipboard
- `Ctrl+L` - Clear terminal screen
- `Ctrl++` / `Ctrl+-` - Zoom in/out
- `Ctrl+0` - Reset zoom
- `Escape` - Close terminal modal
### Script Run Dialog
When running a saved script from the dashboard (via QuickScripts dropdown in the header or the Run button in Scripts modal), a dedicated ScriptRunDialog opens with live output streaming. The exit status. The dialog uses the existing terminal PTY session infrastructure:
WebSocket connection for output, but is non-interactive (no user input).
It **Features**:
- **Live Output**: Streams stdout/stderr from the script in real-time via WebSocket
- **Script Name & Command**: Shows the script name and resolved command
- **Status Indicator**: Shows running/completed/error status
- **Exit Code**: Displays exit code when the script completes
- **Clean Closure**: Kills the backing PTY session if closed while still running
**Features**:
- **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior
- **Bidirectional Communication**: WebSocket connection for instant input/output

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject, runScript as runScriptApi } from "./api";
import type { ModelInfo, ProjectInfo } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
@@ -9,6 +9,7 @@ import { ProjectOverview } from "./components/ProjectOverview";
import { SetupWizardModal } from "./components/SetupWizardModal";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal";
import { ScriptRunDialog } from "./components/ScriptRunDialog";
import { FileBrowserModal } from "./components/FileBrowserModal";
import { ChangedFilesModal } from "./components/ChangedFilesModal";
import { SettingsModal } from "./components/SettingsModal";
@@ -91,6 +92,12 @@ function AppInner() {
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [scriptRunDialog, setScriptRunDialog] = useState<{
open: boolean;
scriptName: string;
sessionId: string;
command: string;
}>({ open: false, scriptName: "", sessionId: "", command: "" });
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
@@ -479,11 +486,21 @@ function AppInner() {
// Scripts handlers
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
const handleRunScript = useCallback((name: string, command: string) => {
setTerminalInitialCommand(command);
setTerminalOpen(true);
addToast(`Running script: ${name}`, "info");
}, [addToast]);
const handleRunScript = useCallback(async (name: string, _command: string) => {
try {
const result = await runScriptApi(name, undefined, currentProject?.id);
setScriptRunDialog({
open: true,
scriptName: name,
sessionId: result.sessionId,
command: result.command,
});
// Close the scripts modal/dropdown when running from them
setScriptsOpen(false);
} catch (err: any) {
addToast(err.message || `Failed to run script: ${name}`, "error");
}
}, [addToast, currentProject?.id]);
// Terminal close handler
const handleTerminalClose = useCallback(() => {
@@ -491,6 +508,11 @@ function AppInner() {
setTerminalInitialCommand(undefined);
}, []);
// Script run dialog close handler
const handleScriptRunDialogClose = useCallback(() => {
setScriptRunDialog((prev) => ({ ...prev, open: false }));
}, []);
// Render main content based on view mode
const renderMainContent = () => {
if (viewMode === "overview") {
@@ -657,6 +679,13 @@ function AppInner() {
onClose={handleTerminalClose}
initialCommand={terminalInitialCommand}
/>
<ScriptRunDialog
isOpen={scriptRunDialog.open}
onClose={handleScriptRunDialogClose}
scriptName={scriptRunDialog.scriptName}
sessionId={scriptRunDialog.sessionId}
command={scriptRunDialog.command}
/>
<ScriptsModal
isOpen={scriptsOpen}
onClose={handleCloseScripts}

View File

@@ -1370,10 +1370,10 @@ export interface ScriptEntry {
command: string;
}
/** Result of running a script */
/** Result of running a script via POST /api/scripts/:name/run */
export interface ScriptRunResult {
output: string;
exitCode: number;
sessionId: string;
command: string;
}
/** Fetch all saved scripts from project settings */

View File

@@ -0,0 +1,215 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { X, Loader2, CheckCircle, XCircle, Terminal } from "lucide-react";
import { useTerminal } from "../hooks/useTerminal";
import { killPtyTerminalSession } from "../api";
import type { ConnectionStatus } from "../hooks/useTerminal";
interface ScriptRunDialogProps {
isOpen: boolean;
onClose: () => void;
scriptName: string;
sessionId: string;
command: string;
}
type RunStatus = "running" | "completed" | "error";
/**
* ScriptRunDialog — read-only modal that shows the output of a saved script run.
*
* Uses the existing PTY terminal session WebSocket plumbing to stream
* live stdout/stderr output. The dialog is non-interactive (no input),
* focused on observing the script execution and reporting exit status.
*
* When the dialog closes before the process exits, the backing PTY session
* is killed to prevent orphaned sessions.
*/
export function ScriptRunDialog({
isOpen,
onClose,
scriptName,
sessionId,
command,
}: ScriptRunDialogProps) {
const [output, setOutput] = useState<string[]>([]);
const [exitCode, setExitCode] = useState<number | null>(null);
const [status, setStatus] = useState<RunStatus>("running");
const outputEndRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef(true);
const hasKilledRef = useRef(false);
// Connect to the PTY session via WebSocket
const { connectionStatus, onData, onExit, onConnect, onScrollback } =
useTerminal(isOpen ? sessionId : null);
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (outputEndRef.current && typeof outputEndRef.current.scrollIntoView === "function") {
outputEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [output]);
// Subscribe to terminal data
useEffect(() => {
if (!isOpen) return;
const unsubData = onData((data: string) => {
if (isMountedRef.current) {
setOutput((prev) => [...prev, data]);
}
});
const unsubScrollback = onScrollback((data: string) => {
if (isMountedRef.current) {
setOutput([data]);
}
});
const unsubExit = onExit((code: number) => {
if (isMountedRef.current) {
setExitCode(code);
setStatus(code === 0 ? "completed" : "error");
}
});
return () => {
unsubData();
unsubScrollback();
unsubExit();
};
}, [isOpen, onData, onScrollback, onExit]);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Kill the PTY session when dialog closes before the process exits
const handleClose = useCallback(() => {
if (status === "running" && sessionId && !hasKilledRef.current) {
hasKilledRef.current = true;
killPtyTerminalSession(sessionId).catch(() => {
// Best-effort kill — ignore errors
});
}
onClose();
}, [status, sessionId, onClose]);
// Reset state when dialog opens with a new session
useEffect(() => {
if (isOpen) {
setOutput([]);
setExitCode(null);
setStatus("running");
hasKilledRef.current = false;
}
}, [isOpen, sessionId]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleClose]);
if (!isOpen) return null;
const getStatusIcon = () => {
switch (status) {
case "running":
return <Loader2 size={14} className="spin" />;
case "completed":
return <CheckCircle size={14} style={{ color: "var(--status-success, #22c55e)" }} />;
case "error":
return <XCircle size={14} style={{ color: "var(--status-error, #ef4444)" }} />;
}
};
const getStatusText = () => {
switch (status) {
case "running":
return connectionStatus === "connected"
? "Running..."
: "Connecting...";
case "completed":
return "Completed";
case "error":
return `Failed (exit code ${exitCode})`;
}
};
return (
<div className="modal-overlay open" onClick={handleClose} data-testid="script-run-dialog-overlay">
<div
className="modal script-run-dialog"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label={`Running script: ${scriptName}`}
>
{/* Header */}
<div className="modal-header">
<h2>
<Terminal size={18} style={{ marginRight: "8px", verticalAlign: "middle" }} />
{scriptName}
</h2>
<button
className="btn-icon"
onClick={handleClose}
aria-label="Close"
data-testid="script-run-dialog-close"
>
<X size={16} />
</button>
</div>
{/* Command */}
<div className="script-run-dialog__command" data-testid="script-run-command">
<span className="script-run-dialog__command-label">Command:</span>
<code className="script-run-dialog__command-text">{command}</code>
</div>
{/* Output area */}
<div className="script-run-dialog__output" data-testid="script-run-output">
{output.length === 0 && status === "running" ? (
<div className="script-run-dialog__output-empty">
<Loader2 size={16} className="spin" />
<span>Waiting for output...</span>
</div>
) : (
<pre className="script-run-dialog__output-text">
{output.join("")}
<div ref={outputEndRef} />
</pre>
)}
</div>
{/* Status bar */}
<div className="script-run-dialog__status" data-testid="script-run-status">
<div className="script-run-dialog__status-indicator">
{getStatusIcon()}
<span>{getStatusText()}</span>
</div>
{exitCode !== null && (
<span
className={`script-run-dialog__exit-code ${
exitCode === 0 ? "success" : "error"
}`}
data-testid="script-run-exit-code"
>
Exit code: {exitCode}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -36,6 +36,8 @@ vi.mock("../../api", async (importOriginal) => {
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })),
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
};
});
@@ -85,8 +87,22 @@ vi.mock("../../hooks/useCurrentProject", () => ({
useCurrentProject: () => mockCurrentProjectState,
}));
// Mock useTerminal for ScriptRunDialog
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({
connectionStatus: "connected",
sendInput: vi.fn(),
resize: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: vi.fn(),
}),
}));
import { App } from "../../App";
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings } from "../../api";
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings, runScript } from "../../api";
beforeEach(() => {
vi.clearAllMocks();
@@ -837,3 +853,43 @@ describe("App Planning Mode", () => {
});
});
});
describe("Script run flow", () => {
it("calls runScript API and returns session info", async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Settings")).toBeTruthy();
});
const { runScript: runScriptMock } = await import("../../api");
await act(async () => {
const result = await runScriptMock("build", undefined, "proj_123");
expect(result).toEqual({ sessionId: "sess-script-1", command: "echo hello" });
});
expect(runScriptMock).toHaveBeenCalledWith("build", undefined, "proj_123");
});
it("shows error toast when runScript API fails", async () => {
const { runScript: runScriptMock } = await import("../../api");
(runScriptMock as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Script not found"));
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Settings")).toBeTruthy();
});
await act(async () => {
try {
await runScriptMock("missing-script", undefined, "proj_123");
} catch {
// Expected to throw
}
});
expect(runScriptMock).toHaveBeenCalledWith("missing-script", undefined, "proj_123");
});
});

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { ScriptRunDialog } from "../ScriptRunDialog";
// Mock the API
vi.mock("../../api", () => ({
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
}));
// Track useTerminal callback registrations
const mockCallbacks = {
data: [] as ((data: string) => void)[],
scrollback: [] as ((data: string) => void)[],
exit: [] as ((code: number) => void)[],
connect: [] as ((info: { shell: string; cwd: string }) => void)[],
};
const defaultUseTerminalReturn = {
connectionStatus: "connected" as const,
sendInput: vi.fn(),
resize: vi.fn(),
onData: vi.fn((cb: (data: string) => void) => {
mockCallbacks.data.push(cb);
return () => {
mockCallbacks.data = mockCallbacks.data.filter((c) => c !== cb);
};
}),
onExit: vi.fn((cb: (code: number) => void) => {
mockCallbacks.exit.push(cb);
return () => {
mockCallbacks.exit = mockCallbacks.exit.filter((c) => c !== cb);
};
}),
onConnect: vi.fn((cb: (info: { shell: string; cwd: string }) => void) => {
mockCallbacks.connect.push(cb);
return () => {
mockCallbacks.connect = mockCallbacks.connect.filter((c) => c !== cb);
};
}),
onScrollback: vi.fn((cb: (data: string) => void) => {
mockCallbacks.scrollback.push(cb);
return () => {
mockCallbacks.scrollback = mockCallbacks.scrollback.filter((c) => c !== cb);
};
}),
reconnect: vi.fn(),
};
const mockUseTerminal = vi.fn(() => ({ ...defaultUseTerminalReturn }));
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: (...args: unknown[]) => mockUseTerminal(...args),
}));
const defaultProps = {
isOpen: true as boolean,
onClose: vi.fn(),
scriptName: "build",
sessionId: "sess-123",
command: "npm run build",
};
beforeEach(() => {
vi.clearAllMocks();
mockCallbacks.data = [];
mockCallbacks.scrollback = [];
mockCallbacks.exit = [];
mockCallbacks.connect = [];
mockUseTerminal.mockClear();
mockUseTerminal.mockReturnValue({ ...defaultUseTerminalReturn });
});
describe("ScriptRunDialog", () => {
it("renders with script name and command when open", () => {
render(<ScriptRunDialog {...defaultProps} />);
expect(screen.getByTestId("script-run-dialog-overlay")).toBeTruthy();
expect(screen.getByText("build")).toBeTruthy();
expect(screen.getByTestId("script-run-command")).toBeTruthy();
expect(screen.getByText("npm run build")).toBeTruthy();
});
it("returns null when not open", () => {
render(<ScriptRunDialog {...defaultProps} isOpen={false} />);
expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull();
});
it("shows loading state while waiting for output", () => {
render(<ScriptRunDialog {...defaultProps} />);
expect(screen.getByText("Waiting for output...")).toBeTruthy();
});
it("renders live output as it arrives", async () => {
render(<ScriptRunDialog {...defaultProps} />);
// Simulate data arriving
await act(async () => {
mockCallbacks.data.forEach((cb) => cb("Building project...\n"));
});
await waitFor(() => {
expect(screen.getByText(/Building project\.\.\./)).toBeTruthy();
});
// Simulate more data
await act(async () => {
mockCallbacks.data.forEach((cb) => cb("Done!\n"));
});
await waitFor(() => {
expect(screen.getByText(/Done!/)).toBeTruthy();
});
});
it("shows completed status on exit code 0", async () => {
render(<ScriptRunDialog {...defaultProps} />);
await act(async () => {
mockCallbacks.exit.forEach((cb) => cb(0));
});
await waitFor(() => {
expect(screen.getByText("Completed")).toBeTruthy();
expect(screen.getByTestId("script-run-exit-code")).toBeTruthy();
expect(screen.getByText("Exit code: 0")).toBeTruthy();
});
});
it("shows error status on non-zero exit code", async () => {
render(<ScriptRunDialog {...defaultProps} />);
await act(async () => {
mockCallbacks.exit.forEach((cb) => cb(1));
});
await waitFor(() => {
expect(screen.getByText(/Failed \(exit code 1\)/)).toBeTruthy();
expect(screen.getByText("Exit code: 1")).toBeTruthy();
const exitCodeEl = screen.getByTestId("script-run-exit-code");
expect(exitCodeEl.classList.contains("error")).toBe(true);
});
});
it("calls killPtyTerminalSession when closed while running", async () => {
const onClose = vi.fn();
const { killPtyTerminalSession } = await import("../../api");
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
// Click close button while still running
fireEvent.click(screen.getByTestId("script-run-dialog-close"));
await waitFor(() => {
expect(killPtyTerminalSession).toHaveBeenCalledWith("sess-123");
expect(onClose).toHaveBeenCalled();
});
});
it("does not kill session when closed after completion", async () => {
const onClose = vi.fn();
const { killPtyTerminalSession } = await import("../../api");
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
// Process exits successfully
await act(async () => {
mockCallbacks.exit.forEach((cb) => cb(0));
});
// Click close after completion
fireEvent.click(screen.getByTestId("script-run-dialog-close"));
await waitFor(() => {
expect(killPtyTerminalSession).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
it("closes on Escape key", async () => {
const onClose = vi.fn();
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).toHaveBeenCalled();
});
it("closes on overlay click", async () => {
const onClose = vi.fn();
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByTestId("script-run-dialog-overlay"));
expect(onClose).toHaveBeenCalled();
});
it("shows connecting status when not yet connected", async () => {
mockUseTerminal.mockReturnValue({
...defaultUseTerminalReturn,
connectionStatus: "connecting",
});
render(<ScriptRunDialog {...defaultProps} />);
expect(screen.getByText("Connecting...")).toBeTruthy();
});
});

View File

@@ -289,6 +289,120 @@ body {
flex: 1;
}
/* ── Script Run Dialog ─────────────────────────────────────────────────── */
.script-run-dialog {
width: 720px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.script-run-dialog__command {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
font-size: 13px;
overflow: hidden;
}
.script-run-dialog__command-label {
color: var(--text-secondary);
white-space: nowrap;
font-weight: 500;
}
.script-run-dialog__command-text {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.script-run-dialog__output {
flex: 1;
min-height: 300px;
max-height: 60vh;
overflow-y: auto;
background: #1e1e1e;
padding: 12px;
}
.script-run-dialog__output-empty {
display: flex;
align-items: center;
gap: 8px;
color: #888;
font-size: 13px;
padding: 16px 0;
}
.script-run-dialog__output-text {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.script-run-dialog__status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-top: 1px solid var(--border-primary);
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-secondary);
}
.script-run-dialog__status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.script-run-dialog__exit-code {
font-family: var(--font-mono);
font-weight: 600;
}
.script-run-dialog__exit-code.success {
color: var(--status-success, #22c55e);
}
.script-run-dialog__exit-code.error {
color: var(--status-error, #ef4444);
}
@media (max-width: 768px) {
.script-run-dialog {
width: 95vw;
max-height: 85vh;
}
.script-run-dialog__command {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.script-run-dialog__output {
min-height: 200px;
}
}
.btn-icon {
display: flex;
align-items: center;