File size: 8,078 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | import { useEffect, useState } from 'react'
import { useTaskStore } from '../../stores/taskStore'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { parseRunOutput } from '../../lib/parseRunOutput'
import type { TaskRun } from '../../types/task'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
function RunOutput({ run }: { run: TaskRun }) {
const t = useTranslation()
// Show error prominently if present
if (run.error) {
return (
<div className="mt-2 max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-sm)] border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 p-2.5 text-xs text-[var(--color-error)]">
{run.error}
</div>
)
}
const text = parseRunOutput(run.output || '')
if (!text) {
return (
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-tertiary)] italic">
{run.sessionId ? t('tasks.outputHintSession') : t('tasks.noOutputText')}
</div>
)
}
return (
<div className="mt-2 max-h-48 overflow-y-auto rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] p-2.5">
<MarkdownRenderer
content={text}
variant="compact"
className="break-words"
/>
</div>
)
}
type Props = {
taskId: string
onClose: () => void
refreshKey?: number
}
const STATUS_CONFIG: Record<string, { icon: string; color: string }> = {
running: { icon: 'sync', color: 'var(--color-warning)' },
completed: { icon: 'check_circle', color: 'var(--color-success)' },
failed: { icon: 'error', color: 'var(--color-error)' },
timeout: { icon: 'timer_off', color: 'var(--color-error)' },
}
export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
const t = useTranslation()
const { fetchTaskRuns } = useTaskStore()
const connectToSession = useChatStore((s) => s.connectToSession)
const openTab = useTabStore((s) => s.openTab)
const [runs, setRuns] = useState<TaskRun[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const openSession = (sessionId: string, taskName?: string) => {
openTab(sessionId, taskName || 'Task Run')
connectToSession(sessionId)
}
const refresh = () => {
fetchTaskRuns(taskId).then((r) => {
setRuns(r)
setLoading(false)
}).catch(() => setLoading(false))
}
// Initial fetch + re-fetch when refreshKey changes
useEffect(() => {
setLoading(true)
refresh()
}, [taskId, fetchTaskRuns, refreshKey])
// Auto-poll while any run is "running" or shortly after a manual trigger.
// Uses faster 1s polling for the first 10s after refreshKey changes, then 3s.
const hasRunning = runs.some((r) => r.status === 'running')
useEffect(() => {
if (!hasRunning && refreshKey === 0) return // no reason to poll initially
// Start with fast polling (1s) to give snappy feedback after "Run Now"
let interval = 1000
let timer = setInterval(refresh, interval)
// After 10s, switch to slower 3s polling if still running
const slowDown = setTimeout(() => {
clearInterval(timer)
if (hasRunning) {
timer = setInterval(refresh, 3000)
}
}, 10000)
// If nothing is running and initial window passes, stop entirely
const stopTimer = hasRunning ? undefined : setTimeout(() => clearInterval(timer), 12000)
return () => {
clearInterval(timer)
clearTimeout(slowDown)
if (stopTimer) clearTimeout(stopTimer)
}
}, [hasRunning, taskId, refreshKey])
return (
<div className="mt-2 mb-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-surface-container)]">
<span className="text-xs font-medium text-[var(--color-text-primary)]">{t('tasks.logsTitle')}</span>
<button
onClick={onClose}
className="p-0.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
{/* Content */}
<div className="max-h-64 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin w-4 h-4 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : runs.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
{t('tasks.noLogs')}
</div>
) : (
<div className="divide-y divide-[var(--color-border-separator)]">
{runs.map((run) => {
const cfg = STATUS_CONFIG[run.status] || STATUS_CONFIG.failed!
const isExpanded = expandedId === run.id
return (
<div key={run.id} className="px-4 py-2.5">
<div className="flex items-center gap-3">
{/* Status icon */}
<span
className={`material-symbols-outlined text-[16px] ${run.status === 'running' ? 'animate-spin' : ''}`}
style={{ color: cfg.color, fontVariationSettings: "'FILL' 1" }}
>
{cfg.icon}
</span>
{/* Status text */}
<span className="text-xs font-medium" style={{ color: cfg.color }}>
{t(`tasks.runStatus.${run.status}` as any)} {/* dynamic key */}
</span>
{/* Time */}
<span className="text-xs text-[var(--color-text-tertiary)]">
{new Date(run.startedAt).toLocaleString()}
</span>
{/* Duration */}
{run.durationMs != null && (
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('tasks.duration', { s: Math.round(run.durationMs / 1000) })}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{/* Open session — only after run completes (session is empty while running) */}
{run.sessionId && run.status !== 'running' && (
<button
onClick={() => openSession(run.sessionId!, run.taskName)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-[var(--color-brand)] bg-[var(--color-brand)]/8 hover:bg-[var(--color-brand)]/15 rounded-[var(--radius-sm)] transition-colors"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t('tasks.openSession')}
</button>
)}
{/* Summary toggle */}
{(run.output || run.error) && (
<button
onClick={() => setExpandedId(isExpanded ? null : run.id)}
className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
{isExpanded ? t('tasks.hideOutput') : t('tasks.viewOutput')}
</button>
)}
</div>
</div>
{/* Expanded output */}
{isExpanded && (
<RunOutput run={run} />
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}
|