@raingor/pi-web-switch 0.4.1 → 0.4.2

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.
@@ -1,617 +0,0 @@
1
- // ChatPage — full chat interface with session sidebar.
2
- // Combines a session list sidebar with the ChatWindow.
3
-
4
- import { useState, useCallback, useEffect, useRef } from "react";
5
- import { ChatWindow } from "@/components/chat/ChatWindow";
6
- import type { SessionInfo, SessionStatsInfo, ContextUsage, ChatInputHandle } from "@/types/chat";
7
- import { useTranslation } from "@/lib/i18n";
8
-
9
- interface ProjectGroup {
10
- projectRoot: string;
11
- sessions: SessionInfo[];
12
- }
13
-
14
- function groupSessionsByProject(sessions: SessionInfo[]): ProjectGroup[] {
15
- const groups = new Map<string, SessionInfo[]>();
16
- for (const s of sessions) {
17
- const key = s.projectRoot ?? s.cwd ?? "Unknown";
18
- if (!groups.has(key)) groups.set(key, []);
19
- groups.get(key)!.push(s);
20
- }
21
- return [...groups.entries()].map(([projectRoot, sessions]) => ({
22
- projectRoot,
23
- sessions: sessions.sort((a, b) => b.modified.localeCompare(a.modified)),
24
- }));
25
- }
26
-
27
- function getProjectName(projectRoot: string): string {
28
- const parts = projectRoot.replace(/\/+$/, "").split("/");
29
- return parts[parts.length - 1] || projectRoot;
30
- }
31
-
32
- function formatRelativeTime(iso: string, t: (key: string, ...args: string[]) => string): string {
33
- const d = new Date(iso);
34
- const now = new Date();
35
- const diffMs = now.getTime() - d.getTime();
36
- const diffMin = Math.floor(diffMs / 60000);
37
- const diffHr = Math.floor(diffMin / 60);
38
- const diffDay = Math.floor(diffHr / 24);
39
- if (diffMin < 1) return t("chat.just_now");
40
- if (diffMin < 60) return t("chat.min_ago", String(diffMin));
41
- if (diffHr < 24) return t("chat.hr_ago", String(diffHr));
42
- if (diffDay < 7) return t("chat.day_ago", String(diffDay));
43
- return d.toLocaleDateString();
44
- }
45
-
46
- export function ChatPage() {
47
- const { t } = useTranslation();
48
- const [sessions, setSessions] = useState<SessionInfo[]>([]);
49
- const [selectedSession, setSelectedSession] = useState<SessionInfo | null>(null);
50
- const [newSessionCwd, setNewSessionCwd] = useState<string | null>(null);
51
- const [loading, setLoading] = useState(true);
52
- const [refreshKey, setRefreshKey] = useState(0);
53
- const [showCwdPicker, setShowCwdPicker] = useState(false);
54
- const [cwdInput, setCwdInput] = useState("");
55
- const [cwdError, setCwdError] = useState<string | null>(null);
56
- const [browsePath, setBrowsePath] = useState("");
57
- const [browseItems, setBrowseItems] = useState<{ name: string; isDirectory: boolean; path: string }[]>([]);
58
- const [sessionStats, setSessionStats] = useState<SessionStatsInfo | null>(null);
59
- const [contextUsage, setContextUsage] = useState<ContextUsage | null>(null);
60
- const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
61
- const chatInputRef = useRef<ChatInputHandle | null>(null);
62
-
63
- // ─── Load sessions ─────────────────────────────────────
64
-
65
- useEffect(() => {
66
- fetch("/api/chat/sessions")
67
- .then((r) => r.json())
68
- .then((data: { sessions: SessionInfo[] }) => {
69
- setSessions(data.sessions ?? []);
70
- setLoading(false);
71
- })
72
- .catch(() => setLoading(false));
73
- }, [refreshKey]);
74
-
75
- // ─── Browse directories ────────────────────────────────
76
-
77
- const browse = useCallback((path: string) => {
78
- setBrowsePath(path);
79
- fetch(`/api/chat/cwd/browse?path=${encodeURIComponent(path)}`)
80
- .then((r) => r.json())
81
- .then((data: { path: string; items: { name: string; isDirectory: boolean; path: string }[] }) => {
82
- setBrowseItems(data.items ?? []);
83
- })
84
- .catch(() => setBrowseItems([]));
85
- }, []);
86
-
87
- useEffect(() => {
88
- // Load home directory on mount
89
- fetch("/api/chat/home")
90
- .then((r) => r.json())
91
- .then((data: { home: string }) => {
92
- setCwdInput(data.home);
93
- browse(data.home);
94
- })
95
- .catch(() => {});
96
- }, [browse]);
97
-
98
- // ─── Session selection ─────────────────────────────────
99
-
100
- const handleSelectSession = useCallback((session: SessionInfo) => {
101
- setNewSessionCwd(null);
102
- setSelectedSession(session);
103
- setSessionStats(null);
104
- setContextUsage(null);
105
- }, []);
106
-
107
- const handleNewSession = useCallback(() => {
108
- setShowCwdPicker(true);
109
- }, []);
110
-
111
- const handleConfirmCwd = useCallback(async () => {
112
- setCwdError(null);
113
- try {
114
- const res = await fetch("/api/chat/cwd/validate", {
115
- method: "POST",
116
- headers: { "Content-Type": "application/json" },
117
- body: JSON.stringify({ cwd: cwdInput }),
118
- });
119
- if (!res.ok) {
120
- const data = await res.json();
121
- throw new Error(data.error ?? t("chat.invalid_directory"));
122
- }
123
- const data = await res.json();
124
- setSelectedSession(null);
125
- setNewSessionCwd(data.cwd);
126
- setShowCwdPicker(false);
127
- setSessionStats(null);
128
- setContextUsage(null);
129
- } catch (e) {
130
- setCwdError(e instanceof Error ? e.message : String(e));
131
- }
132
- }, [cwdInput, t]);
133
-
134
- const handleSessionCreated = useCallback((session: SessionInfo) => {
135
- setSelectedSession(session);
136
- setNewSessionCwd(null);
137
- setRefreshKey((k) => k + 1);
138
- }, []);
139
-
140
- const handleAgentEnd = useCallback(() => {
141
- setRefreshKey((k) => k + 1);
142
- }, []);
143
-
144
- // ─── Delete session ────────────────────────────────────
145
-
146
- const handleDeleteSession = useCallback(async (sessionId: string, e: React.MouseEvent) => {
147
- e.stopPropagation();
148
- if (!confirm(t("chat.delete_confirm"))) return;
149
- try {
150
- await fetch(`/api/chat/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
151
- setRefreshKey((k) => k + 1);
152
- if (selectedSession?.id === sessionId) {
153
- setSelectedSession(null);
154
- }
155
- } catch {
156
- // ignore
157
- }
158
- }, [selectedSession]);
159
-
160
- // ─── Render ────────────────────────────────────────────
161
-
162
- const projectGroups = groupSessionsByProject(sessions);
163
-
164
- // ─── Expand / Collapse all groups ──────────────────────
165
-
166
- const expandAll = useCallback(() => {
167
- setCollapsedGroups(new Set());
168
- }, []);
169
-
170
- const collapseAll = useCallback(() => {
171
- setCollapsedGroups(new Set(projectGroups.map((g) => g.projectRoot)));
172
- }, [projectGroups]);
173
-
174
- const effectiveCwd = selectedSession?.cwd ?? newSessionCwd;
175
- const showChat = selectedSession !== null || newSessionCwd !== null;
176
-
177
- return (
178
- <div style={{ display: "flex", height: "100%", overflow: "hidden" }}>
179
- {/* Session Sidebar */}
180
- <div style={{
181
- width: 280,
182
- flexShrink: 0,
183
- borderRight: "1px solid var(--border)",
184
- background: "var(--bg-panel)",
185
- display: "flex",
186
- flexDirection: "column",
187
- overflow: "hidden",
188
- }}>
189
- {/* Header */}
190
- <div style={{
191
- padding: "12px 14px",
192
- borderBottom: "1px solid var(--border)",
193
- display: "flex",
194
- alignItems: "center",
195
- justifyContent: "space-between",
196
- }}>
197
- <span style={{ fontSize: 14, fontWeight: 600, color: "var(--text)" }}>{t("chat.sessions")}</span>
198
- <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
199
- {projectGroups.length > 0 && (() => {
200
- const allExpanded = collapsedGroups.size === 0;
201
- return (
202
- <button
203
- onClick={allExpanded ? collapseAll : expandAll}
204
- title={allExpanded ? t("chat.collapse_all") : t("chat.expand_all")}
205
- style={{
206
- display: "flex",
207
- alignItems: "center",
208
- gap: 3,
209
- height: 26,
210
- padding: "0 8px",
211
- borderRadius: 6,
212
- border: "1px solid var(--border)",
213
- background: "var(--bg)",
214
- color: "var(--text-muted)",
215
- cursor: "pointer",
216
- fontSize: 11,
217
- fontWeight: 500,
218
- }}
219
- onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
220
- onMouseLeave={(e) => { e.currentTarget.style.background = "var(--bg)"; }}
221
- >
222
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: allExpanded ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
223
- <polyline points="6 9 12 15 18 9" />
224
- </svg>
225
- {allExpanded ? t("chat.collapse_all") : t("chat.expand_all")}
226
- </button>
227
- );
228
- })()}
229
- <button
230
- onClick={handleNewSession}
231
- title={t("chat.new_session")}
232
- style={{
233
- display: "flex",
234
- alignItems: "center",
235
- justifyContent: "center",
236
- width: 28,
237
- height: 28,
238
- borderRadius: 6,
239
- border: "none",
240
- background: "var(--accent)",
241
- color: "#fff",
242
- cursor: "pointer",
243
- fontSize: 18,
244
- }}
245
- >
246
- +
247
- </button>
248
- </div>
249
- </div>
250
-
251
- {/* Session list */}
252
- <div style={{ flex: 1, overflowY: "auto" }}>
253
- {loading ? (
254
- <div style={{ padding: 16, fontSize: 13, color: "var(--text-muted)" }}>{t("chat.loading")}</div>
255
- ) : projectGroups.length === 0 ? (
256
- <div style={{ padding: 16, fontSize: 13, color: "var(--text-muted)" }}>
257
- {t("chat.no_sessions")}
258
- </div>
259
- ) : (
260
- projectGroups.map((group) => {
261
- const isCollapsed = collapsedGroups.has(group.projectRoot);
262
- return (
263
- <div key={group.projectRoot}>
264
- {/* Project header - clickable to collapse/expand */}
265
- <button
266
- onClick={() => {
267
- setCollapsedGroups((prev) => {
268
- const next = new Set(prev);
269
- if (next.has(group.projectRoot)) {
270
- next.delete(group.projectRoot);
271
- } else {
272
- next.add(group.projectRoot);
273
- }
274
- return next;
275
- });
276
- }}
277
- style={{
278
- padding: "6px 14px",
279
- fontSize: 11,
280
- fontWeight: 600,
281
- color: "var(--text-dim)",
282
- textTransform: "uppercase",
283
- letterSpacing: "0.05em",
284
- background: "var(--bg)",
285
- width: "100%",
286
- border: "none",
287
- cursor: "pointer",
288
- display: "flex",
289
- alignItems: "center",
290
- justifyContent: "space-between",
291
- textAlign: "left",
292
- }}
293
- onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
294
- onMouseLeave={(e) => { e.currentTarget.style.background = "var(--bg)"; }}
295
- >
296
- <span>{getProjectName(group.projectRoot)}</span>
297
- <span style={{ display: "flex", alignItems: "center", gap: 4 }}>
298
- <span style={{ fontSize: 10, opacity: 0.7 }}>{group.sessions.length}</span>
299
- <svg
300
- width="12"
301
- height="12"
302
- viewBox="0 0 24 24"
303
- fill="none"
304
- stroke="currentColor"
305
- strokeWidth="2"
306
- strokeLinecap="round"
307
- strokeLinejoin="round"
308
- style={{
309
- transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)",
310
- transition: "transform 0.2s",
311
- }}
312
- >
313
- <polyline points="6 9 12 15 18 9" />
314
- </svg>
315
- </span>
316
- </button>
317
- {/* Sessions */}
318
- {!isCollapsed && group.sessions.map((s) => (
319
- <div
320
- key={s.id}
321
- onClick={() => handleSelectSession(s)}
322
- style={{
323
- padding: "8px 14px",
324
- cursor: "pointer",
325
- background: selectedSession?.id === s.id ? "var(--bg-selected)" : "transparent",
326
- borderLeft: selectedSession?.id === s.id ? "3px solid var(--accent)" : "3px solid transparent",
327
- transition: "background 0.1s",
328
- }}
329
- onMouseEnter={(e) => {
330
- if (selectedSession?.id !== s.id) e.currentTarget.style.background = "var(--bg-hover)";
331
- }}
332
- onMouseLeave={(e) => {
333
- if (selectedSession?.id !== s.id) e.currentTarget.style.background = "transparent";
334
- }}
335
- >
336
- <div style={{
337
- fontSize: 13,
338
- color: selectedSession?.id === s.id ? "var(--text)" : "var(--text-muted)",
339
- overflow: "hidden",
340
- textOverflow: "ellipsis",
341
- whiteSpace: "nowrap",
342
- }}>
343
- {s.name || s.firstMessage?.slice(0, 50) || t("chat.untitled")}
344
- </div>
345
- <div style={{
346
- fontSize: 11,
347
- color: "var(--text-dim)",
348
- marginTop: 2,
349
- display: "flex",
350
- alignItems: "center",
351
- gap: 6,
352
- }}>
353
- <span>{formatRelativeTime(s.modified, t)}</span>
354
- <span>·</span>
355
- <span>{t("chat.msg_count", String(s.messageCount))}</span>
356
- <button
357
- onClick={(e) => handleDeleteSession(s.id, e)}
358
- title={t("chat.delete")}
359
- style={{
360
- marginLeft: "auto",
361
- background: "none",
362
- border: "none",
363
- color: "var(--text-dim)",
364
- cursor: "pointer",
365
- fontSize: 12,
366
- padding: 0,
367
- }}
368
- >
369
- ×
370
- </button>
371
- </div>
372
- </div>
373
- ))}
374
- </div>
375
- );
376
- })
377
- )}
378
- </div>
379
- </div>
380
-
381
- {/* Chat area */}
382
- <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", position: "relative" }}>
383
- {/* Top bar */}
384
- {showChat && (
385
- <div style={{
386
- display: "flex",
387
- alignItems: "center",
388
- gap: 12,
389
- padding: "8px 16px",
390
- borderBottom: "1px solid var(--border)",
391
- background: "var(--bg-panel)",
392
- flexShrink: 0,
393
- }}>
394
- <span style={{ fontSize: 13, color: "var(--text-muted)", fontFamily: "var(--font-mono, monospace)" }}>
395
- {effectiveCwd}
396
- </span>
397
- {/* Session stats */}
398
- {sessionStats && (
399
- <div style={{ marginLeft: "auto", display: "flex", gap: 12, fontSize: 11, color: "var(--text-muted)" }}>
400
- {sessionStats.tokens.input > 0 && (
401
- <span title="Input tokens">↑{sessionStats.tokens.input.toLocaleString()}</span>
402
- )}
403
- {sessionStats.tokens.output > 0 && (
404
- <span title="Output tokens">↓{sessionStats.tokens.output.toLocaleString()}</span>
405
- )}
406
- {sessionStats.cost > 0 && (
407
- <span title="Cost">${sessionStats.cost.toFixed(4)}</span>
408
- )}
409
- {contextUsage?.contextWindow && (
410
- <span
411
- title="Context usage"
412
- style={{
413
- color: contextUsage.percent && contextUsage.percent > 90 ? "#ef4444"
414
- : contextUsage.percent && contextUsage.percent > 70 ? "rgba(234,179,8,0.95)"
415
- : undefined,
416
- }}
417
- >
418
- {contextUsage.percent ? `${contextUsage.percent.toFixed(0)}%` : "?"} / {contextUsage.contextWindow.toLocaleString()}
419
- </span>
420
- )}
421
- </div>
422
- )}
423
- </div>
424
- )}
425
-
426
- {/* Chat window or placeholder */}
427
- <div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
428
- {showChat ? (
429
- <ChatWindow
430
- session={selectedSession}
431
- newSessionCwd={newSessionCwd}
432
- onAgentEnd={handleAgentEnd}
433
- onSessionCreated={handleSessionCreated}
434
- chatInputRef={chatInputRef}
435
- onSessionStatsChange={setSessionStats}
436
- onContextUsageChange={setContextUsage}
437
- />
438
- ) : (
439
- <div style={{
440
- height: "100%",
441
- display: "flex",
442
- flexDirection: "column",
443
- alignItems: "center",
444
- justifyContent: "center",
445
- padding: 24,
446
- color: "var(--text-muted)",
447
- }}>
448
- <div style={{ fontSize: 48, marginBottom: 16, opacity: 0.3 }}>π</div>
449
- <div style={{ fontSize: 18, fontWeight: 600, color: "var(--text)", marginBottom: 8 }}>
450
- {t("chat.welcome_title")}
451
- </div>
452
- <div style={{ fontSize: 14, textAlign: "center", maxWidth: 400, lineHeight: 1.6 }}>
453
- {t("chat.welcome_desc")}
454
- </div>
455
- </div>
456
- )}
457
- </div>
458
-
459
- {/* CWD Picker Modal */}
460
- {showCwdPicker && (
461
- <div style={{
462
- position: "absolute",
463
- inset: 0,
464
- zIndex: 100,
465
- display: "flex",
466
- alignItems: "center",
467
- justifyContent: "center",
468
- background: "rgba(0,0,0,0.3)",
469
- }}>
470
- <div style={{
471
- width: "min(560px, 90%)",
472
- maxHeight: "80%",
473
- background: "var(--bg-panel)",
474
- border: "1px solid var(--border)",
475
- borderRadius: 12,
476
- display: "flex",
477
- flexDirection: "column",
478
- overflow: "hidden",
479
- boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
480
- }}>
481
- {/* Header */}
482
- <div style={{
483
- padding: "12px 16px",
484
- borderBottom: "1px solid var(--border)",
485
- fontSize: 14,
486
- fontWeight: 600,
487
- color: "var(--text)",
488
- }}>
489
- {t("chat.select_cwd")}
490
- </div>
491
-
492
- {/* CWD input */}
493
- <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
494
- <input
495
- value={cwdInput}
496
- onChange={(e) => {
497
- setCwdInput(e.target.value);
498
- setCwdError(null);
499
- }}
500
- onKeyDown={(e) => {
501
- if (e.key === "Enter") handleConfirmCwd();
502
- if (e.key === "Escape") setShowCwdPicker(false);
503
- }}
504
- placeholder="/path/to/project"
505
- style={{
506
- width: "100%",
507
- padding: "8px 12px",
508
- borderRadius: 8,
509
- border: "1px solid var(--border)",
510
- background: "var(--bg)",
511
- color: "var(--text)",
512
- fontSize: 13,
513
- fontFamily: "var(--font-mono, monospace)",
514
- outline: "none",
515
- }}
516
- />
517
- {cwdError && (
518
- <div style={{ marginTop: 6, fontSize: 12, color: "#dc2626" }}>{cwdError}</div>
519
- )}
520
- </div>
521
-
522
- {/* Directory browser */}
523
- <div style={{ flex: 1, overflow: "auto", padding: "4px 0" }}>
524
- {/* Current path breadcrumb */}
525
- <div style={{
526
- padding: "4px 16px",
527
- fontSize: 11,
528
- color: "var(--text-dim)",
529
- fontFamily: "var(--font-mono, monospace)",
530
- borderBottom: "1px solid var(--border)",
531
- }}>
532
- {browsePath}
533
- </div>
534
- {/* Parent directory */}
535
- <div
536
- onClick={() => {
537
- const parent = browsePath.replace(/\/[^/]+\/?$/, "") || "/";
538
- browse(parent);
539
- setCwdInput(parent);
540
- }}
541
- style={{
542
- padding: "6px 16px",
543
- cursor: "pointer",
544
- fontSize: 13,
545
- color: "var(--text-muted)",
546
- }}
547
- >
548
- ../
549
- </div>
550
- {/* Items */}
551
- {browseItems.filter((item) => item.isDirectory).map((item) => (
552
- <div
553
- key={item.path}
554
- onClick={() => {
555
- browse(item.path);
556
- setCwdInput(item.path);
557
- }}
558
- style={{
559
- padding: "6px 16px",
560
- cursor: "pointer",
561
- fontSize: 13,
562
- color: "var(--text)",
563
- }}
564
- onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
565
- onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
566
- >
567
- <span style={{ marginRight: 6 }}>📁</span>
568
- {item.name}
569
- </div>
570
- ))}
571
- </div>
572
-
573
- {/* Footer */}
574
- <div style={{
575
- padding: "10px 16px",
576
- borderTop: "1px solid var(--border)",
577
- display: "flex",
578
- justifyContent: "flex-end",
579
- gap: 8,
580
- }}>
581
- <button
582
- onClick={() => setShowCwdPicker(false)}
583
- style={{
584
- padding: "6px 14px",
585
- borderRadius: 6,
586
- border: "1px solid var(--border)",
587
- background: "var(--bg)",
588
- color: "var(--text-muted)",
589
- cursor: "pointer",
590
- fontSize: 13,
591
- }}
592
- >
593
- {t("chat.cancel")}
594
- </button>
595
- <button
596
- onClick={handleConfirmCwd}
597
- style={{
598
- padding: "6px 14px",
599
- borderRadius: 6,
600
- border: "none",
601
- background: "var(--accent)",
602
- color: "#fff",
603
- cursor: "pointer",
604
- fontSize: 13,
605
- fontWeight: 500,
606
- }}
607
- >
608
- {t("chat.start_chat")}
609
- </button>
610
- </div>
611
- </div>
612
- </div>
613
- )}
614
- </div>
615
- </div>
616
- );
617
- }