dev3000 0.0.49 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +55 -6
  2. package/dist/cdp-monitor.d.ts.map +1 -1
  3. package/dist/cdp-monitor.js +54 -48
  4. package/dist/cdp-monitor.js.map +1 -1
  5. package/dist/cli.js +39 -33
  6. package/dist/cli.js.map +1 -1
  7. package/dist/dev-environment.d.ts +2 -0
  8. package/dist/dev-environment.d.ts.map +1 -1
  9. package/dist/dev-environment.js +212 -181
  10. package/dist/dev-environment.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/mcp-server/app/api/config/route.ts +7 -7
  16. package/mcp-server/app/api/logs/append/route.ts +59 -51
  17. package/mcp-server/app/api/logs/head/route.ts +22 -22
  18. package/mcp-server/app/api/logs/list/route.ts +39 -42
  19. package/mcp-server/app/api/logs/rotate/route.ts +28 -38
  20. package/mcp-server/app/api/logs/stream/route.ts +35 -35
  21. package/mcp-server/app/api/logs/tail/route.ts +22 -22
  22. package/mcp-server/app/api/mcp/[transport]/route.ts +189 -188
  23. package/mcp-server/app/api/replay/route.ts +217 -202
  24. package/mcp-server/app/layout.tsx +9 -8
  25. package/mcp-server/app/logs/LogsClient.test.ts +123 -99
  26. package/mcp-server/app/logs/LogsClient.tsx +724 -562
  27. package/mcp-server/app/logs/page.tsx +71 -72
  28. package/mcp-server/app/logs/utils.ts +99 -28
  29. package/mcp-server/app/page.tsx +10 -14
  30. package/mcp-server/app/replay/ReplayClient.tsx +120 -119
  31. package/mcp-server/app/replay/page.tsx +3 -3
  32. package/mcp-server/next.config.ts +2 -0
  33. package/mcp-server/package.json +5 -2
  34. package/mcp-server/pnpm-lock.yaml +37 -5
  35. package/mcp-server/tsconfig.json +4 -17
  36. package/package.json +16 -13
@@ -1,87 +1,97 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useRef, useMemo } from "react";
4
- import { useRouter, useSearchParams } from "next/navigation";
5
- import {
6
- LogEntry,
7
- LogsApiResponse,
8
- ConfigApiResponse,
9
- LogFile,
10
- LogListResponse,
11
- } from "@/types";
12
- import { parseLogEntries } from "./utils";
1
+ "use client"
2
+
3
+ import Image from "next/image"
4
+ import { useRouter, useSearchParams } from "next/navigation"
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
6
+ import type { LogEntry, LogFile, LogListResponse, LogsApiResponse } from "@/types"
7
+
8
+ // Define interfaces for object property rendering
9
+ interface PropertyData {
10
+ name: string
11
+ value: string | number | boolean | null
12
+ type: string
13
+ subtype?: string
14
+ }
15
+
16
+ interface ReplayEvent {
17
+ timestamp: string
18
+ event: string
19
+ details: string
20
+ type?: string
21
+ x?: number
22
+ y?: number
23
+ target?: string
24
+ direction?: string
25
+ distance?: number
26
+ key?: string
27
+ }
28
+ import { parseLogEntries } from "./utils"
13
29
 
14
30
  // Hook for dark mode with system preference detection
15
31
  function useDarkMode() {
16
32
  const [darkMode, setDarkMode] = useState<boolean>(() => {
17
33
  if (typeof window !== "undefined") {
18
34
  // Check localStorage first
19
- const saved = localStorage.getItem("dev3000-dark-mode");
35
+ const saved = localStorage.getItem("dev3000-dark-mode")
20
36
  if (saved !== null) {
21
- return JSON.parse(saved);
37
+ return JSON.parse(saved)
22
38
  }
23
39
  // Default to system preference
24
- return window.matchMedia("(prefers-color-scheme: dark)").matches;
40
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
25
41
  }
26
- return false;
27
- });
42
+ return false
43
+ })
28
44
 
29
45
  useEffect(() => {
30
46
  // Save to localStorage
31
- localStorage.setItem("dev3000-dark-mode", JSON.stringify(darkMode));
47
+ localStorage.setItem("dev3000-dark-mode", JSON.stringify(darkMode))
32
48
 
33
49
  // Apply dark class to document
34
50
  if (darkMode) {
35
- document.documentElement.classList.add("dark");
51
+ document.documentElement.classList.add("dark")
36
52
  } else {
37
- document.documentElement.classList.remove("dark");
53
+ document.documentElement.classList.remove("dark")
38
54
  }
39
- }, [darkMode]);
55
+ }, [darkMode])
40
56
 
41
57
  // Listen for system theme changes
42
58
  useEffect(() => {
43
- const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
59
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
44
60
  const handler = (e: MediaQueryListEvent) => {
45
61
  // Only update if no explicit choice has been made
46
- const saved = localStorage.getItem("dev3000-dark-mode");
62
+ const saved = localStorage.getItem("dev3000-dark-mode")
47
63
  if (saved === null) {
48
- setDarkMode(e.matches);
64
+ setDarkMode(e.matches)
49
65
  }
50
- };
66
+ }
51
67
 
52
- mediaQuery.addEventListener("change", handler);
53
- return () => mediaQuery.removeEventListener("change", handler);
54
- }, []);
68
+ mediaQuery.addEventListener("change", handler)
69
+ return () => mediaQuery.removeEventListener("change", handler)
70
+ }, [])
55
71
 
56
- return [darkMode, setDarkMode] as const;
72
+ return [darkMode, setDarkMode] as const
57
73
  }
58
74
 
59
75
  // Keep this for backwards compatibility, but it's not used anymore
60
- function parseLogLine(line: string): LogEntry | null {
61
- const match = line.match(/^\[([^\]]+)\] \[([^\]]+)\] (.*)$/s);
62
- if (!match) return null;
76
+ function _parseLogLine(line: string): LogEntry | null {
77
+ const match = line.match(/^\[([^\]]+)\] \[([^\]]+)\] (.*)$/s)
78
+ if (!match) return null
63
79
 
64
- const [, timestamp, source, message] = match;
65
- const screenshot = message.match(/\[SCREENSHOT\] (.+)/)?.[1];
80
+ const [, timestamp, source, message] = match
81
+ const screenshot = message.match(/\[SCREENSHOT\] (.+)/)?.[1]
66
82
 
67
83
  return {
68
84
  timestamp,
69
85
  source,
70
86
  message,
71
87
  screenshot,
72
- original: line,
73
- };
88
+ original: line
89
+ }
74
90
  }
75
91
 
76
92
  // Component to render truncated URLs with click-to-expand
77
- function URLRenderer({
78
- url,
79
- maxLength = 60,
80
- }: {
81
- url: string;
82
- maxLength?: number;
83
- }) {
84
- const [isExpanded, setIsExpanded] = useState(false);
93
+ function URLRenderer({ url, maxLength = 60 }: { url: string; maxLength?: number }) {
94
+ const [isExpanded, setIsExpanded] = useState(false)
85
95
 
86
96
  if (url.length <= maxLength) {
87
97
  return (
@@ -93,10 +103,10 @@ function URLRenderer({
93
103
  >
94
104
  {url}
95
105
  </a>
96
- );
106
+ )
97
107
  }
98
108
 
99
- const truncated = url.substring(0, maxLength) + "...";
109
+ const truncated = `${url.substring(0, maxLength)}...`
100
110
 
101
111
  return (
102
112
  <span className="inline-block">
@@ -111,6 +121,7 @@ function URLRenderer({
111
121
  {url}
112
122
  </a>
113
123
  <button
124
+ type="button"
114
125
  onClick={() => setIsExpanded(false)}
115
126
  className="ml-2 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
116
127
  >
@@ -128,6 +139,7 @@ function URLRenderer({
128
139
  {truncated}
129
140
  </a>
130
141
  <button
142
+ type="button"
131
143
  onClick={() => setIsExpanded(true)}
132
144
  className="ml-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
133
145
  >
@@ -136,37 +148,31 @@ function URLRenderer({
136
148
  </span>
137
149
  )}
138
150
  </span>
139
- );
151
+ )
140
152
  }
141
153
 
142
154
  // Component to render Chrome DevTools-style collapsible objects
143
155
  function ObjectRenderer({ content }: { content: string }) {
144
- const [isExpanded, setIsExpanded] = useState(false);
156
+ const [isExpanded, setIsExpanded] = useState(false)
145
157
 
146
158
  try {
147
- const obj = JSON.parse(content);
159
+ const obj = JSON.parse(content)
148
160
 
149
161
  // Check if it's a Chrome DevTools object representation
150
- if (
151
- obj &&
152
- typeof obj === "object" &&
153
- obj.type === "object" &&
154
- obj.properties
155
- ) {
156
- const properties = obj.properties;
157
- const description = obj.description || "Object";
158
- const overflow = obj.overflow;
162
+ if (obj && typeof obj === "object" && obj.type === "object" && obj.properties) {
163
+ const properties = obj.properties
164
+ const description = obj.description || "Object"
165
+ const overflow = obj.overflow
159
166
 
160
167
  return (
161
168
  <div className="inline-block">
162
169
  <button
170
+ type="button"
163
171
  onClick={() => setIsExpanded(!isExpanded)}
164
172
  className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-mono text-sm"
165
173
  >
166
174
  <svg
167
- className={`w-3 h-3 transition-transform ${
168
- isExpanded ? "rotate-90" : ""
169
- }`}
175
+ className={`w-3 h-3 transition-transform ${isExpanded ? "rotate-90" : ""}`}
170
176
  fill="currentColor"
171
177
  viewBox="0 0 20 20"
172
178
  >
@@ -180,20 +186,20 @@ function ObjectRenderer({ content }: { content: string }) {
180
186
  {!isExpanded && (
181
187
  <span className="text-gray-500">
182
188
  {overflow ? "..." : ""} {"{"}
183
- {properties.slice(0, 3).map((prop: any, idx: number) => (
184
- <span key={idx}>
189
+ {properties.slice(0, 3).map((prop: PropertyData, idx: number) => (
190
+ <span key={`${prop.name}-${idx}`}>
185
191
  {idx > 0 && ", "}
186
192
  <span className="text-red-600">{prop.name}</span>:
187
193
  <span className="text-blue-600">
188
194
  {prop.type === "string"
189
195
  ? `"${prop.value}"`
190
196
  : prop.type === "number"
191
- ? prop.value
192
- : prop.type === "object"
193
- ? prop.subtype === "array"
194
197
  ? prop.value
195
- : "{...}"
196
- : prop.value}
198
+ : prop.type === "object"
199
+ ? prop.subtype === "array"
200
+ ? prop.value
201
+ : "{...}"
202
+ : prop.value}
197
203
  </span>
198
204
  </span>
199
205
  ))}
@@ -209,8 +215,8 @@ function ObjectRenderer({ content }: { content: string }) {
209
215
  <div className="text-gray-600">
210
216
  {description} {"{"}
211
217
  <div className="ml-4">
212
- {properties.map((prop: any, idx: number) => (
213
- <div key={idx} className="py-0.5">
218
+ {properties.map((prop: PropertyData, idx: number) => (
219
+ <div key={`${prop.name}-${idx}`} className="py-0.5">
214
220
  <span className="text-red-600">{prop.name}</span>
215
221
  <span className="text-gray-500">: </span>
216
222
  <span
@@ -218,32 +224,26 @@ function ObjectRenderer({ content }: { content: string }) {
218
224
  prop.type === "string"
219
225
  ? "text-green-600"
220
226
  : prop.type === "number"
221
- ? "text-blue-600"
222
- : prop.type === "object"
223
- ? "text-purple-600"
224
- : "text-orange-600"
227
+ ? "text-blue-600"
228
+ : prop.type === "object"
229
+ ? "text-purple-600"
230
+ : "text-orange-600"
225
231
  }
226
232
  >
227
233
  {prop.type === "string"
228
- ? `"${prop.value}"`
234
+ ? `"${String(prop.value)}"`
229
235
  : prop.type === "number"
230
- ? prop.value
231
- : prop.type === "object"
232
- ? prop.subtype === "array"
233
- ? prop.value
234
- : "{...}"
235
- : prop.value}
236
+ ? String(prop.value)
237
+ : prop.type === "object"
238
+ ? prop.subtype === "array"
239
+ ? String(prop.value)
240
+ : "{...}"
241
+ : String(prop.value)}
236
242
  </span>
237
- {idx < properties.length - 1 && (
238
- <span className="text-gray-500">,</span>
239
- )}
243
+ {idx < properties.length - 1 && <span className="text-gray-500">,</span>}
240
244
  </div>
241
245
  ))}
242
- {overflow && (
243
- <div className="text-gray-500 italic">
244
- ... and more properties
245
- </div>
246
- )}
246
+ {overflow && <div className="text-gray-500 italic">... and more properties</div>}
247
247
  </div>
248
248
  <div className="text-gray-600">{"}"}</div>
249
249
  </div>
@@ -251,20 +251,19 @@ function ObjectRenderer({ content }: { content: string }) {
251
251
  </div>
252
252
  )}
253
253
  </div>
254
- );
254
+ )
255
255
  }
256
256
 
257
257
  // For regular JSON objects, render them nicely too
258
258
  return (
259
259
  <div className="inline-block">
260
260
  <button
261
+ type="button"
261
262
  onClick={() => setIsExpanded(!isExpanded)}
262
263
  className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-mono text-sm"
263
264
  >
264
265
  <svg
265
- className={`w-3 h-3 transition-transform ${
266
- isExpanded ? "rotate-90" : ""
267
- }`}
266
+ className={`w-3 h-3 transition-transform ${isExpanded ? "rotate-90" : ""}`}
268
267
  fill="currentColor"
269
268
  viewBox="0 0 20 20"
270
269
  >
@@ -284,16 +283,14 @@ function ObjectRenderer({ content }: { content: string }) {
284
283
 
285
284
  {isExpanded && (
286
285
  <div className="mt-1 ml-4 border-l-2 border-gray-200 pl-3">
287
- <pre className="font-mono text-sm text-gray-700 whitespace-pre-wrap">
288
- {JSON.stringify(obj, null, 2)}
289
- </pre>
286
+ <pre className="font-mono text-sm text-gray-700 whitespace-pre-wrap">{JSON.stringify(obj, null, 2)}</pre>
290
287
  </div>
291
288
  )}
292
289
  </div>
293
- );
294
- } catch (e) {
290
+ )
291
+ } catch (_e) {
295
292
  // If it's not valid JSON, just return the original content
296
- return <span>{content}</span>;
293
+ return <span>{content}</span>
297
294
  }
298
295
  }
299
296
 
@@ -303,83 +300,76 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
303
300
  if (message.includes("[INTERACTION]"))
304
301
  return {
305
302
  type: "INTERACTION",
306
- color:
307
- "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800",
308
- tag: "bg-purple-100 dark:bg-purple-800 text-purple-800 dark:text-purple-200",
309
- };
303
+ color: "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800",
304
+ tag: "bg-purple-100 dark:bg-purple-800 text-purple-800 dark:text-purple-200"
305
+ }
310
306
  if (message.includes("[CONSOLE ERROR]"))
311
307
  return {
312
308
  type: "ERROR",
313
- color:
314
- "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
315
- tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200",
316
- };
309
+ color: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
310
+ tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200"
311
+ }
317
312
  if (message.includes("[CONSOLE WARN]"))
318
313
  return {
319
314
  type: "WARNING",
320
- color:
321
- "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
322
- tag: "bg-yellow-100 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200",
323
- };
315
+ color: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
316
+ tag: "bg-yellow-100 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200"
317
+ }
324
318
  if (message.includes("[SCREENSHOT]"))
325
319
  return {
326
320
  type: "SCREENSHOT",
327
- color:
328
- "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800",
329
- tag: "bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200",
330
- };
321
+ color: "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800",
322
+ tag: "bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200"
323
+ }
331
324
  if (message.includes("[NAVIGATION]"))
332
325
  return {
333
326
  type: "NAVIGATION",
334
- color:
335
- "bg-indigo-50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-800",
336
- tag: "bg-indigo-100 dark:bg-indigo-800 text-indigo-800 dark:text-indigo-200",
337
- };
327
+ color: "bg-indigo-50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-800",
328
+ tag: "bg-indigo-100 dark:bg-indigo-800 text-indigo-800 dark:text-indigo-200"
329
+ }
338
330
  if (message.includes("[NETWORK ERROR]"))
339
331
  return {
340
332
  type: "NETWORK",
341
- color:
342
- "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
343
- tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200",
344
- };
333
+ color: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
334
+ tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200"
335
+ }
345
336
  if (message.includes("[NETWORK REQUEST]"))
346
337
  return {
347
338
  type: "NETWORK",
348
- color:
349
- "bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700",
350
- tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300",
351
- };
339
+ color: "bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700",
340
+ tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
341
+ }
352
342
  if (message.includes("[PAGE ERROR]"))
353
343
  return {
354
344
  type: "ERROR",
355
- color:
356
- "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
357
- tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200",
358
- };
345
+ color: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
346
+ tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200"
347
+ }
359
348
  return {
360
349
  type: "DEFAULT",
361
350
  color: "border-gray-200 dark:border-gray-700",
362
- tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300",
363
- };
364
- };
351
+ tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
352
+ }
353
+ }
365
354
 
366
- const logTypeInfo = parseLogType(entry.message);
355
+ const logTypeInfo = parseLogType(entry.message)
367
356
 
368
357
  // Extract and highlight type tags, detect JSON objects and URLs
369
358
  const renderMessage = (message: string) => {
370
- const typeTagRegex = /\[([A-Z\s]+)\]/g;
371
- const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
372
- const urlRegex = /(https?:\/\/[^\s]+)/g;
359
+ const typeTagRegex = /\[([A-Z\s]+)\]/g
360
+ const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g
361
+ const urlRegex = /(https?:\/\/[^\s]+)/g
373
362
 
374
- const parts = [];
375
- let lastIndex = 0;
376
- let match;
363
+ const parts = []
364
+ let lastIndex = 0
365
+ let match: RegExpExecArray | null
377
366
 
378
367
  // First, handle type tags
379
- while ((match = typeTagRegex.exec(message)) !== null) {
368
+ match = typeTagRegex.exec(message)
369
+ while (match !== null) {
380
370
  // Add text before the tag
381
371
  if (match.index > lastIndex) {
382
- parts.push(message.slice(lastIndex, match.index));
372
+ parts.push(message.slice(lastIndex, match.index))
383
373
  }
384
374
 
385
375
  // Add the tag with styling
@@ -390,69 +380,68 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
390
380
  >
391
381
  {match[1]}
392
382
  </span>
393
- );
383
+ )
394
384
 
395
- lastIndex = match.index + match[0].length;
385
+ lastIndex = match.index + match[0].length
386
+ match = typeTagRegex.exec(message)
396
387
  }
397
388
 
398
389
  // Add remaining text
399
- let remainingText = message.slice(lastIndex);
390
+ const remainingText = message.slice(lastIndex)
400
391
 
401
392
  // Process remaining text for JSON objects and URLs
402
393
  const processTextForObjects = (text: string, keyPrefix: string) => {
403
- const jsonMatches = [...text.matchAll(jsonRegex)];
404
- const urlMatches = [...text.matchAll(urlRegex)];
394
+ const jsonMatches = [...text.matchAll(jsonRegex)]
395
+ const urlMatches = [...text.matchAll(urlRegex)]
405
396
  const allMatches = [
406
397
  ...jsonMatches.map((m) => ({ ...m, type: "json" })),
407
- ...urlMatches.map((m) => ({ ...m, type: "url" })),
408
- ];
398
+ ...urlMatches.map((m) => ({ ...m, type: "url" }))
399
+ ]
409
400
 
410
401
  // Sort matches by index
411
- allMatches.sort((a, b) => a.index! - b.index!);
402
+ allMatches.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
412
403
 
413
404
  if (allMatches.length === 0) {
414
- return [text];
405
+ return [text]
415
406
  }
416
407
 
417
- const finalParts = [];
418
- let textLastIndex = 0;
408
+ const finalParts = []
409
+ let textLastIndex = 0
419
410
 
420
- allMatches.forEach((objMatch, idx) => {
411
+ allMatches.forEach((objMatch, _idx) => {
421
412
  // Add text before match
422
- if (objMatch.index! > textLastIndex) {
423
- finalParts.push(text.slice(textLastIndex, objMatch.index));
413
+ if ((objMatch.index ?? 0) > textLastIndex) {
414
+ finalParts.push(text.slice(textLastIndex, objMatch.index ?? 0))
424
415
  }
425
416
 
426
417
  // Add appropriate renderer
427
418
  if (objMatch.type === "json") {
428
419
  finalParts.push(
429
420
  <ObjectRenderer
430
- key={`${keyPrefix}-json-${idx}`}
421
+ key={`${keyPrefix}-json-${objMatch.index}-${objMatch[0].slice(0, 20)}`}
431
422
  content={objMatch[0]}
432
423
  />
433
- );
424
+ )
434
425
  } else if (objMatch.type === "url") {
435
- finalParts.push(
436
- <URLRenderer key={`${keyPrefix}-url-${idx}`} url={objMatch[0]} />
437
- );
426
+ finalParts.push(<URLRenderer key={`${keyPrefix}-url-${objMatch.index}-${objMatch[0]}`} url={objMatch[0]} />)
438
427
  }
439
428
 
440
- textLastIndex = objMatch.index! + objMatch[0].length;
441
- });
429
+ textLastIndex = (objMatch.index ?? 0) + objMatch[0].length
430
+ })
442
431
 
443
432
  // Add any text after the last match
444
433
  if (textLastIndex < text.length) {
445
- finalParts.push(text.slice(textLastIndex));
434
+ finalParts.push(text.slice(textLastIndex))
446
435
  }
447
436
 
448
- return finalParts;
449
- };
437
+ return finalParts
438
+ }
450
439
 
451
- const processedRemaining = processTextForObjects(remainingText, "main");
452
- parts.push(...processedRemaining);
440
+ const processedRemaining = processTextForObjects(remainingText, "main")
441
+ parts.push(...processedRemaining)
453
442
 
454
- return parts.length > 0 ? parts : message;
455
- };
443
+ return parts.length > 0 ? parts : message
444
+ }
456
445
 
457
446
  return (
458
447
  <div className={`border-l-4 ${logTypeInfo.color} pl-4 py-2`}>
@@ -474,257 +463,366 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
474
463
  {entry.source}
475
464
  </div>
476
465
 
477
- {/* Column 3: Message content */}
466
+ {/* Column 3: Message content with user agent info */}
478
467
  <div className="font-mono text-sm min-w-0 text-gray-900 dark:text-gray-100">
479
- {renderMessage(entry.message)}
468
+ <div className="flex flex-wrap items-start gap-2">
469
+ <div className="flex-1 min-w-0">{renderMessage(entry.message)}</div>
470
+ {/* User Agent and Tab Identifier Pills */}
471
+ <div className="flex items-center gap-1 flex-shrink-0">
472
+ {entry.tabIdentifier && (
473
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
474
+ {entry.tabIdentifier}
475
+ </span>
476
+ )}
477
+ {entry.userAgent && (
478
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200">
479
+ {entry.userAgent.includes("Chrome")
480
+ ? "Chrome"
481
+ : entry.userAgent.includes("Firefox")
482
+ ? "Firefox"
483
+ : entry.userAgent.includes("Safari")
484
+ ? "Safari"
485
+ : entry.userAgent.includes("Edge")
486
+ ? "Edge"
487
+ : "Browser"}
488
+ </span>
489
+ )}
490
+ </div>
491
+ </div>
480
492
  </div>
481
493
  </div>
482
494
 
483
495
  {entry.screenshot && (
484
496
  <div className="mt-2">
485
- <img
497
+ <Image
486
498
  src={`/screenshots/${entry.screenshot}`}
487
499
  alt="Screenshot"
500
+ width={800}
501
+ height={400}
488
502
  className="max-w-full h-auto border rounded shadow-sm"
489
503
  style={{ maxHeight: "400px" }}
504
+ unoptimized
490
505
  />
491
506
  </div>
492
507
  )}
493
508
  </div>
494
- );
509
+ )
495
510
  }
496
511
 
497
512
  interface LogsClientProps {
498
- version: string;
513
+ version: string
499
514
  initialData?: {
500
- logs: LogEntry[];
501
- logFiles: any[];
502
- currentLogFile: string;
503
- mode: "head" | "tail";
504
- };
515
+ logs: LogEntry[]
516
+ logFiles: LogFile[]
517
+ currentLogFile: string
518
+ mode: "head" | "tail"
519
+ }
505
520
  }
506
521
 
507
522
  export default function LogsClient({ version, initialData }: LogsClientProps) {
508
- const router = useRouter();
509
- const searchParams = useSearchParams();
510
- const [darkMode, setDarkMode] = useDarkMode();
511
- const [logs, setLogs] = useState<LogEntry[]>(initialData?.logs || []);
512
- const [mode, setMode] = useState<"head" | "tail">(
513
- initialData?.mode || "tail"
514
- );
515
- const [isAtBottom, setIsAtBottom] = useState(true);
516
- const [isLoadingNew, setIsLoadingNew] = useState(false);
517
- const [isInitialLoading, setIsInitialLoading] = useState(!initialData);
518
- const [lastLogCount, setLastLogCount] = useState(
519
- initialData?.logs.length || 0
520
- );
521
- const [lastFetched, setLastFetched] = useState<Date | null>(null);
522
- const [availableLogs, setAvailableLogs] = useState<LogFile[]>(
523
- initialData?.logFiles || []
524
- );
525
- const [currentLogFile, setCurrentLogFile] = useState<string>(
526
- initialData?.currentLogFile || ""
527
- );
528
- const [projectName, setProjectName] = useState<string>("");
529
- const [showLogSelector, setShowLogSelector] = useState(false);
530
- const [isReplaying, setIsReplaying] = useState(false);
531
- const [showFilters, setShowFilters] = useState(false);
532
- const [showReplayPreview, setShowReplayPreview] = useState(false);
533
- const [replayEvents, setReplayEvents] = useState<any[]>([]);
534
- const [isRotatingLog, setIsRotatingLog] = useState(false);
523
+ const router = useRouter()
524
+ const searchParams = useSearchParams()
525
+ const [darkMode, setDarkMode] = useDarkMode()
526
+ const [logs, setLogs] = useState<LogEntry[]>(initialData?.logs || [])
527
+ const [mode, setMode] = useState<"head" | "tail">(initialData?.mode || "tail")
528
+
529
+ // Update mode when URL parameters change
530
+ useEffect(() => {
531
+ const urlMode = searchParams.get("mode") as "head" | "tail" | null
532
+ if (urlMode && urlMode !== mode) {
533
+ setMode(urlMode)
534
+ }
535
+ }, [searchParams, mode])
536
+ const [isAtBottom, setIsAtBottom] = useState(true)
537
+ const [isInitialLoading, setIsInitialLoading] = useState(!initialData)
538
+ const [lastLogCount, setLastLogCount] = useState(initialData?.logs.length || 0)
539
+ const [lastFetched, setLastFetched] = useState<Date | null>(null)
540
+ const [availableLogs, setAvailableLogs] = useState<LogFile[]>(initialData?.logFiles || [])
541
+ const [currentLogFile, setCurrentLogFile] = useState<string>(initialData?.currentLogFile || "")
542
+ const [projectName, setProjectName] = useState<string>("")
543
+ const [showLogSelector, setShowLogSelector] = useState(false)
544
+ const [isReplaying, setIsReplaying] = useState(false)
545
+ const [showFilters, setShowFilters] = useState(false)
546
+ const [showReplayPreview, setShowReplayPreview] = useState(false)
547
+ const [replayEvents, setReplayEvents] = useState<ReplayEvent[]>([])
548
+ const [isRotatingLog, setIsRotatingLog] = useState(false)
535
549
  const [filters, setFilters] = useState({
536
550
  browser: true,
537
551
  server: true,
538
552
  interaction: true,
539
- screenshot: true,
540
- });
541
- const bottomRef = useRef<HTMLDivElement>(null);
542
- const containerRef = useRef<HTMLDivElement>(null);
543
- const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
544
- const dropdownRef = useRef<HTMLDivElement>(null);
545
- const filterDropdownRef = useRef<HTMLDivElement>(null);
546
-
547
- const loadAvailableLogs = async () => {
553
+ screenshot: true
554
+ })
555
+ const [userAgentFilters, setUserAgentFilters] = useState<Record<string, boolean>>({})
556
+ const bottomRef = useRef<HTMLDivElement>(null)
557
+ const containerRef = useRef<HTMLDivElement>(null)
558
+ const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)
559
+ const dropdownRef = useRef<HTMLDivElement>(null)
560
+ const filterDropdownRef = useRef<HTMLDivElement>(null)
561
+
562
+ const loadAvailableLogs = useCallback(async () => {
548
563
  try {
549
- const response = await fetch("/api/logs/list");
564
+ const response = await fetch("/api/logs/list")
550
565
  if (response.ok) {
551
- const data: LogListResponse = await response.json();
552
- setAvailableLogs(data.files);
553
- setCurrentLogFile(data.currentFile);
554
- setProjectName(data.projectName);
566
+ const data: LogListResponse = await response.json()
567
+ setAvailableLogs(data.files)
568
+ setCurrentLogFile(data.currentFile)
569
+ setProjectName(data.projectName)
555
570
  }
556
571
  } catch (error) {
557
- console.error("Error loading available logs:", error);
572
+ console.error("Error loading available logs:", error)
558
573
  }
559
- };
574
+ }, [])
560
575
 
561
- const pollForNewLogs = async () => {
562
- if (mode !== "tail" || !isAtBottom) return;
576
+ const pollForNewLogs = useCallback(async () => {
577
+ if (mode !== "tail" || !isAtBottom) return
563
578
 
564
579
  try {
565
- const response = await fetch("/api/logs/tail?lines=1000");
580
+ // Determine which log file to poll
581
+ const requestedFile = searchParams.get("file")
582
+ let logPath = ""
583
+ let isCurrentFile = true
584
+
585
+ if (requestedFile && availableLogs.length > 0) {
586
+ // Find the specific log file requested
587
+ const foundFile = availableLogs.find((f) => f.name === requestedFile)
588
+ logPath = foundFile?.path || currentLogFile
589
+ isCurrentFile = foundFile?.isCurrent !== false
590
+ } else {
591
+ // Use current log file
592
+ logPath = currentLogFile
593
+ isCurrentFile = true
594
+ }
595
+
596
+ // Only poll for new logs if viewing the current (active) log file
597
+ if (!isCurrentFile) return
598
+
599
+ // Build API URL with logPath parameter if needed
600
+ const apiUrl = logPath
601
+ ? `/api/logs/tail?lines=1000&logPath=${encodeURIComponent(logPath)}`
602
+ : `/api/logs/tail?lines=1000`
603
+
604
+ const response = await fetch(apiUrl)
566
605
  if (!response.ok) {
567
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
606
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
568
607
  }
569
608
 
570
- const data: LogsApiResponse = await response.json();
609
+ const data: LogsApiResponse = await response.json()
571
610
 
572
611
  if (!data.logs) {
573
- console.warn("No logs data in response");
574
- return;
612
+ console.warn("No logs data in response")
613
+ return
575
614
  }
576
615
 
577
- const entries = parseLogEntries(data.logs);
616
+ const entries = parseLogEntries(data.logs)
578
617
 
579
618
  if (entries.length > lastLogCount) {
580
- setIsLoadingNew(true);
581
- setLastFetched(new Date());
582
- setTimeout(() => {
583
- setLogs(entries);
584
- setLastLogCount(entries.length);
585
- setIsLoadingNew(false);
586
- // Auto-scroll to bottom for new content
619
+ setLastFetched(new Date())
620
+ setLogs(entries)
621
+ setLastLogCount(entries.length)
622
+ // Only auto-scroll if user is at bottom (don't force scroll when user scrolled up)
623
+ if (isAtBottom) {
587
624
  setTimeout(() => {
588
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
589
- }, 50);
590
- }, 250);
625
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" })
626
+ }, 50)
627
+ }
591
628
  }
592
629
  } catch (error) {
593
- console.error("Error polling logs:", error);
630
+ console.error("Error polling logs:", error)
594
631
  // Don't spam console on network errors during polling
595
632
  }
596
- };
633
+ }, [mode, isAtBottom, searchParams, availableLogs, currentLogFile, lastLogCount])
597
634
 
598
635
  // Start/stop polling based on mode and scroll position
599
636
  useEffect(() => {
600
637
  if (mode === "tail" && isAtBottom) {
601
- pollIntervalRef.current = setInterval(pollForNewLogs, 2000); // Poll every 2 seconds
638
+ pollIntervalRef.current = setInterval(pollForNewLogs, 2000) // Poll every 2 seconds
602
639
  return () => {
603
640
  if (pollIntervalRef.current) {
604
- clearInterval(pollIntervalRef.current);
641
+ clearInterval(pollIntervalRef.current)
605
642
  }
606
- };
643
+ }
607
644
  } else {
608
645
  if (pollIntervalRef.current) {
609
- clearInterval(pollIntervalRef.current);
646
+ clearInterval(pollIntervalRef.current)
610
647
  }
611
648
  }
612
- }, [mode, isAtBottom, lastLogCount]);
649
+ }, [mode, isAtBottom, pollForNewLogs]) // Remove lastLogCount to avoid excessive polling restarts
613
650
 
614
- const loadInitialLogs = async () => {
615
- setIsInitialLoading(true);
651
+ const loadInitialLogs = useCallback(async () => {
652
+ setIsInitialLoading(true)
616
653
 
617
654
  // Load available logs list first
618
- await loadAvailableLogs();
655
+ await loadAvailableLogs()
619
656
 
620
657
  try {
621
- const response = await fetch(`/api/logs/${mode}?lines=1000`);
658
+ // Determine which log file to load
659
+ const requestedFile = searchParams.get("file")
660
+ let logPath = ""
661
+
662
+ if (requestedFile && availableLogs.length > 0) {
663
+ // Find the specific log file requested
664
+ const foundFile = availableLogs.find((f) => f.name === requestedFile)
665
+ logPath = foundFile?.path || currentLogFile
666
+ } else {
667
+ // Use current log file
668
+ logPath = currentLogFile
669
+ }
670
+
671
+ // Build API URL with logPath parameter if needed
672
+ const apiUrl = logPath
673
+ ? `/api/logs/${mode}?lines=1000&logPath=${encodeURIComponent(logPath)}`
674
+ : `/api/logs/${mode}?lines=1000`
675
+
676
+ const response = await fetch(apiUrl)
622
677
  if (!response.ok) {
623
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
678
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
624
679
  }
625
680
 
626
- const data: LogsApiResponse = await response.json();
681
+ const data: LogsApiResponse = await response.json()
627
682
 
628
683
  if (!data.logs) {
629
- console.warn("No logs data in response");
630
- setLogs([]);
631
- setIsInitialLoading(false);
632
- return;
684
+ console.warn("No logs data in response")
685
+ setLogs([])
686
+ setIsInitialLoading(false)
687
+ return
633
688
  }
634
689
 
635
- const entries = parseLogEntries(data.logs);
690
+ const entries = parseLogEntries(data.logs)
636
691
 
637
- setLogs(entries);
638
- setLastLogCount(entries.length);
639
- setLastFetched(new Date());
640
- setIsInitialLoading(false);
692
+ setLogs(entries)
693
+ setLastLogCount(entries.length)
694
+ setLastFetched(new Date())
695
+ setIsInitialLoading(false)
641
696
 
642
697
  // Auto-scroll to bottom for tail mode
643
698
  if (mode === "tail") {
644
- setTimeout(() => {
645
- bottomRef.current?.scrollIntoView({ behavior: "auto" });
646
- setIsAtBottom(true);
647
- }, 100);
699
+ setIsAtBottom(true)
700
+ const scrollToBottom = () => {
701
+ if (bottomRef.current) {
702
+ bottomRef.current.scrollIntoView({ behavior: "auto" })
703
+ } else if (containerRef.current) {
704
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
705
+ }
706
+ }
707
+ // Try multiple times to ensure it works
708
+ setTimeout(scrollToBottom, 0)
709
+ setTimeout(scrollToBottom, 100)
648
710
  }
649
711
  } catch (error) {
650
- console.error("Error loading logs:", error);
651
- setLogs([]);
712
+ console.error("Error loading logs:", error)
713
+ setLogs([])
652
714
  }
653
- };
715
+ }, [loadAvailableLogs, searchParams, availableLogs, currentLogFile, mode])
654
716
 
655
717
  useEffect(() => {
656
- // Only load initial logs if we don't have initial data (client-side fallback)
657
- if (!initialData) {
658
- loadInitialLogs();
659
- } else {
660
- // We have initial data, just start polling for updates
661
- setIsInitialLoading(false);
662
- if (mode === "tail" && isAtBottom) {
663
- // Set up polling timer for new logs
664
- pollIntervalRef.current = setInterval(() => {
665
- pollForNewLogs();
666
- }, 2000);
718
+ // Only load logs if we don't have initial data or mode actually changed
719
+ const _currentMode = searchParams.get("mode") || "tail"
720
+ const hasInitialData = initialData?.logs && initialData?.logs.length > 0
721
+
722
+ if (!hasInitialData && !logs.length) {
723
+ // No server-side data and no client data - load fresh
724
+ loadInitialLogs()
725
+ } else if (hasInitialData && logs.length === 0) {
726
+ // We have server-side data but client state is empty - use server data
727
+ setLogs(initialData.logs)
728
+ setIsInitialLoading(false)
729
+ if (mode === "tail") {
730
+ // Scroll to bottom for tail mode with server data - more aggressive approach
731
+ setIsAtBottom(true)
732
+ const scrollToBottom = () => {
733
+ if (bottomRef.current) {
734
+ bottomRef.current.scrollIntoView({ behavior: "auto" })
735
+ } else if (containerRef.current) {
736
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
737
+ }
738
+ }
739
+ // Try multiple times to ensure it works
740
+ setTimeout(scrollToBottom, 0)
741
+ setTimeout(scrollToBottom, 100)
742
+ setTimeout(scrollToBottom, 300)
667
743
  }
744
+ } else if (mode === "tail" && isAtBottom) {
745
+ // Set up polling timer for new logs if we're in tail mode
746
+ setIsInitialLoading(false)
747
+ pollIntervalRef.current = setInterval(() => {
748
+ pollForNewLogs()
749
+ }, 2000)
668
750
  }
669
- }, [mode]);
751
+ }, [
752
+ mode,
753
+ initialData?.logs,
754
+ isAtBottom, // No server-side data and no client data - load fresh
755
+ loadInitialLogs,
756
+ logs.length,
757
+ pollForNewLogs,
758
+ searchParams.get
759
+ ]) // Only depend on mode to avoid infinite loops
760
+
761
+ // Separate effect to handle scrolling after logs are rendered
762
+ useEffect(() => {
763
+ if (logs.length > 0 && mode === "tail" && isAtBottom) {
764
+ const scrollToBottom = () => {
765
+ if (bottomRef.current) {
766
+ bottomRef.current.scrollIntoView({ behavior: "auto" })
767
+ } else if (containerRef.current) {
768
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
769
+ }
770
+ }
771
+ // Scroll after DOM updates
772
+ setTimeout(scrollToBottom, 0)
773
+ }
774
+ }, [logs.length, mode, isAtBottom]) // Trigger when logs are actually rendered
670
775
 
671
776
  useEffect(() => {
672
777
  return () => {
673
778
  if (pollIntervalRef.current) {
674
- clearInterval(pollIntervalRef.current);
779
+ clearInterval(pollIntervalRef.current)
675
780
  }
676
- };
677
- }, []);
781
+ }
782
+ }, [])
678
783
 
679
784
  // Close dropdowns when clicking outside
680
785
  useEffect(() => {
681
786
  const handleClickOutside = (event: MouseEvent) => {
682
- if (
683
- dropdownRef.current &&
684
- !dropdownRef.current.contains(event.target as Node)
685
- ) {
686
- setShowLogSelector(false);
787
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
788
+ setShowLogSelector(false)
687
789
  }
688
- if (
689
- filterDropdownRef.current &&
690
- !filterDropdownRef.current.contains(event.target as Node)
691
- ) {
692
- setShowFilters(false);
790
+ if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target as Node)) {
791
+ setShowFilters(false)
693
792
  }
694
- };
793
+ }
695
794
 
696
795
  if (showLogSelector || showFilters) {
697
- document.addEventListener("mousedown", handleClickOutside);
698
- return () =>
699
- document.removeEventListener("mousedown", handleClickOutside);
796
+ document.addEventListener("mousedown", handleClickOutside)
797
+ return () => document.removeEventListener("mousedown", handleClickOutside)
700
798
  }
701
- }, [showLogSelector, showFilters]);
799
+ }, [showLogSelector, showFilters])
702
800
 
703
801
  const handleScroll = () => {
704
802
  if (containerRef.current) {
705
- const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
706
- const atBottom = scrollTop + clientHeight >= scrollHeight - 10;
707
- setIsAtBottom(atBottom);
803
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current
804
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 10
805
+ setIsAtBottom(atBottom)
708
806
  }
709
- };
807
+ }
710
808
 
711
809
  const handleReplay = async () => {
712
- if (isReplaying) return;
810
+ if (isReplaying) return
713
811
 
714
- setIsReplaying(true);
812
+ setIsReplaying(true)
715
813
 
716
814
  try {
717
815
  // Get replay data from logs
718
- const response = await fetch("/api/replay?action=parse");
816
+ const response = await fetch("/api/replay?action=parse")
719
817
  if (!response.ok) {
720
- throw new Error("Failed to parse replay data");
818
+ throw new Error("Failed to parse replay data")
721
819
  }
722
820
 
723
- const replayData = await response.json();
821
+ const replayData = await response.json()
724
822
 
725
823
  if (replayData.interactions.length === 0) {
726
- alert("No user interactions found in logs to replay");
727
- return;
824
+ alert("No user interactions found in logs to replay")
825
+ return
728
826
  }
729
827
 
730
828
  // Generate CDP commands for replay
@@ -734,121 +832,173 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
734
832
  body: JSON.stringify({
735
833
  action: "execute",
736
834
  replayData: replayData,
737
- speed: 2,
738
- }),
739
- });
835
+ speed: 2
836
+ })
837
+ })
740
838
 
741
- const result = await response2.json();
839
+ const result = await response2.json()
742
840
 
743
841
  if (result.success) {
744
- console.log("Replay executed successfully:", result);
745
- alert(`Replay completed! Executed ${result.totalCommands} commands.`);
842
+ console.log("Replay executed successfully:", result)
843
+ alert(`Replay completed! Executed ${result.totalCommands} commands.`)
746
844
  } else {
747
- console.log("CDP execution failed, showing commands:", result);
845
+ console.log("CDP execution failed, showing commands:", result)
748
846
  alert(
749
- `CDP execution not available. Generated ${
750
- result.commands?.length || 0
751
- } commands. Check console for details.`
752
- );
847
+ `CDP execution not available. Generated ${result.commands?.length || 0} commands. Check console for details.`
848
+ )
753
849
  }
754
850
  } catch (error) {
755
- console.error("Replay error:", error);
756
- alert(
757
- "Failed to start replay: " +
758
- (error instanceof Error ? error.message : "Unknown error")
759
- );
851
+ console.error("Replay error:", error)
852
+ alert(`Failed to start replay: ${error instanceof Error ? error.message : "Unknown error"}`)
760
853
  } finally {
761
- setIsReplaying(false);
854
+ setIsReplaying(false)
762
855
  }
763
- };
856
+ }
764
857
 
765
858
  const loadReplayPreview = () => {
766
859
  // Extract interactions from current logs instead of making API call
767
860
  const interactions = logs
768
861
  .filter((log) => log.message.includes("[INTERACTION]"))
769
862
  .map((log) => {
770
- const match = log.message.match(/\[INTERACTION\] (.+)/);
863
+ const match = log.message.match(/\[INTERACTION\] (.+)/)
771
864
  if (match) {
772
865
  try {
773
866
  // Try parsing as JSON (new format)
774
- const data = JSON.parse(match[1]);
867
+ const data = JSON.parse(match[1])
775
868
  return {
776
869
  timestamp: log.timestamp,
870
+ event: data.type || 'unknown',
871
+ details: JSON.stringify(data),
777
872
  type: data.type,
778
- details: data,
779
- };
873
+ x: data.x,
874
+ y: data.y,
875
+ target: data.target,
876
+ direction: data.direction,
877
+ distance: data.distance,
878
+ key: data.key
879
+ } as ReplayEvent
780
880
  } catch {
781
881
  // Fallback to old format parsing
782
- const oldMatch = match[1].match(/(CLICK|TAP|SCROLL|KEY) (.+)/);
882
+ const oldMatch = match[1].match(/(CLICK|TAP|SCROLL|KEY) (.+)/)
783
883
  if (oldMatch) {
784
884
  return {
785
885
  timestamp: log.timestamp,
786
- type: oldMatch[1],
886
+ event: oldMatch[1],
787
887
  details: oldMatch[2],
788
- };
888
+ type: oldMatch[1]
889
+ } as ReplayEvent
789
890
  }
790
891
  }
791
892
  }
792
- return null;
893
+ return null
793
894
  })
794
- .filter(Boolean);
895
+ .filter((item): item is ReplayEvent => item !== null)
795
896
 
796
- setReplayEvents(interactions);
797
- };
897
+ setReplayEvents(interactions)
898
+ }
798
899
 
799
900
  const handleRotateLog = async () => {
800
- if (!currentLogFile || isRotatingLog) return;
901
+ if (!currentLogFile || isRotatingLog) return
902
+
903
+ const confirmed = window.confirm(
904
+ "Clear logs and start fresh?\n\n" +
905
+ "This will:\n" +
906
+ "• Archive the current log file\n" +
907
+ "• Start a new empty log file\n" +
908
+ "• Clear the current view\n\n" +
909
+ "The archived logs will still be available in the dropdown."
910
+ )
801
911
 
802
- setIsRotatingLog(true);
912
+ if (!confirmed) return
913
+
914
+ setIsRotatingLog(true)
803
915
  try {
804
916
  const response = await fetch("/api/logs/rotate", {
805
917
  method: "POST",
806
918
  headers: {
807
- "Content-Type": "application/json",
919
+ "Content-Type": "application/json"
808
920
  },
809
- body: JSON.stringify({ currentLogPath: currentLogFile }),
810
- });
921
+ body: JSON.stringify({ currentLogPath: currentLogFile })
922
+ })
811
923
 
812
924
  if (response.ok) {
813
925
  // Clear current logs from UI
814
- setLogs([]);
815
- setLastLogCount(0);
816
- setLastFetched(null);
926
+ setLogs([])
927
+ setLastLogCount(0)
928
+ setLastFetched(null)
817
929
 
818
930
  // Reload available logs to show the new archived file
819
- await loadAvailableLogs();
931
+ await loadAvailableLogs()
820
932
 
821
933
  // Start fresh polling
822
- await loadInitialLogs();
934
+ await loadInitialLogs()
823
935
  } else {
824
- const error = await response.json();
825
- console.error("Failed to rotate log:", error);
826
- alert("Failed to rotate log: " + error.error);
936
+ const error = await response.json()
937
+ console.error("Failed to rotate log:", error)
938
+ alert(`Failed to rotate log: ${error.error}`)
827
939
  }
828
940
  } catch (error) {
829
- console.error("Error rotating log:", error);
830
- alert("Error rotating log");
941
+ console.error("Error rotating log:", error)
942
+ alert("Error rotating log")
831
943
  } finally {
832
- setIsRotatingLog(false);
944
+ setIsRotatingLog(false)
833
945
  }
834
- };
946
+ }
947
+
948
+ // Compute available user agents from browser logs
949
+ const availableUserAgents = useMemo(() => {
950
+ const userAgents = new Set<string>()
951
+ logs.forEach((entry) => {
952
+ if (entry.source === "BROWSER" && entry.userAgent) {
953
+ userAgents.add(entry.userAgent)
954
+ }
955
+ })
956
+ return Array.from(userAgents).sort()
957
+ }, [logs])
958
+
959
+ // Update user agent filters when available user agents change
960
+ useEffect(() => {
961
+ if (availableUserAgents.length > 0) {
962
+ setUserAgentFilters((prev) => {
963
+ const newFilters = { ...prev }
964
+ // Enable all user agents by default if not already set
965
+ availableUserAgents.forEach((ua) => {
966
+ if (!(ua in newFilters)) {
967
+ newFilters[ua] = true
968
+ }
969
+ })
970
+ return newFilters
971
+ })
972
+ }
973
+ }, [availableUserAgents])
835
974
 
836
975
  const filteredLogs = useMemo(() => {
837
976
  return logs.filter((entry) => {
838
977
  // Check specific message types first (these override source filtering)
839
- const isInteraction = entry.message.includes("[INTERACTION]");
840
- const isScreenshot = entry.message.includes("[SCREENSHOT]");
978
+ const isInteraction = entry.message.includes("[INTERACTION]")
979
+ const isScreenshot = entry.message.includes("[SCREENSHOT]")
841
980
 
842
- if (isInteraction) return filters.interaction;
843
- if (isScreenshot) return filters.screenshot;
981
+ if (isInteraction) return filters.interaction
982
+ if (isScreenshot) return filters.screenshot
844
983
 
845
984
  // For other logs, filter by source
846
- if (entry.source === "SERVER") return filters.server;
847
- if (entry.source === "BROWSER") return filters.browser;
985
+ if (entry.source === "SERVER") return filters.server
986
+ if (entry.source === "BROWSER") {
987
+ // First check if browser logs are enabled at all
988
+ if (!filters.browser) return false
989
+
990
+ // If there are user agent filters and this entry has a user agent, apply UA filtering
991
+ if (availableUserAgents.length > 0 && entry.userAgent) {
992
+ return userAgentFilters[entry.userAgent] !== false
993
+ }
994
+
995
+ // Otherwise, just show if browser is enabled
996
+ return true
997
+ }
848
998
 
849
- return true;
850
- });
851
- }, [logs, filters]);
999
+ return true
1000
+ })
1001
+ }, [logs, filters, userAgentFilters, availableUserAgents])
852
1002
 
853
1003
  return (
854
1004
  <div className="h-screen bg-gray-50 dark:bg-gray-900 flex flex-col transition-colors">
@@ -861,14 +1011,13 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
861
1011
  <h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white whitespace-nowrap">
862
1012
  dev3000
863
1013
  </h1>
864
- <span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
865
- (v{version})
866
- </span>
1014
+ <span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">(v{version})</span>
867
1015
  </div>
868
1016
  {/* Log File Selector */}
869
1017
  {availableLogs.length > 1 ? (
870
1018
  <div className="relative" ref={dropdownRef}>
871
1019
  <button
1020
+ type="button"
872
1021
  onClick={() => setShowLogSelector(!showLogSelector)}
873
1022
  className="flex items-center gap-2 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors"
874
1023
  >
@@ -879,25 +1028,23 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
879
1028
  style={{ width: "220px" }}
880
1029
  />
881
1030
  ) : currentLogFile ? (
882
- currentLogFile.split("/").pop()
1031
+ // Show symlink name for current file, basename for others
1032
+ availableLogs.find((log) => log.path === currentLogFile)?.isCurrent ? (
1033
+ "d3k.log"
1034
+ ) : (
1035
+ currentLogFile.split("/").pop()
1036
+ )
883
1037
  ) : (
884
- "dev3000.log"
1038
+ "d3k.log"
885
1039
  )}
886
1040
  </span>
887
1041
  <svg
888
- className={`w-4 h-4 transition-transform ${
889
- showLogSelector ? "rotate-180" : ""
890
- }`}
1042
+ className={`w-4 h-4 transition-transform ${showLogSelector ? "rotate-180" : ""}`}
891
1043
  fill="none"
892
1044
  stroke="currentColor"
893
1045
  viewBox="0 0 24 24"
894
1046
  >
895
- <path
896
- strokeLinecap="round"
897
- strokeLinejoin="round"
898
- strokeWidth={2}
899
- d="M19 9l-7 7-7-7"
900
- />
1047
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
901
1048
  </svg>
902
1049
  </button>
903
1050
  {/* Dropdown */}
@@ -909,35 +1056,23 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
909
1056
  </div>
910
1057
  {availableLogs.map((logFile) => (
911
1058
  <button
1059
+ type="button"
912
1060
  key={logFile.path}
913
1061
  onClick={() => {
914
- setShowLogSelector(false);
915
- router.push(
916
- `/logs?file=${encodeURIComponent(
917
- logFile.name
918
- )}&mode=${mode}`
919
- );
1062
+ setShowLogSelector(false)
1063
+ router.push(`/logs?file=${encodeURIComponent(logFile.name)}&mode=${mode}`)
920
1064
  }}
921
1065
  className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between ${
922
- logFile.isCurrent
923
- ? "bg-blue-50 text-blue-900"
924
- : "text-gray-700"
1066
+ logFile.isCurrent ? "bg-blue-50 text-blue-900" : "text-gray-700"
925
1067
  }`}
926
1068
  >
927
1069
  <div className="flex flex-col">
928
- <span className="font-mono text-xs">
929
- {logFile.name}
930
- </span>
1070
+ <span className="font-mono text-xs">{logFile.name}</span>
931
1071
  <span className="text-xs text-gray-500">
932
- {new Date(logFile.mtime).toLocaleString()} •{" "}
933
- {Math.round(logFile.size / 1024)}KB
1072
+ {new Date(logFile.mtime).toLocaleString()} • {Math.round(logFile.size / 1024)}KB
934
1073
  </span>
935
1074
  </div>
936
- {logFile.isCurrent && (
937
- <span className="text-xs text-blue-600 font-medium">
938
- current
939
- </span>
940
- )}
1075
+ {logFile.isCurrent && <span className="text-xs text-blue-600 font-medium">current</span>}
941
1076
  </button>
942
1077
  ))}
943
1078
  </div>
@@ -948,32 +1083,35 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
948
1083
  <div className="flex items-center gap-2">
949
1084
  <span className="font-mono text-xs text-gray-600 px-3 py-1 whitespace-nowrap">
950
1085
  {isInitialLoading && !currentLogFile ? (
951
- <div
952
- className="h-4 bg-gray-200 rounded animate-pulse"
953
- style={{ width: "220px" }}
954
- />
1086
+ <div className="h-4 bg-gray-200 rounded animate-pulse" style={{ width: "220px" }} />
955
1087
  ) : currentLogFile ? (
956
- currentLogFile.split("/").pop()
1088
+ // Show symlink name for current file, basename for others
1089
+ availableLogs.find((log) => log.path === currentLogFile)?.isCurrent ? (
1090
+ "d3k.log"
1091
+ ) : (
1092
+ currentLogFile.split("/").pop()
1093
+ )
957
1094
  ) : (
958
- "dev3000.log"
1095
+ "d3k.log"
959
1096
  )}
960
1097
  </span>
961
- {currentLogFile && !isInitialLoading && (
962
- <button
963
- onClick={handleRotateLog}
964
- disabled={isRotatingLog}
965
- className="px-2 py-1 text-xs bg-orange-100 text-orange-700 hover:bg-orange-200 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
966
- title="Clear logs (rotate current log to archive and start fresh)"
967
- >
968
- {isRotatingLog ? "..." : "Clear"}
969
- </button>
970
- )}
971
1098
  </div>
972
1099
  )}
973
- {logs.length > 0 && (
974
- <span className="text-sm text-gray-500 hidden sm:inline">
975
- {logs.length} entries
976
- </span>
1100
+
1101
+ {/* Entries count */}
1102
+ {logs.length > 0 && <span className="text-sm text-gray-500 hidden sm:inline">{logs.length} entries</span>}
1103
+
1104
+ {/* Clear button - always visible when we have a current log file */}
1105
+ {currentLogFile && !isInitialLoading && (
1106
+ <button
1107
+ type="button"
1108
+ onClick={handleRotateLog}
1109
+ disabled={isRotatingLog}
1110
+ className="px-2 py-1 text-xs bg-orange-100 text-orange-700 hover:bg-orange-200 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1111
+ title="Clear logs (rotate current log to archive and start fresh)"
1112
+ >
1113
+ {isRotatingLog ? "..." : "Clear"}
1114
+ </button>
977
1115
  )}
978
1116
  </div>
979
1117
 
@@ -982,12 +1120,13 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
982
1120
  {/* Replay Button with Hover Preview */}
983
1121
  <div className="relative">
984
1122
  <button
1123
+ type="button"
985
1124
  onClick={handleReplay}
986
1125
  disabled={isReplaying}
987
1126
  onMouseEnter={() => {
988
1127
  if (!isReplaying) {
989
- loadReplayPreview();
990
- setShowReplayPreview(true);
1128
+ loadReplayPreview()
1129
+ setShowReplayPreview(true)
991
1130
  }
992
1131
  }}
993
1132
  onMouseLeave={() => setShowReplayPreview(false)}
@@ -1004,12 +1143,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1004
1143
  </>
1005
1144
  ) : (
1006
1145
  <>
1007
- <svg
1008
- className="w-4 h-4"
1009
- fill="none"
1010
- stroke="currentColor"
1011
- viewBox="0 0 24 24"
1012
- >
1146
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1013
1147
  <path
1014
1148
  strokeLinecap="round"
1015
1149
  strokeLinejoin="round"
@@ -1030,13 +1164,11 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1030
1164
  </div>
1031
1165
  <div className="max-h-60 overflow-y-auto">
1032
1166
  {replayEvents.length === 0 ? (
1033
- <div className="px-3 py-4 text-sm text-gray-500 text-center">
1034
- No interactions to replay
1035
- </div>
1167
+ <div className="px-3 py-4 text-sm text-gray-500 text-center">No interactions to replay</div>
1036
1168
  ) : (
1037
1169
  replayEvents.map((event, index) => (
1038
1170
  <div
1039
- key={index}
1171
+ key={`${event.timestamp}-${index}`}
1040
1172
  className="px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
1041
1173
  >
1042
1174
  <div className="flex items-center gap-2">
@@ -1045,25 +1177,21 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1045
1177
  event.type === "CLICK"
1046
1178
  ? "bg-blue-100 text-blue-800"
1047
1179
  : event.type === "SCROLL"
1048
- ? "bg-green-100 text-green-800"
1049
- : "bg-gray-100 text-gray-700"
1180
+ ? "bg-green-100 text-green-800"
1181
+ : "bg-gray-100 text-gray-700"
1050
1182
  }`}
1051
1183
  >
1052
1184
  {event.type}
1053
1185
  </span>
1054
1186
  <span className="text-xs text-gray-500 font-mono">
1055
- {new Date(
1056
- event.timestamp
1057
- ).toLocaleTimeString()}
1187
+ {new Date(event.timestamp).toLocaleTimeString()}
1058
1188
  </span>
1059
1189
  </div>
1060
1190
  <div className="mt-1 text-xs text-gray-600 font-mono truncate">
1061
- {event.type === "CLICK" &&
1062
- `(${event.x}, ${event.y}) on ${event.target}`}
1191
+ {event.type === "CLICK" && `(${event.x}, ${event.y}) on ${event.target}`}
1063
1192
  {event.type === "SCROLL" &&
1064
1193
  `${event.direction} ${event.distance}px to (${event.x}, ${event.y})`}
1065
- {event.type === "KEY" &&
1066
- `${event.key} in ${event.target}`}
1194
+ {event.type === "KEY" && `${event.key} in ${event.target}`}
1067
1195
  </div>
1068
1196
  </div>
1069
1197
  ))
@@ -1076,15 +1204,11 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1076
1204
  {/* Filter Button */}
1077
1205
  <div className="relative" ref={filterDropdownRef}>
1078
1206
  <button
1207
+ type="button"
1079
1208
  onClick={() => setShowFilters(!showFilters)}
1080
1209
  className="flex items-center gap-1 px-3 py-1 rounded text-sm font-medium transition-colors whitespace-nowrap bg-gray-100 text-gray-700 hover:bg-gray-200"
1081
1210
  >
1082
- <svg
1083
- className="w-4 h-4"
1084
- fill="none"
1085
- stroke="currentColor"
1086
- viewBox="0 0 24 24"
1087
- >
1211
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1088
1212
  <path
1089
1213
  strokeLinecap="round"
1090
1214
  strokeLinejoin="round"
@@ -1096,99 +1220,169 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1096
1220
  </button>
1097
1221
  {/* Filter Dropdown */}
1098
1222
  {showFilters && (
1099
- <div className="absolute top-full right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-20 min-w-48">
1223
+ <div className="absolute top-full right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 min-w-48">
1100
1224
  <div className="py-2">
1101
- <div className="px-3 py-2 text-xs font-medium text-gray-500 border-b">
1225
+ <div className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
1102
1226
  Log Types
1103
1227
  </div>
1104
- {[
1105
- {
1106
- key: "server",
1107
- label: "Server",
1108
- count: logs.filter((l) => l.source === "SERVER")
1109
- .length,
1110
- },
1111
- {
1112
- key: "browser",
1113
- label: "Browser",
1114
- count: logs.filter((l) => l.source === "BROWSER")
1115
- .length,
1116
- },
1117
- {
1118
- key: "interaction",
1119
- label: "Interaction",
1120
- count: logs.filter((l) =>
1121
- l.message.includes("[INTERACTION]")
1122
- ).length,
1123
- },
1124
- {
1125
- key: "screenshot",
1126
- label: "Screenshot",
1127
- count: logs.filter((l) =>
1128
- l.message.includes("[SCREENSHOT]")
1129
- ).length,
1130
- },
1131
- ].map(({ key, label, count }) => (
1132
- <label
1133
- key={key}
1134
- className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer"
1135
- >
1136
- <div className="flex items-center gap-2">
1137
- <input
1138
- type="checkbox"
1139
- checked={filters[key as keyof typeof filters]}
1140
- onChange={(e) =>
1141
- setFilters((prev) => ({
1142
- ...prev,
1143
- [key]: e.target.checked,
1144
- }))
1145
- }
1146
- className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
1147
- />
1148
- <span>{label}</span>
1149
- </div>
1150
- <span className="text-xs text-gray-400">{count}</span>
1151
- </label>
1152
- ))}
1228
+
1229
+ {/* Server Logs */}
1230
+ <label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
1231
+ <div className="flex items-center gap-2">
1232
+ <input
1233
+ type="checkbox"
1234
+ checked={filters.server}
1235
+ onChange={(e) =>
1236
+ setFilters((prev) => ({
1237
+ ...prev,
1238
+ server: e.target.checked
1239
+ }))
1240
+ }
1241
+ className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
1242
+ />
1243
+ <span className="text-gray-900 dark:text-gray-100">Server</span>
1244
+ </div>
1245
+ <span className="text-xs text-gray-400">
1246
+ {logs.filter((l) => l.source === "SERVER").length}
1247
+ </span>
1248
+ </label>
1249
+
1250
+ {/* Browser Logs */}
1251
+ <label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
1252
+ <div className="flex items-center gap-2">
1253
+ <input
1254
+ type="checkbox"
1255
+ checked={filters.browser}
1256
+ onChange={(e) =>
1257
+ setFilters((prev) => ({
1258
+ ...prev,
1259
+ browser: e.target.checked
1260
+ }))
1261
+ }
1262
+ className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
1263
+ />
1264
+ <span className="text-gray-900 dark:text-gray-100">Browser</span>
1265
+ </div>
1266
+ <span className="text-xs text-gray-400">
1267
+ {logs.filter((l) => l.source === "BROWSER").length}
1268
+ </span>
1269
+ </label>
1270
+
1271
+ {/* User Agent Sub-filters */}
1272
+ {availableUserAgents.length > 1 && filters.browser && (
1273
+ <div className="ml-6 border-l border-gray-200 dark:border-gray-600 pl-2">
1274
+ {availableUserAgents.map((ua) => {
1275
+ const shortUA = ua.includes("Chrome")
1276
+ ? "Chrome"
1277
+ : ua.includes("Firefox")
1278
+ ? "Firefox"
1279
+ : ua.includes("Safari")
1280
+ ? "Safari"
1281
+ : ua.includes("Edge")
1282
+ ? "Edge"
1283
+ : "Browser"
1284
+ return (
1285
+ <label
1286
+ key={ua}
1287
+ className="flex items-center justify-between px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
1288
+ >
1289
+ <div className="flex items-center gap-2">
1290
+ <input
1291
+ type="checkbox"
1292
+ checked={userAgentFilters[ua] !== false}
1293
+ onChange={(e) =>
1294
+ setUserAgentFilters((prev) => ({
1295
+ ...prev,
1296
+ [ua]: e.target.checked
1297
+ }))
1298
+ }
1299
+ className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 w-3 h-3"
1300
+ />
1301
+ <span className="text-gray-700 dark:text-gray-300">{shortUA}</span>
1302
+ </div>
1303
+ <span className="text-xs text-gray-400">
1304
+ {logs.filter((l) => l.source === "BROWSER" && l.userAgent === ua).length}
1305
+ </span>
1306
+ </label>
1307
+ )
1308
+ })}
1309
+ </div>
1310
+ )}
1311
+
1312
+ {/* Interaction Logs */}
1313
+ <label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
1314
+ <div className="flex items-center gap-2">
1315
+ <input
1316
+ type="checkbox"
1317
+ checked={filters.interaction}
1318
+ onChange={(e) =>
1319
+ setFilters((prev) => ({
1320
+ ...prev,
1321
+ interaction: e.target.checked
1322
+ }))
1323
+ }
1324
+ className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
1325
+ />
1326
+ <span className="text-gray-900 dark:text-gray-100">Interaction</span>
1327
+ </div>
1328
+ <span className="text-xs text-gray-400">
1329
+ {logs.filter((l) => l.message.includes("[INTERACTION]")).length}
1330
+ </span>
1331
+ </label>
1332
+
1333
+ {/* Screenshot Logs */}
1334
+ <label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
1335
+ <div className="flex items-center gap-2">
1336
+ <input
1337
+ type="checkbox"
1338
+ checked={filters.screenshot}
1339
+ onChange={(e) =>
1340
+ setFilters((prev) => ({
1341
+ ...prev,
1342
+ screenshot: e.target.checked
1343
+ }))
1344
+ }
1345
+ className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
1346
+ />
1347
+ <span className="text-gray-900 dark:text-gray-100">Screenshot</span>
1348
+ </div>
1349
+ <span className="text-xs text-gray-400">
1350
+ {logs.filter((l) => l.message.includes("[SCREENSHOT]")).length}
1351
+ </span>
1352
+ </label>
1153
1353
  </div>
1154
1354
  </div>
1155
1355
  )}
1156
1356
  </div>
1157
1357
  <div className="flex items-center bg-gray-100 rounded-md p-1">
1158
1358
  <button
1359
+ type="button"
1159
1360
  onClick={() => {
1160
- const currentFile = searchParams.get("file");
1361
+ const currentFile = searchParams.get("file")
1161
1362
  if (currentFile) {
1162
- router.push(
1163
- `/logs?file=${encodeURIComponent(
1164
- currentFile
1165
- )}&mode=head`
1166
- );
1363
+ router.push(`/logs?file=${encodeURIComponent(currentFile)}&mode=head`)
1364
+ } else {
1365
+ router.push("/logs?mode=head")
1167
1366
  }
1168
1367
  }}
1169
1368
  className={`px-2 sm:px-3 py-1 rounded text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
1170
- mode === "head"
1171
- ? "bg-white text-gray-900 shadow-sm"
1172
- : "text-gray-600 hover:text-gray-900"
1369
+ mode === "head" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600 hover:text-gray-900"
1173
1370
  }`}
1174
1371
  >
1175
1372
  Head
1176
1373
  </button>
1177
1374
  <button
1375
+ type="button"
1178
1376
  onClick={() => {
1179
- const currentFile = searchParams.get("file");
1377
+ const currentFile = searchParams.get("file")
1180
1378
  if (currentFile) {
1181
- router.push(
1182
- `/logs?file=${encodeURIComponent(
1183
- currentFile
1184
- )}&mode=tail`
1185
- );
1379
+ router.push(`/logs?file=${encodeURIComponent(currentFile)}&mode=tail`)
1380
+ } else {
1381
+ router.push("/logs?mode=tail")
1186
1382
  }
1187
1383
  }}
1188
1384
  className={`px-2 sm:px-3 py-1 rounded text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
1189
- mode === "tail"
1190
- ? "bg-white text-gray-900 shadow-sm"
1191
- : "text-gray-600 hover:text-gray-900"
1385
+ mode === "tail" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600 hover:text-gray-900"
1192
1386
  }`}
1193
1387
  >
1194
1388
  Tail
@@ -1196,20 +1390,14 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1196
1390
  </div>
1197
1391
  {/* Dark Mode Toggle - moved to last item */}
1198
1392
  <button
1393
+ type="button"
1199
1394
  onClick={() => setDarkMode(!darkMode)}
1200
1395
  className="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ml-2"
1201
- title={
1202
- darkMode ? "Switch to light mode" : "Switch to dark mode"
1203
- }
1396
+ title={darkMode ? "Switch to light mode" : "Switch to dark mode"}
1204
1397
  >
1205
1398
  {darkMode ? (
1206
1399
  // Sun icon for light mode
1207
- <svg
1208
- className="w-4 h-4"
1209
- fill="none"
1210
- stroke="currentColor"
1211
- viewBox="0 0 24 24"
1212
- >
1400
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1213
1401
  <path
1214
1402
  strokeLinecap="round"
1215
1403
  strokeLinejoin="round"
@@ -1219,12 +1407,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1219
1407
  </svg>
1220
1408
  ) : (
1221
1409
  // Moon icon for dark mode
1222
- <svg
1223
- className="w-4 h-4"
1224
- fill="none"
1225
- stroke="currentColor"
1226
- viewBox="0 0 24 24"
1227
- >
1410
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1228
1411
  <path
1229
1412
  strokeLinecap="round"
1230
1413
  strokeLinejoin="round"
@@ -1241,11 +1424,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1241
1424
 
1242
1425
  {/* Content Area - Fills remaining space */}
1243
1426
  <div className="flex-1 overflow-hidden">
1244
- <div
1245
- ref={containerRef}
1246
- className="max-w-7xl mx-auto px-4 py-6 h-full overflow-y-auto"
1247
- onScroll={handleScroll}
1248
- >
1427
+ <div ref={containerRef} className="max-w-7xl mx-auto px-4 py-6 h-full overflow-y-auto" onScroll={handleScroll}>
1249
1428
  {isInitialLoading ? (
1250
1429
  <div className="text-center py-12">
1251
1430
  <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
@@ -1253,18 +1432,14 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1253
1432
  </div>
1254
1433
  ) : logs.length === 0 ? (
1255
1434
  <div className="text-center py-12">
1256
- <div className="text-gray-400 dark:text-gray-500 text-lg">
1257
- 📝 No logs yet
1258
- </div>
1435
+ <div className="text-gray-400 dark:text-gray-500 text-lg">📝 No logs yet</div>
1259
1436
  <div className="text-gray-500 dark:text-gray-400 text-sm mt-2">
1260
1437
  Logs will appear here as your development server runs
1261
1438
  </div>
1262
1439
  </div>
1263
1440
  ) : filteredLogs.length === 0 ? (
1264
1441
  <div className="text-center py-12">
1265
- <div className="text-gray-400 dark:text-gray-500 text-lg">
1266
- 🔍 No logs match current filters
1267
- </div>
1442
+ <div className="text-gray-400 dark:text-gray-500 text-lg">🔍 No logs match current filters</div>
1268
1443
  <div className="text-gray-500 dark:text-gray-400 text-sm mt-2">
1269
1444
  Try adjusting your filter settings to see more logs
1270
1445
  </div>
@@ -1272,7 +1447,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1272
1447
  ) : (
1273
1448
  <div className="space-y-1 pb-4">
1274
1449
  {filteredLogs.map((entry, index) => (
1275
- <LogEntryComponent key={index} entry={entry} />
1450
+ <LogEntryComponent key={`${entry.timestamp}-${index}`} entry={entry} />
1276
1451
  ))}
1277
1452
  <div ref={bottomRef} />
1278
1453
  </div>
@@ -1284,15 +1459,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1284
1459
  <div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-none">
1285
1460
  <div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
1286
1461
  <div className="flex items-center gap-3">
1287
- {isLoadingNew && (
1288
- <div className="flex items-center gap-1">
1289
- <div className="w-3 h-3 border border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
1290
- <span className="text-xs text-gray-500 dark:text-gray-400">
1291
- Loading...
1292
- </span>
1293
- </div>
1294
- )}
1295
- {!isLoadingNew && lastFetched && (
1462
+ {lastFetched && (
1296
1463
  <span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
1297
1464
  Last updated {lastFetched.toLocaleTimeString()}
1298
1465
  </span>
@@ -1314,9 +1481,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1314
1481
  {/* Live indicator when at bottom */}
1315
1482
  <div
1316
1483
  className={`flex items-center gap-1 text-green-600 ${
1317
- mode === "tail" && isAtBottom && !isLoadingNew
1318
- ? "visible"
1319
- : "invisible"
1484
+ mode === "tail" && isAtBottom ? "visible" : "invisible"
1320
1485
  }`}
1321
1486
  >
1322
1487
  <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
@@ -1325,13 +1490,10 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1325
1490
 
1326
1491
  {/* Scroll to bottom button when not at bottom */}
1327
1492
  <button
1328
- onClick={() =>
1329
- bottomRef.current?.scrollIntoView({ behavior: "smooth" })
1330
- }
1493
+ type="button"
1494
+ onClick={() => bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
1331
1495
  className={`absolute top-0 right-0 flex items-center gap-1 px-2 py-0.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 whitespace-nowrap ${
1332
- mode === "tail" && !isAtBottom && !isLoadingNew
1333
- ? "visible"
1334
- : "invisible"
1496
+ mode === "tail" && !isAtBottom ? "visible" : "invisible"
1335
1497
  }`}
1336
1498
  >
1337
1499
  ↓ Live
@@ -1340,5 +1502,5 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
1340
1502
  </div>
1341
1503
  </div>
1342
1504
  </div>
1343
- );
1505
+ )
1344
1506
  }