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
90 changes: 89 additions & 1 deletion src/renderer/components/TaskTerminalPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -28,6 +28,7 @@ import {
formatLifecycleLogLine,
} from '@shared/lifecycle';
import { shouldDisablePlay } from '../lib/lifecycleUi';
import { normalizeUrl } from '@shared/urls';

interface Task {
id: string;
Expand Down Expand Up @@ -64,6 +65,9 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
const { effectiveTheme } = useTheme();
const { toast } = useToast();

const [detectedUrls, setDetectedUrls] = useState<string[]>([]);
const [selectedUrl, setSelectedUrl] = useState<string | null>(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);
Expand Down Expand Up @@ -100,6 +104,42 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
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<LifecyclePhaseStatus>('idle');
const [setupStatus, setSetupStatus] = useState<LifecyclePhaseStatus>('idle');
const [teardownStatus, setTeardownStatus] = useState<LifecyclePhaseStatus>('idle');
Expand Down Expand Up @@ -637,6 +677,54 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
</TooltipProvider>
)}

{task && detectedUrls.length > 0 && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center">
{detectedUrls.length > 1 ? (
<Select
value={selectedUrl || ''}
onValueChange={(value) => {
setSelectedUrl(value);
window.electronAPI.openExternal(value);
}}
>
<SelectTrigger className="h-7 w-auto min-w-[40px] gap-2 border-none bg-transparent px-2 text-xs shadow-none hover:bg-accent">
<Globe className="h-3.5 w-3.5" />
<SelectValue placeholder="Open URL" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{detectedUrls.map((url) => (
<SelectItem key={url} value={url} className="text-xs">
{url.replace(/^https?:\/\/localhost:/, 'port: ')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => selectedUrl && window.electronAPI.openExternal(selectedUrl)}
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{selectedUrl ? `Open ${selectedUrl} in browser` : 'No URL detected yet'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}

{(() => {
const canDelete =
selection.parsed?.mode === 'task'
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down
12 changes: 12 additions & 0 deletions src/shared/urls.ts
Original file line number Diff line number Diff line change
@@ -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 '';
}
}
Loading