@raingor/pi-web-switch 0.4.0 → 0.4.1

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.
@@ -0,0 +1,595 @@
1
+ // MessageView — renders a single chat message (user, assistant, tool result, bash).
2
+ // Ported and simplified from pi-web's components/MessageView.tsx.
3
+
4
+ import { memo, useState, type ReactNode } from "react";
5
+ import type {
6
+ AgentMessage,
7
+ AssistantMessage,
8
+ ToolResultMessage,
9
+ ToolCallContent,
10
+ TextContent,
11
+ ThinkingContent,
12
+ ImageContent,
13
+ BashExecutionMessage,
14
+ CustomMessage,
15
+ } from "@/types/chat";
16
+
17
+ interface Props {
18
+ message: AgentMessage;
19
+ isStreaming?: boolean;
20
+ modelNames?: Record<string, string>;
21
+ cwd?: string;
22
+ onOpenFile?: (filePath: string) => void;
23
+ entryId?: string;
24
+ onFork?: (entryId: string) => void;
25
+ forking?: boolean;
26
+ onEditContent?: (content: string) => void;
27
+ showTimestamp?: boolean;
28
+ sessionId?: string;
29
+ }
30
+
31
+ function formatTimestamp(ts?: number): string {
32
+ if (!ts) return "";
33
+ const d = new Date(ts);
34
+ return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
35
+ }
36
+
37
+ function extractText(content: string | (TextContent | ImageContent)[]): string {
38
+ if (typeof content === "string") return content;
39
+ return content
40
+ .filter((b): b is TextContent => b.type === "text")
41
+ .map((b) => b.text)
42
+ .join("\n");
43
+ }
44
+
45
+ // ─── Tool Call Block ─────────────────────────────────────
46
+
47
+ function ToolCallBlock({ block }: { block: ToolCallContent }) {
48
+ const [expanded, setExpanded] = useState(false);
49
+ const inputStr = JSON.stringify(block.input, null, 2);
50
+
51
+ return (
52
+ <div style={{
53
+ border: "1px solid var(--border, #e5e7eb)",
54
+ borderRadius: 8,
55
+ background: "var(--bg-panel, #f9fafb)",
56
+ overflow: "hidden",
57
+ margin: "8px 0",
58
+ }}>
59
+ <button
60
+ onClick={() => setExpanded(!expanded)}
61
+ style={{
62
+ display: "flex",
63
+ alignItems: "center",
64
+ gap: 8,
65
+ width: "100%",
66
+ padding: "8px 12px",
67
+ background: "none",
68
+ border: "none",
69
+ cursor: "pointer",
70
+ textAlign: "left",
71
+ color: "var(--text-muted, #6b7280)",
72
+ fontSize: 13,
73
+ }}
74
+ >
75
+ <svg
76
+ width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6"
77
+ strokeLinecap="round" strokeLinejoin="round"
78
+ style={{ transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s", flexShrink: 0 }}
79
+ >
80
+ <polyline points="4 2.5 7.5 6 4 9.5" />
81
+ </svg>
82
+ <span style={{ fontWeight: 600, color: "var(--accent, #2563eb)" }}>{block.toolName}</span>
83
+ <span style={{ fontSize: 11, opacity: 0.6 }}>tool call</span>
84
+ </button>
85
+ {expanded && (
86
+ <pre style={{
87
+ margin: 0,
88
+ padding: "8px 12px",
89
+ borderTop: "1px solid var(--border, #e5e7eb)",
90
+ fontSize: 12,
91
+ fontFamily: "var(--font-mono, monospace)",
92
+ color: "var(--text-muted, #6b7280)",
93
+ whiteSpace: "pre-wrap",
94
+ wordBreak: "break-word",
95
+ overflow: "auto",
96
+ maxHeight: 300,
97
+ }}>
98
+ {inputStr}
99
+ </pre>
100
+ )}
101
+ </div>
102
+ );
103
+ }
104
+
105
+ // ─── Thinking Block ──────────────────────────────────────
106
+
107
+ function ThinkingBlock({ block }: { block: ThinkingContent }) {
108
+ const [expanded, setExpanded] = useState(false);
109
+ if (!block.thinking && block.deferred) {
110
+ return (
111
+ <div style={{ margin: "6px 0", fontSize: 12, color: "var(--text-dim)", fontStyle: "italic" }}>
112
+ Thinking (collapsed)
113
+ </div>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <div style={{ margin: "8px 0" }}>
119
+ <button
120
+ onClick={() => setExpanded(!expanded)}
121
+ style={{
122
+ display: "flex",
123
+ alignItems: "center",
124
+ gap: 6,
125
+ background: "none",
126
+ border: "none",
127
+ cursor: "pointer",
128
+ fontSize: 12,
129
+ color: "var(--text-muted, #6b7280)",
130
+ }}
131
+ >
132
+ <svg
133
+ width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6"
134
+ strokeLinecap="round" strokeLinejoin="round"
135
+ style={{ transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}
136
+ >
137
+ <polyline points="4 2.5 7.5 6 4 9.5" />
138
+ </svg>
139
+ Thinking
140
+ </button>
141
+ {expanded && (
142
+ <div style={{
143
+ marginTop: 6,
144
+ padding: "8px 12px",
145
+ borderLeft: "2px solid var(--border, #e5e7eb)",
146
+ fontSize: 13,
147
+ color: "var(--text-muted, #6b7280)",
148
+ whiteSpace: "pre-wrap",
149
+ fontFamily: "var(--font-mono, monospace)",
150
+ lineHeight: 1.6,
151
+ }}>
152
+ {block.thinking}
153
+ </div>
154
+ )}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // ─── Image Block ─────────────────────────────────────────
160
+
161
+ function ImageBlock({ block }: { block: ImageContent }) {
162
+ const src = block.source.type === "url"
163
+ ? block.source.url
164
+ : `data:${block.source.media_type ?? "image/png"};base64,${block.source.data}`;
165
+ return (
166
+ <img
167
+ src={src}
168
+ alt="attachment"
169
+ style={{
170
+ maxWidth: "100%",
171
+ maxHeight: 400,
172
+ borderRadius: 8,
173
+ margin: "8px 0",
174
+ }}
175
+ />
176
+ );
177
+ }
178
+
179
+ // ─── Tool Result Block ───────────────────────────────────
180
+
181
+ function ToolResultView({ message }: { message: ToolResultMessage }) {
182
+ const [expanded, setExpanded] = useState(false);
183
+ const textContent = message.content
184
+ .filter((b): b is TextContent => b.type === "text")
185
+ .map((b) => b.text)
186
+ .join("\n");
187
+
188
+ const truncated = textContent.length > 500;
189
+ const displayText = expanded || !truncated ? textContent : textContent.slice(0, 500) + "...";
190
+
191
+ return (
192
+ <div style={{
193
+ margin: "4px 0 8px 0",
194
+ padding: "8px 12px",
195
+ borderRadius: 8,
196
+ background: message.isError ? "rgba(239,68,68,0.06)" : "var(--bg-panel, #f9fafb)",
197
+ border: `1px solid ${message.isError ? "rgba(239,68,68,0.2)" : "var(--border, #e5e7eb)"}`,
198
+ fontSize: 13,
199
+ }}>
200
+ {message.toolName && (
201
+ <div style={{
202
+ fontSize: 11,
203
+ fontWeight: 600,
204
+ color: message.isError ? "#dc2626" : "var(--text-muted, #6b7280)",
205
+ marginBottom: 4,
206
+ }}>
207
+ {message.toolName} result {message.isError ? "(error)" : ""}
208
+ </div>
209
+ )}
210
+ <pre style={{
211
+ margin: 0,
212
+ fontFamily: "var(--font-mono, monospace)",
213
+ color: message.isError ? "#dc2626" : "var(--text-muted, #6b7280)",
214
+ whiteSpace: "pre-wrap",
215
+ wordBreak: "break-word",
216
+ lineHeight: 1.5,
217
+ }}>
218
+ {displayText}
219
+ </pre>
220
+ {truncated && (
221
+ <button
222
+ onClick={() => setExpanded(!expanded)}
223
+ style={{
224
+ marginTop: 4,
225
+ background: "none",
226
+ border: "none",
227
+ color: "var(--accent, #2563eb)",
228
+ cursor: "pointer",
229
+ fontSize: 12,
230
+ }}
231
+ >
232
+ {expanded ? "Show less" : `Show ${textContent.length - 500} more characters`}
233
+ </button>
234
+ )}
235
+ </div>
236
+ );
237
+ }
238
+
239
+ // ─── Bash Execution View ─────────────────────────────────
240
+
241
+ function BashExecutionView({ message }: { message: BashExecutionMessage }) {
242
+ return (
243
+ <div style={{
244
+ margin: "8px 0",
245
+ borderRadius: 8,
246
+ overflow: "hidden",
247
+ border: "1px solid var(--border, #e5e7eb)",
248
+ }}>
249
+ <div style={{
250
+ padding: "6px 12px",
251
+ background: "var(--bg-panel, #f9fafb)",
252
+ fontSize: 12,
253
+ fontFamily: "var(--font-mono, monospace)",
254
+ color: "var(--text-muted, #6b7280)",
255
+ display: "flex",
256
+ alignItems: "center",
257
+ gap: 6,
258
+ }}>
259
+ <span style={{ color: "var(--accent, #2563eb)" }}>$</span>
260
+ <span>{message.command}</span>
261
+ {message.exitCode !== undefined && (
262
+ <span style={{ marginLeft: "auto", color: message.exitCode === 0 ? "#10b981" : "#dc2626" }}>
263
+ exit {message.exitCode}
264
+ </span>
265
+ )}
266
+ </div>
267
+ {message.output && (
268
+ <pre style={{
269
+ margin: 0,
270
+ padding: "8px 12px",
271
+ fontSize: 12,
272
+ fontFamily: "var(--font-mono, monospace)",
273
+ color: "var(--text-muted, #6b7280)",
274
+ whiteSpace: "pre-wrap",
275
+ wordBreak: "break-word",
276
+ maxHeight: 300,
277
+ overflow: "auto",
278
+ }}>
279
+ {message.output}
280
+ </pre>
281
+ )}
282
+ </div>
283
+ );
284
+ }
285
+
286
+ // ─── Markdown Renderer (simplified) ──────────────────────
287
+
288
+ function renderMarkdown(text: string): ReactNode {
289
+ // Simple markdown rendering: code blocks, inline code, bold, links, line breaks
290
+ const parts: ReactNode[] = [];
291
+ const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
292
+ let lastIndex = 0;
293
+ let match;
294
+ let key = 0;
295
+
296
+ while ((match = codeBlockRegex.exec(text)) !== null) {
297
+ if (match.index > lastIndex) {
298
+ parts.push(renderInlineMarkdown(text.slice(lastIndex, match.index), `md-${key++}`));
299
+ }
300
+ const lang = match[1] || "text";
301
+ const code = match[2];
302
+ parts.push(
303
+ <div key={`code-${key++}`} style={{
304
+ margin: "8px 0",
305
+ borderRadius: 8,
306
+ overflow: "hidden",
307
+ border: "1px solid var(--border, #e5e7eb)",
308
+ }}>
309
+ <div style={{
310
+ padding: "4px 12px",
311
+ background: "var(--bg-panel, #f9fafb)",
312
+ fontSize: 11,
313
+ color: "var(--text-dim)",
314
+ fontFamily: "var(--font-mono, monospace)",
315
+ borderBottom: "1px solid var(--border, #e5e7eb)",
316
+ }}>
317
+ {lang}
318
+ </div>
319
+ <pre style={{
320
+ margin: 0,
321
+ padding: "10px 12px",
322
+ fontSize: 13,
323
+ fontFamily: "var(--font-mono, monospace)",
324
+ color: "var(--text, #1f2937)",
325
+ whiteSpace: "pre-wrap",
326
+ wordBreak: "break-word",
327
+ overflow: "auto",
328
+ maxHeight: 500,
329
+ }}>
330
+ <code>{code}</code>
331
+ </pre>
332
+ </div>
333
+ );
334
+ lastIndex = match.index + match[0].length;
335
+ }
336
+
337
+ if (lastIndex < text.length) {
338
+ parts.push(renderInlineMarkdown(text.slice(lastIndex), `md-${key++}`));
339
+ }
340
+
341
+ return <>{parts}</>;
342
+ }
343
+
344
+ function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode {
345
+ // Split by lines for paragraph rendering
346
+ const lines = text.split("\n");
347
+ return (
348
+ <div key={keyPrefix}>
349
+ {lines.map((line, i) => (
350
+ <div key={`${keyPrefix}-${i}`} style={{ minHeight: "1.5em" }}>
351
+ {renderLine(line, `${keyPrefix}-${i}`)}
352
+ </div>
353
+ ))}
354
+ </div>
355
+ );
356
+ }
357
+
358
+ function renderLine(line: string, keyPrefix: string): ReactNode {
359
+ // Handle inline code, bold, links
360
+ const parts: ReactNode[] = [];
361
+ const regex = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g;
362
+ let lastIndex = 0;
363
+ let match;
364
+ let key = 0;
365
+
366
+ while ((match = regex.exec(line)) !== null) {
367
+ if (match.index > lastIndex) {
368
+ parts.push(line.slice(lastIndex, match.index));
369
+ }
370
+ const token = match[0];
371
+ if (token.startsWith("`")) {
372
+ parts.push(
373
+ <code key={`${keyPrefix}-${key++}`} style={{
374
+ padding: "1px 5px",
375
+ borderRadius: 4,
376
+ background: "var(--bg-panel, #f3f4f6)",
377
+ fontSize: "0.9em",
378
+ fontFamily: "var(--font-mono, monospace)",
379
+ }}>
380
+ {token.slice(1, -1)}
381
+ </code>
382
+ );
383
+ } else if (token.startsWith("**")) {
384
+ parts.push(<strong key={`${keyPrefix}-${key++}`}>{token.slice(2, -2)}</strong>);
385
+ } else if (token.startsWith("[")) {
386
+ const linkMatch = token.match(/\[([^\]]+)\]\(([^)]+)\)/);
387
+ if (linkMatch) {
388
+ parts.push(
389
+ <a key={`${keyPrefix}-${key++}`} href={linkMatch[2]} target="_blank" rel="noopener noreferrer"
390
+ style={{ color: "var(--accent, #2563eb)", textDecoration: "underline" }}>
391
+ {linkMatch[1]}
392
+ </a>
393
+ );
394
+ }
395
+ }
396
+ lastIndex = match.index + token.length;
397
+ }
398
+
399
+ if (lastIndex < line.length) {
400
+ parts.push(line.slice(lastIndex));
401
+ }
402
+
403
+ return parts.length > 0 ? parts : (line === "" ? "\u200B" : line);
404
+ }
405
+
406
+ // ─── Main MessageView ────────────────────────────────────
407
+
408
+ function MessageViewImpl({
409
+ message,
410
+ isStreaming,
411
+ modelNames,
412
+ cwd,
413
+ onOpenFile,
414
+ entryId,
415
+ onFork,
416
+ forking,
417
+ onEditContent,
418
+ showTimestamp,
419
+ sessionId,
420
+ }: Props) {
421
+ if (message.role === "user") {
422
+ const text = typeof message.content === "string"
423
+ ? message.content
424
+ : extractText(message.content);
425
+ const images = Array.isArray(message.content)
426
+ ? message.content.filter((b): b is ImageContent => b.type === "image")
427
+ : [];
428
+
429
+ return (
430
+ <div style={{
431
+ display: "flex",
432
+ justifyContent: "flex-end",
433
+ marginBottom: 16,
434
+ }}>
435
+ <div style={{
436
+ maxWidth: "80%",
437
+ padding: "10px 14px",
438
+ borderRadius: "16px 16px 4px 16px",
439
+ background: "var(--accent, #2563eb)",
440
+ color: "#fff",
441
+ fontSize: 14,
442
+ lineHeight: 1.6,
443
+ wordBreak: "break-word",
444
+ }}>
445
+ {text && <div>{renderMarkdown(text)}</div>}
446
+ {images.map((img, i) => (
447
+ <ImageBlock key={i} block={img} />
448
+ ))}
449
+ </div>
450
+ </div>
451
+ );
452
+ }
453
+
454
+ if (message.role === "assistant") {
455
+ const am = message as AssistantMessage;
456
+ const blocks = am.content;
457
+ const modelLabel = am.provider && am.model
458
+ ? modelNames?.[`${am.provider}:${am.model}`] ?? am.model
459
+ : am.model;
460
+
461
+ return (
462
+ <div style={{ marginBottom: 16 }}>
463
+ <div style={{
464
+ display: "flex",
465
+ alignItems: "center",
466
+ gap: 8,
467
+ marginBottom: 4,
468
+ }}>
469
+ <span style={{
470
+ fontSize: 13,
471
+ fontWeight: 600,
472
+ color: "var(--text, #1f2937)",
473
+ }}>
474
+ π {modelLabel && <span style={{ fontWeight: 400, color: "var(--text-muted, #6b7280)", fontSize: 12 }}>· {modelLabel}</span>}
475
+ </span>
476
+ {showTimestamp && message.timestamp && (
477
+ <span style={{ fontSize: 11, color: "var(--text-dim)" }}>
478
+ {formatTimestamp(message.timestamp)}
479
+ </span>
480
+ )}
481
+ {onFork && entryId && !isStreaming && (
482
+ <button
483
+ onClick={() => onFork(entryId)}
484
+ disabled={forking}
485
+ title="Fork from here"
486
+ style={{
487
+ background: "none",
488
+ border: "none",
489
+ cursor: forking ? "wait" : "pointer",
490
+ color: "var(--text-dim)",
491
+ padding: 0,
492
+ fontSize: 11,
493
+ }}
494
+ >
495
+ {forking ? "⏳" : "⎇"}
496
+ </button>
497
+ )}
498
+ </div>
499
+ <div style={{
500
+ padding: "0 2px",
501
+ fontSize: 14,
502
+ lineHeight: 1.7,
503
+ color: "var(--text, #1f2937)",
504
+ }}>
505
+ {blocks.map((block, i) => {
506
+ switch (block.type) {
507
+ case "text":
508
+ return <div key={i}>{renderMarkdown(block.text)}</div>;
509
+ case "thinking":
510
+ return <ThinkingBlock key={i} block={block} />;
511
+ case "toolCall":
512
+ return <ToolCallBlock key={i} block={block} />;
513
+ case "image":
514
+ return <ImageBlock key={i} block={block} />;
515
+ default:
516
+ return null;
517
+ }
518
+ })}
519
+ {am.errorMessage && (
520
+ <div style={{
521
+ marginTop: 8,
522
+ padding: "8px 12px",
523
+ borderRadius: 8,
524
+ background: "rgba(239,68,68,0.06)",
525
+ color: "#dc2626",
526
+ fontSize: 13,
527
+ }}>
528
+ {am.errorMessage}
529
+ </div>
530
+ )}
531
+ {am.usage && (
532
+ <div style={{
533
+ marginTop: 6,
534
+ fontSize: 11,
535
+ color: "var(--text-dim)",
536
+ display: "flex",
537
+ gap: 12,
538
+ }}>
539
+ <span>in: {am.usage.input.toLocaleString()}</span>
540
+ <span>out: {am.usage.output.toLocaleString()}</span>
541
+ {am.usage.cacheRead > 0 && <span>cache: {am.usage.cacheRead.toLocaleString()}</span>}
542
+ {am.usage.cost?.total ? <span>${am.usage.cost.total.toFixed(4)}</span> : null}
543
+ </div>
544
+ )}
545
+ </div>
546
+ </div>
547
+ );
548
+ }
549
+
550
+ if (message.role === "toolResult") {
551
+ return <ToolResultView message={message as ToolResultMessage} />;
552
+ }
553
+
554
+ if (message.role === "bashExecution") {
555
+ return <BashExecutionView message={message as BashExecutionMessage} />;
556
+ }
557
+
558
+ if (message.role === "custom") {
559
+ const cm = message as CustomMessage;
560
+ if (!cm.display) return null;
561
+ const text = typeof cm.content === "string" ? cm.content : extractText(cm.content);
562
+ if (cm.customType === "compaction") {
563
+ return (
564
+ <div style={{
565
+ margin: "12px 0",
566
+ padding: "8px 14px",
567
+ borderRadius: 8,
568
+ background: "var(--bg-panel, #f9fafb)",
569
+ border: "1px solid var(--border, #e5e7eb)",
570
+ fontSize: 13,
571
+ color: "var(--text-muted, #6b7280)",
572
+ fontStyle: "italic",
573
+ }}>
574
+ {text}
575
+ </div>
576
+ );
577
+ }
578
+ return (
579
+ <div style={{
580
+ margin: "8px 0",
581
+ padding: "8px 14px",
582
+ borderRadius: 8,
583
+ background: "var(--bg-panel, #f9fafb)",
584
+ fontSize: 13,
585
+ color: "var(--text-muted, #6b7280)",
586
+ }}>
587
+ {text}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ return null;
593
+ }
594
+
595
+ export const MessageView = memo(MessageViewImpl);
@@ -69,6 +69,7 @@ interface UsageRangeData {
69
69
  }[];
70
70
  }
71
71
 
72
+ type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok";
72
73
  type RangeKey = "today" | "7d" | "30d" | "custom";
73
74
  type TabKey = "log" | "provider" | "model";
74
75
  type SortDir = "asc" | "desc";
@@ -270,6 +271,7 @@ export function DashboardPage() {
270
271
  const { t, lang } = useTranslation();
271
272
  const { currency, toggle: toggleCurrency } = useCurrency();
272
273
  const { initialized } = useConfigStore();
274
+ const [source, setSource] = useState<SourceKey>("pi");
273
275
  const [range, setRange] = useState<RangeKey>("today");
274
276
  const [customFrom, setCustomFrom] = useState("");
275
277
  const [customTo, setCustomTo] = useState("");
@@ -290,7 +292,15 @@ export function DashboardPage() {
290
292
 
291
293
  const fetchData = useCallback(() => {
292
294
  if (!initialized || customInvalid) return;
293
- let url = "/api/pi/usage-range?range=" + range;
295
+ let baseUrl = "/api/pi/usage-range";
296
+ if (source === "all") baseUrl = "/api/pi/all-usage-range";
297
+ else if (source === "cindy-pi") baseUrl = "/api/pi/cindy-usage-range";
298
+ else if (source === "claude") baseUrl = "/api/pi/claude-usage-range";
299
+ else if (source === "codex") baseUrl = "/api/pi/codex-usage-range";
300
+ else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
301
+ else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
302
+ else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
303
+ let url = `${baseUrl}?range=${range}`;
294
304
  if (range === "custom" && customFrom) {
295
305
  url += `&from=${customFrom}&to=${customTo || customFrom}`;
296
306
  }
@@ -308,19 +318,22 @@ export function DashboardPage() {
308
318
  // Previous period of equal length → period-over-period trend on stat cards
309
319
  const prev = getPrevRange(range);
310
320
  if (prev) {
311
- fetch(`/api/pi/usage-range?range=custom&from=${prev.from}&to=${prev.to}`)
321
+ fetch(`${baseUrl}?range=custom&from=${prev.from}&to=${prev.to}`)
312
322
  .then((r) => r.json())
313
323
  .then((p) => setPrevTotals({ tokens: p.totalTokens ?? 0, cost: p.totalCost ?? 0 }))
314
324
  .catch(() => setPrevTotals(null));
315
325
  } else {
316
326
  setPrevTotals(null);
317
327
  }
318
- }, [initialized, range, customFrom, customTo, customInvalid]);
328
+ }, [initialized, source, range, customFrom, customTo, customInvalid]);
319
329
 
320
330
  useEffect(() => { fetchData(); }, [fetchData]);
321
331
 
322
- // Reset request-log pagination when the queried range changes
323
- useEffect(() => { setLogPage(1); }, [range, customFrom, customTo]);
332
+ // Reset request-log pagination when the queried range or source changes
333
+ useEffect(() => { setLogPage(1); }, [range, customFrom, customTo, source]);
334
+
335
+ // Reset loading state when source changes
336
+ useEffect(() => { setLoading(true); }, [source]);
324
337
 
325
338
  // Auto-refresh with configurable interval (seconds)
326
339
  useEffect(() => {
@@ -384,6 +397,33 @@ export function DashboardPage() {
384
397
 
385
398
  return (
386
399
  <div className="space-y-5">
400
+ {/* Source Selector: Pi / Cindy-Pi */}
401
+ <div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
402
+ {([
403
+ { key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
404
+ { key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
405
+ { key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
406
+ { key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
407
+ { key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
408
+ { key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
409
+ { key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
410
+ { key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
411
+ ]).map((s) => (
412
+ <button
413
+ key={s.key}
414
+ onClick={() => setSource(s.key)}
415
+ className={cn(
416
+ "rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
417
+ source === s.key ? "text-white" : "hover:bg-gray-800/30"
418
+ )}
419
+ style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
420
+ >
421
+ <span>{s.icon}</span>
422
+ <span>{t(s.label)}</span>
423
+ </button>
424
+ ))}
425
+ </div>
426
+
387
427
  {/* Title + Time Range Selector + Currency Toggle */}
388
428
  <div className="flex items-center justify-between flex-wrap gap-3">
389
429
  <div>