timchen0618
Deploy research dashboard
b03f016
import { useState, useMemo } from "react";
import type { ActivityLogEntry, ActivityEntryType } from "../types";
interface TimelineTabProps {
entries: ActivityLogEntry[];
onArtifactClick: (datasetName: string) => void;
}
// Deterministic color hash for run-scoped labels
function scopeColor(scope: string): string {
if (scope === "debug") return "bg-gray-700 text-gray-300";
if (scope === "cross-run") return "bg-purple-900/60 text-purple-300";
if (scope === "meta") return "bg-blue-900/60 text-blue-300";
// Deterministic hash for other scopes (run labels etc.)
let hash = 0;
for (let i = 0; i < scope.length; i++) {
hash = (hash * 31 + scope.charCodeAt(i)) & 0xffff;
}
const palette = [
"bg-emerald-900/60 text-emerald-300",
"bg-amber-900/60 text-amber-300",
"bg-rose-900/60 text-rose-300",
"bg-teal-900/60 text-teal-300",
"bg-indigo-900/60 text-indigo-300",
"bg-fuchsia-900/60 text-fuchsia-300",
"bg-orange-900/60 text-orange-300",
"bg-lime-900/60 text-lime-300",
];
return palette[hash % palette.length];
}
const TYPE_ICONS: Record<ActivityEntryType, string> = {
action: "▶",
result: "◆",
note: "✎",
milestone: "⚑",
};
const TYPE_LABELS: ActivityEntryType[] = ["action", "result", "note", "milestone"];
function relativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
if (isNaN(diffMs)) return iso;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDays = Math.floor(diffHr / 24);
return `${diffDays}d ago`;
}
export default function TimelineTab({ entries, onArtifactClick }: TimelineTabProps) {
const [scopeFilter, setScopeFilter] = useState<string>("all");
const [activeTypes, setActiveTypes] = useState<Set<ActivityEntryType>>(
new Set(TYPE_LABELS)
);
// Sorted most-recent-first (ensure stable order even if backend varies)
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
),
[entries]
);
const uniqueScopes = useMemo(() => {
const set = new Set<string>();
entries.forEach((e) => set.add(e.scope));
return Array.from(set).sort();
}, [entries]);
const filtered = useMemo(
() =>
sorted.filter(
(e) =>
(scopeFilter === "all" || e.scope === scopeFilter) &&
activeTypes.has(e.type)
),
[sorted, scopeFilter, activeTypes]
);
function toggleType(t: ActivityEntryType) {
setActiveTypes((prev) => {
const next = new Set(prev);
if (next.has(t)) {
// Keep at least one type active
if (next.size > 1) next.delete(t);
} else {
next.add(t);
}
return next;
});
}
if (entries.length === 0) {
return (
<div className="flex items-center justify-center h-40 text-sm text-gray-500 italic">
No activity log entries yet.
</div>
);
}
return (
<div className="space-y-4">
{/* Filter controls */}
<div className="flex flex-wrap items-center gap-3">
{/* Scope dropdown */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 uppercase tracking-wide">Scope</span>
<select
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value)}
className="bg-gray-800 text-gray-300 text-xs rounded px-2 py-1 border border-gray-700 outline-none focus:border-cyan-500"
>
<option value="all">All</option>
{uniqueScopes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Type filter chips */}
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500 uppercase tracking-wide mr-1">Type</span>
{TYPE_LABELS.map((t) => (
<button
key={t}
onClick={() => toggleType(t)}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
activeTypes.has(t)
? "bg-cyan-900/50 border-cyan-700 text-cyan-300"
: "bg-gray-800 border-gray-700 text-gray-500 hover:text-gray-400"
}`}
>
{TYPE_ICONS[t]} {t}
</button>
))}
</div>
<span className="text-xs text-gray-600 ml-auto flex items-center gap-2">
<span className="text-cyan-600">LLM-generated log</span>
<span>{filtered.length} of {entries.length}</span>
</span>
</div>
{/* Entries */}
{filtered.length === 0 ? (
<div className="text-sm text-gray-500 italic py-4">
No entries match the current filters.
</div>
) : (
<div className="space-y-0 border-l-2 border-gray-800 pl-4">
{filtered.map((entry, i) => (
<div
key={i}
className="relative py-3 border-b border-gray-800/50 last:border-0"
>
{/* Timeline dot */}
<div className="absolute -left-[17px] top-4 w-2 h-2 rounded-full bg-gray-600" />
<div className="flex flex-wrap items-start gap-2">
{/* Timestamp */}
<span
title={new Date(entry.timestamp).toISOString()}
className="text-xs text-gray-600 shrink-0 mt-0.5 cursor-default"
>
{relativeTime(entry.timestamp)}
</span>
{/* Scope badge */}
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${scopeColor(
entry.scope
)}`}
>
{entry.scope}
</span>
{/* Type icon + label */}
<span className="text-xs text-gray-500 shrink-0 mt-0.5">
{TYPE_ICONS[entry.type]}
</span>
{/* Author */}
<span
className={`text-xs shrink-0 mt-0.5 font-medium ${
entry.author === "agent"
? "text-cyan-500"
: "text-amber-400"
}`}
>
{entry.author === "agent" ? "Claude Code" : "Researcher"}
</span>
</div>
{/* Message */}
<p className="text-sm text-gray-300 mt-1">{entry.message}</p>
{/* Artifact chips */}
{entry.artifacts && entry.artifacts.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{entry.artifacts.map((ds) => (
<button
key={ds}
onClick={() => onArtifactClick(ds)}
className="text-xs px-2 py-0.5 rounded-full bg-cyan-900/40 border border-cyan-800/60 text-cyan-400 hover:bg-cyan-900/70 hover:text-cyan-300 transition-colors"
>
{ds}
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}