diff --git a/src/renderer/components/TaskTerminalPanel.tsx b/src/renderer/components/TaskTerminalPanel.tsx index d8f6b3219..28d787aa7 100644 --- a/src/renderer/components/TaskTerminalPanel.tsx +++ b/src/renderer/components/TaskTerminalPanel.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { TerminalPane } from './TerminalPane'; -import { Plus, Play, RotateCw, Square, X } from 'lucide-react'; +import { Plus, Play, RotateCw, Square, X, ExternalLink, Globe } from 'lucide-react'; import { useTheme } from '../hooks/useTheme'; import { useTaskTerminals } from '@/lib/taskTerminalsStore'; import { useTerminalSelection } from '../hooks/useTerminalSelection'; @@ -28,6 +28,7 @@ import { formatLifecycleLogLine, } from '@shared/lifecycle'; import { shouldDisablePlay } from '../lib/lifecycleUi'; +import { normalizeUrl } from '@shared/urls'; interface Task { id: string; @@ -64,6 +65,9 @@ const TaskTerminalPanelComponent: React.FC = ({ const { effectiveTheme } = useTheme(); const { toast } = useToast(); + const [detectedUrls, setDetectedUrls] = useState([]); + const [selectedUrl, setSelectedUrl] = useState(null); + // Use path in the key to differentiate multi-agent variants that share the same task.id const taskKey = task ? `${task.id}::${task.path}` : 'task-placeholder'; const taskTerminals = useTaskTerminals(taskKey, task?.path); @@ -100,6 +104,42 @@ const TaskTerminalPanelComponent: React.FC = ({ return () => clearTimeout(timer); }, [selection.activeTerminalId]); + useEffect(() => { + // Reset URLs when task changes + setDetectedUrls([]); + setSelectedUrl(null); + + if (!task?.id) return; + + const off = window.electronAPI?.onLifecycleEvent?.((data: any) => { + if (data?.taskId !== task.id) return; + + // Extract URL from run line output + if (data?.phase === 'run' && data?.status === 'line' && data?.line) { + const url = normalizeUrl(data.line); + if (url) { + setDetectedUrls((prev) => { + if (prev.includes(url)) return prev; + return [...prev, url]; + }); + if (!selectedUrl) { + setSelectedUrl(url); + } + } + } + + // Reset URLs when run exits + if (data?.phase === 'run' && (data?.status === 'done' || data?.status === 'error')) { + setDetectedUrls([]); + setSelectedUrl(null); + } + }); + + return () => { + off?.(); + }; + }, [task?.id]); + const [runStatus, setRunStatus] = useState('idle'); const [setupStatus, setSetupStatus] = useState('idle'); const [teardownStatus, setTeardownStatus] = useState('idle'); @@ -637,6 +677,54 @@ const TaskTerminalPanelComponent: React.FC = ({ )} + {task && detectedUrls.length > 0 && ( + + + + + {detectedUrls.length > 1 ? ( + + ) : ( + + )} + + + +

+ {selectedUrl ? `Open ${selectedUrl} in browser` : 'No URL detected yet'} +

+
+
+
+ )} + {(() => { const canDelete = selection.parsed?.mode === 'task' diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 81550d56d..5e160c78a 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -622,6 +622,14 @@ declare global { branches?: Array<{ ref: string; remote: string; branch: string; label: string }>; error?: string; }>; + onHostPreviewEvent: ( + listener: (data: { + type: 'url' | 'setup' | 'exit'; + taskId: string; + url?: string; + status?: string; + }) => void + ) => () => void; openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; clipboardWriteText: (text: string) => Promise<{ success: boolean; error?: string }>; paste: () => Promise<{ success: boolean; error?: string }>; diff --git a/src/shared/urls.ts b/src/shared/urls.ts index c4ac05a4e..b967ceb26 100644 --- a/src/shared/urls.ts +++ b/src/shared/urls.ts @@ -1,2 +1,14 @@ export const EMDASH_RELEASES_URL = 'https://github.com/generalaction/emdash/releases'; export const EMDASH_DOCS_URL = 'https://docs.emdash.sh'; +export function normalizeUrl(u: string): string { + try { + const re = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):\d{2,5}(?:\/\S*)?)/i; + const m = u.match(re); + if (!m) return ''; + const url = new URL(m[1]); + url.hostname = 'localhost'; + return url.toString(); + } catch { + return ''; + } +}