feat: add dev server log panel with real-time streaming

Add the ability to view dev server logs in a dedicated panel with:
- Real-time log streaming via WebSocket events
- ANSI color support using xterm.js
- Scrollback buffer (50KB) for log history on reconnect
- Output throttling to prevent UI flooding
- "View Logs" option in worktree dropdown menu

Server changes:
- Add scrollback buffer and event emission to DevServerService
- Add GET /api/worktree/dev-server-logs endpoint
- Add dev-server:started, dev-server:output, dev-server:stopped events

UI changes:
- Add reusable XtermLogViewer component
- Add DevServerLogsPanel dialog component
- Add useDevServerLogs hook for WebSocket subscription

Closes #462

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-13 20:42:57 +01:00
parent 7ef525effa
commit 073f6d5793
17 changed files with 1277 additions and 30 deletions

View File

@@ -0,0 +1,300 @@
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import { useAppStore } from '@/store/app-store';
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
// Types for dynamically imported xterm modules
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
type XFitAddon = InstanceType<typeof import('@xterm/addon-fit').FitAddon>;
export interface XtermLogViewerRef {
/** Append content to the log viewer */
append: (content: string) => void;
/** Clear all content */
clear: () => void;
/** Scroll to the bottom */
scrollToBottom: () => void;
/** Write content (replaces all content) */
write: (content: string) => void;
}
export interface XtermLogViewerProps {
/** Initial content to display */
initialContent?: string;
/** Font size in pixels (default: 13) */
fontSize?: number;
/** Whether to auto-scroll to bottom when new content is added (default: true) */
autoScroll?: boolean;
/** Custom class name for the container */
className?: string;
/** Minimum height for the container */
minHeight?: number;
/** Callback when user scrolls away from bottom */
onScrollAwayFromBottom?: () => void;
/** Callback when user scrolls to bottom */
onScrollToBottom?: () => void;
}
/**
* A read-only terminal log viewer using xterm.js for perfect ANSI color rendering.
* Use this component when you need to display terminal output with ANSI escape codes.
*/
export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>(
(
{
initialContent,
fontSize = 13,
autoScroll = true,
className,
minHeight = 300,
onScrollAwayFromBottom,
onScrollToBottom,
},
ref
) => {
const containerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const [isReady, setIsReady] = useState(false);
const autoScrollRef = useRef(autoScroll);
const pendingContentRef = useRef<string[]>([]);
// Get theme from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
// Track system dark mode for "system" theme
const [systemIsDark, setSystemIsDark] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return true;
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const resolvedTheme =
effectiveTheme === 'system' ? (systemIsDark ? 'dark' : 'light') : effectiveTheme;
// Update autoScroll ref when prop changes
useEffect(() => {
autoScrollRef.current = autoScroll;
}, [autoScroll]);
// Initialize xterm
useEffect(() => {
if (!containerRef.current) return;
let mounted = true;
const initTerminal = async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
await import('@xterm/xterm/css/xterm.css');
if (!mounted || !containerRef.current) return;
const terminalTheme = getTerminalTheme(resolvedTheme);
const terminal = new Terminal({
cursorBlink: false,
cursorStyle: 'underline',
cursorInactiveStyle: 'none',
fontSize,
fontFamily: DEFAULT_TERMINAL_FONT,
lineHeight: 1.2,
theme: terminalTheme,
disableStdin: true, // Read-only mode
scrollback: 10000,
convertEol: true,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
// Try to load WebGL addon for better performance
try {
const { WebglAddon } = await import('@xterm/addon-webgl');
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => webglAddon.dispose());
terminal.loadAddon(webglAddon);
} catch {
// WebGL not available, continue with canvas renderer
}
// Wait for layout to stabilize then fit
requestAnimationFrame(() => {
if (mounted && containerRef.current) {
try {
fitAddon.fit();
} catch {
// Ignore fit errors during initialization
}
}
});
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
setIsReady(true);
// Write initial content if provided
if (initialContent) {
terminal.write(initialContent);
}
// Write any pending content that was queued before terminal was ready
if (pendingContentRef.current.length > 0) {
pendingContentRef.current.forEach((content) => terminal.write(content));
pendingContentRef.current = [];
}
};
initTerminal();
return () => {
mounted = false;
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setIsReady(false);
};
}, []); // Only run once on mount
// Update theme when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
const terminalTheme = getTerminalTheme(resolvedTheme);
xtermRef.current.options.theme = terminalTheme;
}
}, [resolvedTheme, isReady]);
// Update font size when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontSize = fontSize;
fitAddonRef.current?.fit();
}
}, [fontSize, isReady]);
// Handle resize
useEffect(() => {
if (!containerRef.current || !isReady) return;
const handleResize = () => {
if (fitAddonRef.current && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
fitAddonRef.current.fit();
} catch {
// Ignore fit errors
}
}
}
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(containerRef.current);
window.addEventListener('resize', handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}, [isReady]);
// Monitor scroll position
useEffect(() => {
if (!isReady || !containerRef.current) return;
const viewport = containerRef.current.querySelector('.xterm-viewport') as HTMLElement | null;
if (!viewport) return;
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const isAtBottom = scrollHeight - scrollTop - clientHeight <= 10;
if (isAtBottom) {
autoScrollRef.current = true;
onScrollToBottom?.();
} else {
autoScrollRef.current = false;
onScrollAwayFromBottom?.();
}
};
viewport.addEventListener('scroll', checkScroll, { passive: true });
return () => viewport.removeEventListener('scroll', checkScroll);
}, [isReady, onScrollAwayFromBottom, onScrollToBottom]);
// Expose methods via ref
const append = useCallback((content: string) => {
if (xtermRef.current) {
xtermRef.current.write(content);
if (autoScrollRef.current) {
xtermRef.current.scrollToBottom();
}
} else {
// Queue content if terminal isn't ready yet
pendingContentRef.current.push(content);
}
}, []);
const clear = useCallback(() => {
if (xtermRef.current) {
xtermRef.current.clear();
}
}, []);
const scrollToBottom = useCallback(() => {
if (xtermRef.current) {
xtermRef.current.scrollToBottom();
autoScrollRef.current = true;
}
}, []);
const write = useCallback((content: string) => {
if (xtermRef.current) {
xtermRef.current.reset();
xtermRef.current.write(content);
if (autoScrollRef.current) {
xtermRef.current.scrollToBottom();
}
} else {
pendingContentRef.current = [content];
}
}, []);
useImperativeHandle(ref, () => ({
append,
clear,
scrollToBottom,
write,
}));
const terminalTheme = getTerminalTheme(resolvedTheme);
return (
<div
ref={containerRef}
className={className}
style={{
minHeight,
backgroundColor: terminalTheme.background,
}}
/>
);
}
);
XtermLogViewer.displayName = 'XtermLogViewer';

View File

@@ -0,0 +1,289 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Loader2,
Terminal,
ArrowDown,
ExternalLink,
Square,
RefreshCw,
AlertCircle,
Clock,
GitBranch,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
import type { WorktreeInfo } from '../types';
interface DevServerLogsPanelProps {
/** Whether the panel is open */
open: boolean;
/** Callback when the panel is closed */
onClose: () => void;
/** The worktree to show logs for */
worktree: WorktreeInfo | null;
/** Callback to stop the dev server */
onStopDevServer?: (worktree: WorktreeInfo) => void;
/** Callback to open the dev server URL in browser */
onOpenDevServerUrl?: (worktree: WorktreeInfo) => void;
}
/**
* Panel component for displaying dev server logs with ANSI color rendering
* and auto-scroll functionality.
*
* Features:
* - Real-time log streaming via WebSocket
* - Full ANSI color code rendering via xterm.js
* - Auto-scroll to bottom (can be paused by scrolling up)
* - Server status indicators
* - Quick actions (stop server, open in browser)
*/
export function DevServerLogsPanel({
open,
onClose,
worktree,
onStopDevServer,
onOpenDevServerUrl,
}: DevServerLogsPanelProps) {
const xtermRef = useRef<XtermLogViewerRef>(null);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const lastLogsLengthRef = useRef(0);
const lastWorktreePathRef = useRef<string | null>(null);
const {
logs,
isRunning,
isLoading,
error,
port,
url,
startedAt,
exitCode,
serverError,
fetchLogs,
} = useDevServerLogs({
worktreePath: open ? (worktree?.path ?? null) : null,
autoSubscribe: open,
});
// Write logs to xterm when they change
useEffect(() => {
if (!xtermRef.current || !logs) return;
// If worktree changed, reset the terminal and write all content
if (lastWorktreePathRef.current !== worktree?.path) {
lastWorktreePathRef.current = worktree?.path ?? null;
lastLogsLengthRef.current = 0;
xtermRef.current.write(logs);
lastLogsLengthRef.current = logs.length;
return;
}
// If logs got shorter (e.g., cleared), rewrite all
if (logs.length < lastLogsLengthRef.current) {
xtermRef.current.write(logs);
lastLogsLengthRef.current = logs.length;
return;
}
// Append only the new content
if (logs.length > lastLogsLengthRef.current) {
const newContent = logs.slice(lastLogsLengthRef.current);
xtermRef.current.append(newContent);
lastLogsLengthRef.current = logs.length;
}
}, [logs, worktree?.path]);
// Reset when panel opens with a new worktree
useEffect(() => {
if (open) {
setAutoScrollEnabled(true);
if (worktree?.path !== lastWorktreePathRef.current) {
lastLogsLengthRef.current = 0;
}
}
}, [open, worktree?.path]);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
xtermRef.current?.scrollToBottom();
setAutoScrollEnabled(true);
}, []);
// Format the started time
const formatStartedAt = useCallback((timestamp: string | null) => {
if (!timestamp) return null;
try {
const date = new Date(timestamp);
return date.toLocaleTimeString();
} catch {
return null;
}
}, []);
if (!worktree) return null;
const formattedStartTime = formatStartedAt(startedAt);
const lineCount = logs ? logs.split('\n').length : 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
data-testid="dev-server-logs-panel"
>
{/* Compact Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<Terminal className="w-4 h-4 text-primary" />
<span>Dev Server</span>
{isRunning ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-500/10 text-green-500 text-xs font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
Running
</span>
) : exitCode !== null ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-red-500/10 text-red-500 text-xs font-medium">
<AlertCircle className="w-3 h-3" />
Stopped ({exitCode})
</span>
) : null}
</DialogTitle>
<div className="flex items-center gap-1.5">
{isRunning && url && onOpenDevServerUrl && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => onOpenDevServerUrl(worktree)}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Open
</Button>
)}
{isRunning && onStopDevServer && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onStopDevServer(worktree)}
>
<Square className="w-3 h-3 mr-1.5 fill-current" />
Stop
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => fetchLogs()}
title="Refresh logs"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</div>
</div>
{/* Info bar - more compact */}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
<span className="font-medium text-foreground/80">{worktree.branch}</span>
</span>
{port && (
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground/60">Port</span>
<span className="font-mono text-primary">{port}</span>
</span>
)}
{formattedStartTime && (
<span className="inline-flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formattedStartTime}
</span>
)}
</div>
</DialogHeader>
{/* Error displays - inline */}
{(error || serverError) && (
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
{error && (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
{serverError && (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>Server error: {serverError}</span>
</div>
)}
</div>
)}
{/* Log content area - fills remaining space */}
<div
className="flex-1 min-h-0 overflow-hidden bg-zinc-950"
data-testid="dev-server-logs-content"
>
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<Terminal className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm">No dev server running</p>
<p className="text-xs mt-1 opacity-60">Start a dev server to see logs here</p>
</div>
) : !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
<p className="text-sm">Waiting for output...</p>
<p className="text-xs mt-1 opacity-60">
Logs will appear as the server generates output
</p>
</div>
) : (
<XtermLogViewer
ref={xtermRef}
className="h-full"
minHeight={280}
fontSize={13}
autoScroll={autoScrollEnabled}
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
onScrollToBottom={() => setAutoScrollEnabled(true)}
/>
)}
</div>
{/* Footer status bar */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
{!autoScrollEnabled && logs && (
<button
onClick={scrollToBottom}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
>
<ArrowDown className="w-3 h-3" />
Scroll to bottom
</button>
)}
{autoScrollEnabled && logs && (
<span className="inline-flex items-center gap-1.5 opacity-60">
<ArrowDown className="w-3 h-3" />
Auto-scroll
</span>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,3 +1,4 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { DevServerLogsPanel } from './dev-server-logs-panel';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -25,6 +25,7 @@ import {
AlertCircle,
RefreshCw,
Copy,
ScrollText,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -56,6 +57,7 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -83,6 +85,7 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
hasInitScript,
}: WorktreeActionsDropdownProps) {
@@ -147,6 +150,10 @@ export function WorktreeActionsDropdown({
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Logs
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"

View File

@@ -1,4 +1,4 @@
// @ts-nocheck
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -45,6 +45,7 @@ interface WorktreeTabProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -87,6 +88,7 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
hasInitScript,
}: WorktreeTabProps) {
@@ -337,6 +339,7 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
hasInitScript={hasInitScript}
/>

View File

@@ -1,5 +1,6 @@
export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers';
export { useDevServerLogs } from './use-dev-server-logs';
export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions';
export { useRunningFeatures } from './use-running-features';

View File

@@ -0,0 +1,221 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { pathsEqual } from '@/lib/utils';
const logger = createLogger('DevServerLogs');
export interface DevServerLogState {
/** The log content (buffered + live) */
logs: string;
/** Whether the server is currently running */
isRunning: boolean;
/** Whether initial logs are being fetched */
isLoading: boolean;
/** Error message if fetching logs failed */
error: string | null;
/** Server port (if running) */
port: number | null;
/** Server URL (if running) */
url: string | null;
/** Timestamp when the server started */
startedAt: string | null;
/** Exit code (if server stopped) */
exitCode: number | null;
/** Error message from server (if stopped with error) */
serverError: string | null;
}
interface UseDevServerLogsOptions {
/** Path to the worktree to monitor logs for */
worktreePath: string | null;
/** Whether to automatically subscribe to log events (default: true) */
autoSubscribe?: boolean;
}
/**
* Hook to subscribe to dev server log events and manage log state.
*
* This hook:
* 1. Fetches initial buffered logs from the server
* 2. Subscribes to WebSocket events for real-time log streaming
* 3. Handles server started/stopped events
* 4. Provides log state for rendering in a panel
*
* @example
* ```tsx
* const { logs, isRunning, isLoading } = useDevServerLogs({
* worktreePath: '/path/to/worktree'
* });
* ```
*/
export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevServerLogsOptions) {
const [state, setState] = useState<DevServerLogState>({
logs: '',
isRunning: false,
isLoading: false,
error: null,
port: null,
url: null,
startedAt: null,
exitCode: null,
serverError: null,
});
// Keep track of whether we've fetched initial logs
const hasFetchedInitialLogs = useRef(false);
/**
* Fetch buffered logs from the server
*/
const fetchLogs = useCallback(async () => {
if (!worktreePath) return;
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const api = getElectronAPI();
if (!api?.worktree?.getDevServerLogs) {
setState((prev) => ({
...prev,
isLoading: false,
error: 'Dev server logs API not available',
}));
return;
}
const result = await api.worktree.getDevServerLogs(worktreePath);
if (result.success && result.result) {
setState((prev) => ({
...prev,
logs: result.result!.logs,
isRunning: true,
isLoading: false,
port: result.result!.port,
url: `http://localhost:${result.result!.port}`,
startedAt: result.result!.startedAt,
error: null,
}));
hasFetchedInitialLogs.current = true;
} else {
// Server might not be running - this is not necessarily an error
setState((prev) => ({
...prev,
isLoading: false,
isRunning: false,
error: result.error || null,
}));
}
} catch (error) {
logger.error('Failed to fetch dev server logs:', error);
setState((prev) => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch logs',
}));
}
}, [worktreePath]);
/**
* Clear logs and reset state
*/
const clearLogs = useCallback(() => {
setState({
logs: '',
isRunning: false,
isLoading: false,
error: null,
port: null,
url: null,
startedAt: null,
exitCode: null,
serverError: null,
});
hasFetchedInitialLogs.current = false;
}, []);
/**
* Append content to logs
*/
const appendLogs = useCallback((content: string) => {
setState((prev) => ({
...prev,
logs: prev.logs + content,
}));
}, []);
// Fetch initial logs when worktreePath changes
useEffect(() => {
if (worktreePath && autoSubscribe) {
hasFetchedInitialLogs.current = false;
fetchLogs();
} else {
clearLogs();
}
}, [worktreePath, autoSubscribe, fetchLogs, clearLogs]);
// Subscribe to WebSocket events
useEffect(() => {
if (!worktreePath || !autoSubscribe) return;
const api = getElectronAPI();
if (!api?.worktree?.onDevServerLogEvent) {
logger.warn('Dev server log event API not available');
return;
}
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
// Filter events to only handle those for our worktree
if (!pathsEqual(event.payload.worktreePath, worktreePath)) return;
switch (event.type) {
case 'dev-server:started': {
const { payload } = event;
logger.info('Dev server started:', payload);
setState((prev) => ({
...prev,
isRunning: true,
port: payload.port,
url: payload.url,
startedAt: payload.timestamp,
exitCode: null,
serverError: null,
// Clear logs on restart
logs: '',
}));
hasFetchedInitialLogs.current = false;
break;
}
case 'dev-server:output': {
const { payload } = event;
// Append the new output to existing logs
if (payload.content) {
appendLogs(payload.content);
}
break;
}
case 'dev-server:stopped': {
const { payload } = event;
logger.info('Dev server stopped:', payload);
setState((prev) => ({
...prev,
isRunning: false,
exitCode: payload.exitCode,
serverError: payload.error ?? null,
}));
break;
}
}
});
return unsubscribe;
}, [worktreePath, autoSubscribe, appendLogs]);
return {
...state,
fetchLogs,
clearLogs,
appendLogs,
};
}

View File

@@ -12,7 +12,7 @@ import {
useWorktreeActions,
useRunningFeatures,
} from './hooks';
import { WorktreeTab } from './components';
import { WorktreeTab, DevServerLogsPanel } from './components';
export function WorktreePanel({
projectPath,
@@ -84,6 +84,10 @@ export function WorktreePanel({
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
// Log panel state management
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
@@ -164,6 +168,18 @@ export function WorktreePanel({
[projectPath]
);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
setLogPanelOpen(true);
}, []);
// Handle closing the log panel
const handleCloseLogPanel = useCallback(() => {
setLogPanelOpen(false);
// Keep logPanelWorktree set for smooth close animation
}, []);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -213,6 +229,7 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
@@ -269,6 +286,7 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
@@ -303,6 +321,15 @@ export function WorktreePanel({
</div>
</>
)}
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
onClose={handleCloseLogPanel}
worktree={logPanelWorktree}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</div>
);
}