@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,338 @@
1
+ // ChatWindow — main chat area with messages and input.
2
+ // Ported and simplified from pi-web's components/ChatWindow.tsx.
3
+
4
+ import { useCallback, useRef, useEffect, type ReactNode } from "react";
5
+ import type { AgentMessage, AssistantMessage, SessionInfo, SessionTreeNode } from "@/types/chat";
6
+ import { MessageView } from "./MessageView";
7
+ import { ChatInput } from "./ChatInput";
8
+ import type { ChatInputHandle } from "@/types/chat";
9
+ import { useAgentSession, type ThinkingLevelOption } from "@/hooks/useAgentSession";
10
+ import type { SessionStatsInfo, AgentPhase, NoticeItem } from "@/types/chat";
11
+ import { useTranslation } from "@/lib/i18n";
12
+
13
+ interface Props {
14
+ session: SessionInfo | null;
15
+ newSessionCwd: string | null;
16
+ onAgentEnd?: () => void;
17
+ onSessionCreated?: (session: SessionInfo) => void;
18
+ onSessionForked?: (newSessionId: string) => void;
19
+ modelsRefreshKey?: number;
20
+ chatInputRef?: React.RefObject<ChatInputHandle | null>;
21
+ onSessionStatsChange?: (stats: SessionStatsInfo | null) => void;
22
+ onContextUsageChange?: (usage: { percent: number | null; contextWindow: number; tokens: number | null } | null) => void;
23
+ }
24
+
25
+ function phaseLabel(phase: AgentPhase, t: (key: string, ...args: string[]) => string): string | null {
26
+ if (phase?.kind === "running_tools") {
27
+ const names = phase.tools.map((t) => t.name);
28
+ if (names.length === 0) return t("chat.running_tool");
29
+ if (names.length === 1) return t("chat.running_tool") + " " + names[0] + "...";
30
+ if (names.length <= 3) return t("chat.running_tool") + " " + names.join(", ") + "...";
31
+ return t("chat.running_tool") + " " + names.slice(0, 2).join(", ") + " +" + (names.length - 2) + "...";
32
+ }
33
+ if (phase?.kind === "waiting_model") return t("chat.thinking");
34
+ if (phase?.kind === "running_command") return t("chat.running_command");
35
+ return null;
36
+ }
37
+
38
+ export function ChatWindow({
39
+ session,
40
+ newSessionCwd,
41
+ onAgentEnd,
42
+ onSessionCreated,
43
+ onSessionForked,
44
+ modelsRefreshKey,
45
+ chatInputRef,
46
+ onSessionStatsChange,
47
+ onContextUsageChange,
48
+ }: Props) {
49
+ const { t } = useTranslation();
50
+ const {
51
+ loading, error, messages, entryIds, streamState,
52
+ agentRunning, bashRunning, pendingBash,
53
+ modelNames, modelList, modelError,
54
+ newSessionModel, toolPreset, thinkingLevel,
55
+ retryInfo, contextUsage, forkingEntryId,
56
+ isCompacting, compactError, compactResult,
57
+ displayModel, sessionStats,
58
+ slashCommands, slashCommandsLoading, queuedMessages,
59
+ notices,
60
+ agentPhase,
61
+ isNew,
62
+ sessionIdRef, messagesEndRef, scrollContainerRef,
63
+ handleSend, handleAbort, handleFork, handleModelChange,
64
+ handleCompact, handleSteer, handleFollowUp, handleAbortCompaction,
65
+ handleRecallQueue, handleBuiltinSlashCommand,
66
+ handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands,
67
+ isAutoModelSelection,
68
+ } = useAgentSession({
69
+ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked,
70
+ modelsRefreshKey, chatInputRef,
71
+ });
72
+
73
+ // Push stats up to parent - use useEffect to avoid setState during render
74
+ useEffect(() => {
75
+ onSessionStatsChange?.(sessionStats);
76
+ }, [sessionStats, onSessionStatsChange]);
77
+
78
+ // Push context usage up to parent - use useEffect to avoid setState during render
79
+ useEffect(() => {
80
+ onContextUsageChange?.(contextUsage);
81
+ }, [contextUsage, onContextUsageChange]);
82
+
83
+ // ─── Tool results map ─────────────────────────────────
84
+
85
+ const toolResultsMap = new Map<string, any>();
86
+ for (const msg of messages) {
87
+ if (msg.role === "toolResult") {
88
+ toolResultsMap.set((msg as any).toolCallId, msg);
89
+ }
90
+ }
91
+
92
+ // ─── Input History ────────────────────────────────────
93
+
94
+ const inputHistory: string[] = [];
95
+ const seen = new Set<string>();
96
+ for (let i = messages.length - 1; i >= 0; i--) {
97
+ const msg = messages[i];
98
+ if (!msg || msg.role !== "user") continue;
99
+ const text = typeof msg.content === "string"
100
+ ? msg.content
101
+ : Array.isArray(msg.content)
102
+ ? msg.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n")
103
+ : "";
104
+ if (!text || seen.has(text)) continue;
105
+ seen.add(text);
106
+ inputHistory.push(text);
107
+ if (inputHistory.length >= 50) break;
108
+ }
109
+ inputHistory.reverse();
110
+
111
+ // ─── Render ────────────────────────────────────────────
112
+
113
+ const isEmptyNew = isNew && messages.length === 0 && !streamState.isStreaming && !agentRunning;
114
+
115
+ if (loading) {
116
+ return (
117
+ <div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "var(--text-muted)" }}>
118
+ {t("chat.loading_session")}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ if (error) {
124
+ return (
125
+ <div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#dc2626" }}>
126
+ {error}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
133
+ {/* Notices */}
134
+ {notices.length > 0 && (
135
+ <div style={{
136
+ position: "absolute",
137
+ top: 12,
138
+ left: "50%",
139
+ transform: "translateX(-50%)",
140
+ zIndex: 40,
141
+ display: "flex",
142
+ flexDirection: "column",
143
+ alignItems: "center",
144
+ gap: 6,
145
+ }}>
146
+ {notices.map((notice) => {
147
+ const color = notice.type === "error" ? "#dc2626"
148
+ : notice.type === "warning" ? "#d97706"
149
+ : notice.type === "success" ? "#10b981"
150
+ : "var(--accent)";
151
+ return (
152
+ <div key={notice.id} style={{
153
+ display: "flex",
154
+ alignItems: "center",
155
+ gap: 8,
156
+ padding: "8px 14px",
157
+ borderRadius: 10,
158
+ background: "var(--bg-panel)",
159
+ border: "1px solid var(--border)",
160
+ fontSize: 13,
161
+ color: "var(--text-muted)",
162
+ boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
163
+ opacity: notice.exiting ? 0.5 : 1,
164
+ transition: "opacity 0.18s",
165
+ }}>
166
+ <span style={{ width: 7, height: 7, borderRadius: "50%", background: color, flexShrink: 0 }} />
167
+ <span>{notice.message}</span>
168
+ </div>
169
+ );
170
+ })}
171
+ </div>
172
+ )}
173
+
174
+ {/* Messages area */}
175
+ {isEmptyNew ? (
176
+ <div style={{
177
+ flex: 1,
178
+ display: "flex",
179
+ flexDirection: "column",
180
+ alignItems: "center",
181
+ justifyContent: "center",
182
+ padding: "24px 16px",
183
+ }}>
184
+ <div style={{ width: "100%", maxWidth: 760 }}>
185
+ <div style={{
186
+ display: "flex",
187
+ alignItems: "baseline",
188
+ gap: 10,
189
+ marginBottom: 24,
190
+ }}>
191
+ <span style={{ fontSize: 32, fontWeight: 700, color: "var(--accent)" }}>π</span>
192
+ <span style={{ fontSize: 24, fontWeight: 700, color: "var(--text)" }}>Pi Chat</span>
193
+ </div>
194
+ <ChatInput
195
+ ref={chatInputRef}
196
+ onSend={handleSend}
197
+ onAbort={handleAbort}
198
+ isStreaming={agentRunning}
199
+ model={displayModel}
200
+ isAutoModelSelection={isAutoModelSelection}
201
+ modelNames={modelNames}
202
+ modelList={modelList}
203
+ modelError={modelError}
204
+ onModelChange={handleModelChange}
205
+ onCompact={handleCompact}
206
+ isCompacting={isCompacting}
207
+ compactError={compactError}
208
+ toolPreset={toolPreset}
209
+ onToolPresetChange={handleToolPresetChange}
210
+ thinkingLevel={thinkingLevel}
211
+ onThinkingLevelChange={handleThinkingLevelChange}
212
+ retryInfo={retryInfo}
213
+ queuedMessages={queuedMessages}
214
+ onRecallQueue={handleRecallQueue}
215
+ slashCommands={slashCommands}
216
+ slashCommandsLoading={slashCommandsLoading}
217
+ onLoadSlashCommands={loadSlashCommands}
218
+ onBuiltinCommand={handleBuiltinSlashCommand}
219
+ inputHistory={inputHistory}
220
+ draftKey={session?.id ?? (newSessionCwd ? `new:${newSessionCwd}` : undefined)}
221
+ cwd={session?.cwd ?? newSessionCwd ?? undefined}
222
+ />
223
+ </div>
224
+ </div>
225
+ ) : (
226
+ <>
227
+ <div
228
+ ref={scrollContainerRef}
229
+ style={{
230
+ flex: 1,
231
+ overflowY: "auto",
232
+ overflowX: "hidden",
233
+ padding: "16px 16px 0 16px",
234
+ }}
235
+ >
236
+ <div style={{ maxWidth: 820, margin: "0 auto" }}>
237
+ {messages.map((msg, idx) => {
238
+ const entryId = entryIds[idx];
239
+ const isLastUser = idx === messages.length - 1 && msg.role === "user";
240
+ return (
241
+ <MessageView
242
+ key={`msg-${idx}-${entryId ?? idx}`}
243
+ message={msg}
244
+ isStreaming={streamState.isStreaming && idx === messages.length - 1}
245
+ modelNames={modelNames}
246
+ cwd={session?.cwd ?? newSessionCwd ?? undefined}
247
+ entryId={entryId}
248
+ onFork={agentRunning || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork}
249
+ forking={forkingEntryId === entryId}
250
+ showTimestamp={true}
251
+ sessionId={session?.id ?? sessionIdRef.current ?? undefined}
252
+ />
253
+ );
254
+ })}
255
+
256
+ {/* Streaming message */}
257
+ {streamState.isStreaming && streamState.streamingMessage && (
258
+ <MessageView
259
+ message={streamState.streamingMessage as AgentMessage}
260
+ isStreaming
261
+ modelNames={modelNames}
262
+ cwd={session?.cwd ?? newSessionCwd ?? undefined}
263
+ />
264
+ )}
265
+
266
+ {/* Agent phase indicator */}
267
+ {agentRunning && !streamState.streamingMessage && agentPhase && (
268
+ <div style={{ padding: "8px 0", fontSize: 13, color: "var(--text-muted)" }}>
269
+ <span style={{ animation: "pulse 1.5s infinite" }}>{phaseLabel(agentPhase, t)}</span>
270
+ </div>
271
+ )}
272
+
273
+ {bashRunning && !pendingBash && (
274
+ <div style={{ padding: "8px 0", fontSize: 13, color: "var(--text-muted)" }}>
275
+ <span style={{ animation: "pulse 1.5s infinite" }}>{t("chat.running_command")}</span>
276
+ </div>
277
+ )}
278
+
279
+ {pendingBash && (
280
+ <MessageView
281
+ message={{
282
+ role: "bashExecution",
283
+ command: pendingBash.command,
284
+ output: "",
285
+ excludeFromContext: pendingBash.excludeFromContext,
286
+ } as any}
287
+ sessionId={session?.id ?? sessionIdRef.current ?? undefined}
288
+ />
289
+ )}
290
+
291
+ {agentRunning && (
292
+ <div style={{ height: scrollContainerRef.current?.clientHeight ? scrollContainerRef.current.clientHeight * 0.6 : "60vh" }} />
293
+ )}
294
+
295
+ <div ref={messagesEndRef} />
296
+ </div>
297
+ </div>
298
+
299
+ {/* Input area */}
300
+ <div>
301
+ <ChatInput
302
+ ref={chatInputRef}
303
+ onSend={handleSend}
304
+ onAbort={handleAbort}
305
+ onSteer={agentRunning ? handleSteer : undefined}
306
+ onFollowUp={agentRunning ? handleFollowUp : undefined}
307
+ isStreaming={agentRunning || bashRunning}
308
+ model={displayModel}
309
+ isAutoModelSelection={isAutoModelSelection}
310
+ modelNames={modelNames}
311
+ modelList={modelList}
312
+ modelError={modelError}
313
+ onModelChange={handleModelChange}
314
+ onCompact={handleCompact}
315
+ isCompacting={isCompacting}
316
+ compactError={compactError}
317
+ onAbortCompaction={handleAbortCompaction}
318
+ toolPreset={toolPreset}
319
+ onToolPresetChange={handleToolPresetChange}
320
+ thinkingLevel={thinkingLevel}
321
+ onThinkingLevelChange={handleThinkingLevelChange}
322
+ retryInfo={retryInfo}
323
+ queuedMessages={queuedMessages}
324
+ onRecallQueue={handleRecallQueue}
325
+ slashCommands={slashCommands}
326
+ slashCommandsLoading={slashCommandsLoading}
327
+ onLoadSlashCommands={loadSlashCommands}
328
+ onBuiltinCommand={handleBuiltinSlashCommand}
329
+ inputHistory={inputHistory}
330
+ draftKey={session?.id ?? (newSessionCwd ? `new:${newSessionCwd}` : undefined)}
331
+ cwd={session?.cwd ?? newSessionCwd ?? undefined}
332
+ />
333
+ </div>
334
+ </>
335
+ )}
336
+ </div>
337
+ );
338
+ }