File size: 7,536 Bytes
b03f016
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
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>
  );
}