newpr 0.3.0 → 0.5.0
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.
- package/README.md +135 -103
- package/package.json +2 -2
- package/src/analyzer/pipeline.ts +1 -4
- package/src/cli/args.ts +1 -1
- package/src/cli/index.ts +2 -1
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/prompts.ts +82 -27
- package/src/llm/slides.ts +381 -0
- package/src/types/config.ts +1 -1
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/version.ts +23 -0
- package/src/web/client/App.tsx +51 -1
- package/src/web/client/components/AppShell.tsx +173 -45
- package/src/web/client/components/ChatSection.tsx +76 -185
- package/src/web/client/components/DetailPane.tsx +1 -0
- package/src/web/client/components/DiffViewer.tsx +200 -4
- package/src/web/client/components/InputScreen.tsx +3 -0
- package/src/web/client/components/Markdown.tsx +66 -16
- package/src/web/client/components/ResultsScreen.tsx +32 -2
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/hooks/useBackgroundAnalyses.ts +152 -0
- package/src/web/client/hooks/useChatStore.ts +247 -0
- package/src/web/client/hooks/useFeatures.ts +2 -1
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/index.html +1 -0
- package/src/web/server/routes.ts +226 -4
- package/src/web/server/session-manager.ts +34 -0
- package/src/web/server.ts +20 -1
- package/src/web/styles/built.css +1 -1
- package/src/workspace/explore.ts +39 -6
- package/src/workspace/types.ts +1 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import { Sun, Moon, Monitor, Plus, Settings, ArrowUp } from "lucide-react";
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
|
+
import { Sun, Moon, Monitor, Plus, Settings, ArrowUp, Loader2, X, Check, AlertCircle, ChevronRight } from "lucide-react";
|
|
3
|
+
import type { BackgroundAnalysis } from "../hooks/useBackgroundAnalyses.ts";
|
|
4
|
+
import { useChatLoadingIndicator } from "../hooks/useChatStore.ts";
|
|
3
5
|
import type { SessionRecord } from "../../../history/types.ts";
|
|
4
6
|
import type { GithubUser } from "../hooks/useGithubUser.ts";
|
|
5
7
|
import { SettingsPanel } from "./SettingsPanel.tsx";
|
|
@@ -42,6 +44,114 @@ function formatTimeAgo(isoDate: string): string {
|
|
|
42
44
|
return `${days}d`;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
interface RepoGroup {
|
|
48
|
+
repo: string;
|
|
49
|
+
repoShort: string;
|
|
50
|
+
sessions: SessionRecord[];
|
|
51
|
+
latestAt: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function groupByRepo(sessions: SessionRecord[]): RepoGroup[] {
|
|
55
|
+
const map = new Map<string, SessionRecord[]>();
|
|
56
|
+
for (const s of sessions) {
|
|
57
|
+
const list = map.get(s.repo) ?? [];
|
|
58
|
+
list.push(s);
|
|
59
|
+
map.set(s.repo, list);
|
|
60
|
+
}
|
|
61
|
+
const groups: RepoGroup[] = [];
|
|
62
|
+
for (const [repo, list] of map) {
|
|
63
|
+
const latestAt = Math.max(...list.map((s) => new Date(s.analyzed_at).getTime()));
|
|
64
|
+
groups.push({ repo, repoShort: repo.split("/").pop() ?? repo, sessions: list, latestAt });
|
|
65
|
+
}
|
|
66
|
+
groups.sort((a, b) => b.latestAt - a.latestAt);
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function SessionList({
|
|
71
|
+
sessions,
|
|
72
|
+
activeSessionId,
|
|
73
|
+
onSessionSelect,
|
|
74
|
+
}: {
|
|
75
|
+
sessions: SessionRecord[];
|
|
76
|
+
activeSessionId?: string | null;
|
|
77
|
+
onSessionSelect: (id: string) => void;
|
|
78
|
+
}) {
|
|
79
|
+
const groups = useMemo(() => groupByRepo(sessions), [sessions]);
|
|
80
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
81
|
+
|
|
82
|
+
const toggle = useCallback((repo: string) => {
|
|
83
|
+
setCollapsed((prev) => {
|
|
84
|
+
const next = new Set(prev);
|
|
85
|
+
next.has(repo) ? next.delete(repo) : next.add(repo);
|
|
86
|
+
return next;
|
|
87
|
+
});
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
if (sessions.length === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex-1 overflow-y-auto px-2 flex flex-col items-center justify-center text-center gap-2 opacity-40">
|
|
93
|
+
<p className="text-[11px] text-muted-foreground">No analyses yet</p>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex-1 overflow-y-auto px-2 py-1">
|
|
100
|
+
{groups.map((g) => {
|
|
101
|
+
const isCollapsed = collapsed.has(g.repo);
|
|
102
|
+
return (
|
|
103
|
+
<div key={g.repo} className="mb-1">
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => toggle(g.repo)}
|
|
107
|
+
className="w-full flex items-center gap-1.5 px-1.5 py-1 rounded-md hover:bg-accent/30 transition-colors"
|
|
108
|
+
>
|
|
109
|
+
<ChevronRight className={`h-2.5 w-2.5 text-muted-foreground/30 shrink-0 transition-transform ${isCollapsed ? "" : "rotate-90"}`} />
|
|
110
|
+
<span className="text-[11px] font-mono text-muted-foreground/60 truncate">{g.repoShort}</span>
|
|
111
|
+
<span className="text-[10px] text-muted-foreground/25 shrink-0">{g.sessions.length}</span>
|
|
112
|
+
</button>
|
|
113
|
+
{!isCollapsed && (
|
|
114
|
+
<div className="space-y-px mt-px">
|
|
115
|
+
{g.sessions.map((s) => {
|
|
116
|
+
const isActive = activeSessionId === s.id;
|
|
117
|
+
return (
|
|
118
|
+
<button
|
|
119
|
+
key={s.id}
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => onSessionSelect(s.id)}
|
|
122
|
+
className={`w-full flex items-start gap-2 rounded-md pl-5 pr-2.5 py-1.5 text-left transition-colors group ${
|
|
123
|
+
isActive ? "bg-accent text-foreground" : "hover:bg-accent/40"
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
<span className={`mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
|
|
127
|
+
<div className="flex-1 min-w-0">
|
|
128
|
+
<div className={`text-[11px] truncate leading-tight ${isActive ? "font-medium" : "text-foreground/80 group-hover:text-foreground"} transition-colors`}>
|
|
129
|
+
{s.pr_title}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40">
|
|
132
|
+
<span className="font-mono">#{s.pr_number}</span>
|
|
133
|
+
{s.pr_state && STATE_LABEL[s.pr_state] && (
|
|
134
|
+
<>
|
|
135
|
+
<span className="text-muted-foreground/15">·</span>
|
|
136
|
+
<span className={STATE_LABEL[s.pr_state]!.class}>{STATE_LABEL[s.pr_state]!.text}</span>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
<span className="text-muted-foreground/15">·</span>
|
|
140
|
+
<span>{formatTimeAgo(s.analyzed_at)}</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</button>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
45
155
|
export function AppShell({
|
|
46
156
|
theme,
|
|
47
157
|
onThemeChange,
|
|
@@ -52,6 +162,10 @@ export function AppShell({
|
|
|
52
162
|
detailPanel,
|
|
53
163
|
bottomBar,
|
|
54
164
|
activeSessionId,
|
|
165
|
+
version,
|
|
166
|
+
bgAnalyses,
|
|
167
|
+
onBgClick,
|
|
168
|
+
onBgDismiss,
|
|
55
169
|
children,
|
|
56
170
|
}: {
|
|
57
171
|
theme: Theme;
|
|
@@ -63,6 +177,10 @@ export function AppShell({
|
|
|
63
177
|
detailPanel?: React.ReactNode;
|
|
64
178
|
bottomBar?: React.ReactNode;
|
|
65
179
|
activeSessionId?: string | null;
|
|
180
|
+
version?: string;
|
|
181
|
+
bgAnalyses?: BackgroundAnalysis[];
|
|
182
|
+
onBgClick?: (sessionId: string) => void;
|
|
183
|
+
onBgDismiss?: (sessionId: string) => void;
|
|
66
184
|
children: React.ReactNode;
|
|
67
185
|
}) {
|
|
68
186
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
@@ -71,6 +189,7 @@ export function AppShell({
|
|
|
71
189
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
|
72
190
|
const mainRef = useRef<HTMLElement>(null);
|
|
73
191
|
const prevDetailPanel = useRef(detailPanel);
|
|
192
|
+
const chatLoading = useChatLoadingIndicator();
|
|
74
193
|
|
|
75
194
|
useEffect(() => {
|
|
76
195
|
const wasNull = prevDetailPanel.current == null;
|
|
@@ -121,6 +240,7 @@ export function AppShell({
|
|
|
121
240
|
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
|
122
241
|
>
|
|
123
242
|
<span className="text-xs font-semibold tracking-tight font-mono">newpr</span>
|
|
243
|
+
{version && <span className="text-[10px] text-muted-foreground/30">v{version}</span>}
|
|
124
244
|
</button>
|
|
125
245
|
<button
|
|
126
246
|
type="button"
|
|
@@ -132,50 +252,58 @@ export function AppShell({
|
|
|
132
252
|
</button>
|
|
133
253
|
</div>
|
|
134
254
|
|
|
135
|
-
<
|
|
136
|
-
{sessions
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
<span className={STATE_LABEL[s.pr_state]!.class}>{STATE_LABEL[s.pr_state]!.text}</span>
|
|
163
|
-
</>
|
|
164
|
-
)}
|
|
165
|
-
<span className="text-muted-foreground/20 mx-0.5">·</span>
|
|
166
|
-
<span>{formatTimeAgo(s.analyzed_at)}</span>
|
|
167
|
-
</div>
|
|
255
|
+
<SessionList
|
|
256
|
+
sessions={sessions}
|
|
257
|
+
activeSessionId={activeSessionId}
|
|
258
|
+
onSessionSelect={onSessionSelect}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
{bgAnalyses && bgAnalyses.length > 0 && (
|
|
262
|
+
<div className="shrink-0 border-t px-2 py-2 space-y-px">
|
|
263
|
+
{bgAnalyses.map((bg) => (
|
|
264
|
+
<div
|
|
265
|
+
key={bg.sessionId}
|
|
266
|
+
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 group"
|
|
267
|
+
>
|
|
268
|
+
{bg.status === "running" && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40 shrink-0" />}
|
|
269
|
+
{bg.status === "done" && <Check className="h-3 w-3 text-green-500 shrink-0" />}
|
|
270
|
+
{bg.status === "error" && <AlertCircle className="h-3 w-3 text-red-500 shrink-0" />}
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() => onBgClick?.(bg.sessionId)}
|
|
274
|
+
className="flex-1 min-w-0 text-left"
|
|
275
|
+
>
|
|
276
|
+
<div className="text-[11px] truncate text-muted-foreground/70">
|
|
277
|
+
{bg.prTitle ?? bg.prInput}
|
|
278
|
+
</div>
|
|
279
|
+
{bg.status === "running" && bg.lastMessage && (
|
|
280
|
+
<div className="text-[10px] text-muted-foreground/30 truncate font-mono mt-0.5">
|
|
281
|
+
{bg.lastMessage}
|
|
168
282
|
</div>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
283
|
+
)}
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={(e) => { e.stopPropagation(); onBgDismiss?.(bg.sessionId); }}
|
|
288
|
+
className="h-4 w-4 flex items-center justify-center rounded text-muted-foreground/20 hover:text-muted-foreground/60 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
289
|
+
>
|
|
290
|
+
<X className="h-2.5 w-2.5" />
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{chatLoading.length > 0 && (
|
|
298
|
+
<div className="shrink-0 border-t px-2 py-2 space-y-px">
|
|
299
|
+
{chatLoading.map(({ sessionId: sid }) => (
|
|
300
|
+
<div key={sid} className="flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
|
301
|
+
<Loader2 className="h-3 w-3 animate-spin text-blue-500/60 shrink-0" />
|
|
302
|
+
<span className="text-[11px] text-muted-foreground/50 truncate">Chat responding...</span>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
179
307
|
|
|
180
308
|
<div className="shrink-0 border-t px-2 py-2 space-y-1">
|
|
181
309
|
{githubUser && (
|
|
@@ -1,198 +1,31 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
2
2
|
import { Loader2, ChevronRight, CornerDownLeft } from "lucide-react";
|
|
3
3
|
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
4
4
|
import { Markdown } from "./Markdown.tsx";
|
|
5
5
|
import { TipTapEditor, getTextWithAnchors, type AnchorItem, type CommandItem } from "./TipTapEditor.tsx";
|
|
6
6
|
import type { useEditor } from "@tiptap/react";
|
|
7
|
+
import { useChatStore } from "../hooks/useChatStore.ts";
|
|
7
8
|
|
|
8
9
|
export interface ChatState {
|
|
9
10
|
messages: ChatMessage[];
|
|
10
|
-
input: string;
|
|
11
11
|
loading: boolean;
|
|
12
12
|
streaming: { segments: ChatSegment[]; activeToolName?: string } | null;
|
|
13
13
|
loaded: boolean;
|
|
14
|
-
setInput: (v: string) => void;
|
|
15
14
|
sendMessage: (text?: string) => void;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
interface ChatContextValue {
|
|
19
18
|
state: ChatState;
|
|
20
19
|
anchorItems?: AnchorItem[];
|
|
20
|
+
analyzedAt?: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
24
24
|
|
|
25
|
-
export
|
|
26
|
-
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
27
|
-
const [input, setInput] = useState("");
|
|
28
|
-
const [loading, setLoading] = useState(false);
|
|
29
|
-
const [streaming, setStreaming] = useState<ChatState["streaming"]>(null);
|
|
30
|
-
const [loaded, setLoaded] = useState(false);
|
|
31
|
-
const prevSessionId = useRef(sessionId);
|
|
25
|
+
export { useChatStore as useChatState };
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
setMessages([]);
|
|
36
|
-
setInput("");
|
|
37
|
-
setLoading(false);
|
|
38
|
-
setStreaming(null);
|
|
39
|
-
setLoaded(false);
|
|
40
|
-
prevSessionId.current = sessionId;
|
|
41
|
-
}
|
|
42
|
-
if (!sessionId) return;
|
|
43
|
-
fetch(`/api/sessions/${sessionId}/chat`)
|
|
44
|
-
.then((r) => r.json())
|
|
45
|
-
.then((data) => {
|
|
46
|
-
setMessages(data as ChatMessage[]);
|
|
47
|
-
setLoaded(true);
|
|
48
|
-
})
|
|
49
|
-
.catch(() => setLoaded(true));
|
|
50
|
-
}, [sessionId]);
|
|
51
|
-
|
|
52
|
-
const undoLast = useCallback(async () => {
|
|
53
|
-
if (!sessionId) return;
|
|
54
|
-
setMessages((prev) => {
|
|
55
|
-
const lastAssistantIdx = prev.findLastIndex((m) => m.role === "assistant");
|
|
56
|
-
if (lastAssistantIdx === -1) return prev;
|
|
57
|
-
const lastUserIdx = prev.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
|
|
58
|
-
const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
|
|
59
|
-
return prev.slice(0, removeFrom);
|
|
60
|
-
});
|
|
61
|
-
await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
|
|
62
|
-
}, [sessionId]);
|
|
63
|
-
|
|
64
|
-
const sendMessage = useCallback(async (text?: string) => {
|
|
65
|
-
const messageText = text?.trim() || input.trim();
|
|
66
|
-
if (!sessionId || !messageText || loading) return;
|
|
67
|
-
|
|
68
|
-
if (messageText.replace(/\n/g, "").trim() === "/undo") {
|
|
69
|
-
setInput("");
|
|
70
|
-
await undoLast();
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const userMessage = messageText;
|
|
75
|
-
setInput("");
|
|
76
|
-
setLoading(true);
|
|
77
|
-
setStreaming({ segments: [] });
|
|
78
|
-
|
|
79
|
-
setMessages((prev) => [...prev, {
|
|
80
|
-
role: "user",
|
|
81
|
-
content: userMessage,
|
|
82
|
-
timestamp: new Date().toISOString(),
|
|
83
|
-
}]);
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
const res = await fetch(`/api/sessions/${sessionId}/chat`, {
|
|
87
|
-
method: "POST",
|
|
88
|
-
headers: { "Content-Type": "application/json" },
|
|
89
|
-
body: JSON.stringify({ message: userMessage }),
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
if (!res.ok) {
|
|
93
|
-
const err = await res.json() as { error?: string };
|
|
94
|
-
throw new Error(err.error ?? `HTTP ${res.status}`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const reader = res.body!.getReader();
|
|
98
|
-
const decoder = new TextDecoder();
|
|
99
|
-
let buffer = "";
|
|
100
|
-
let fullText = "";
|
|
101
|
-
const orderedSegments: ChatSegment[] = [];
|
|
102
|
-
const allToolCalls: ChatToolCall[] = [];
|
|
103
|
-
let pendingEvent = "";
|
|
104
|
-
|
|
105
|
-
while (true) {
|
|
106
|
-
const { done, value } = await reader.read();
|
|
107
|
-
if (done) break;
|
|
108
|
-
|
|
109
|
-
buffer += decoder.decode(value, { stream: true });
|
|
110
|
-
const lines = buffer.split("\n");
|
|
111
|
-
buffer = lines.pop() ?? "";
|
|
112
|
-
|
|
113
|
-
for (const line of lines) {
|
|
114
|
-
const trimmed = line.trim();
|
|
115
|
-
if (!trimmed) {
|
|
116
|
-
pendingEvent = "";
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (trimmed.startsWith("event: ")) {
|
|
121
|
-
pendingEvent = trimmed.slice(7);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
if (!trimmed.startsWith("data: ")) continue;
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const data = JSON.parse(trimmed.slice(6));
|
|
128
|
-
|
|
129
|
-
switch (pendingEvent) {
|
|
130
|
-
case "text": {
|
|
131
|
-
fullText += data.content ?? "";
|
|
132
|
-
const lastSeg = orderedSegments[orderedSegments.length - 1];
|
|
133
|
-
if (lastSeg && lastSeg.type === "text") {
|
|
134
|
-
lastSeg.content += data.content ?? "";
|
|
135
|
-
} else {
|
|
136
|
-
orderedSegments.push({ type: "text", content: data.content ?? "" });
|
|
137
|
-
}
|
|
138
|
-
setStreaming({ segments: [...orderedSegments] });
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
case "tool_call": {
|
|
142
|
-
const tc: ChatToolCall = {
|
|
143
|
-
id: data.id,
|
|
144
|
-
name: data.name,
|
|
145
|
-
arguments: data.arguments ?? {},
|
|
146
|
-
};
|
|
147
|
-
allToolCalls.push(tc);
|
|
148
|
-
orderedSegments.push({ type: "tool_call", toolCall: tc });
|
|
149
|
-
setStreaming({ segments: [...orderedSegments], activeToolName: data.name });
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
case "tool_result": {
|
|
153
|
-
const tc = allToolCalls.find((c) => c.id === data.id);
|
|
154
|
-
if (tc) tc.result = data.result;
|
|
155
|
-
setStreaming({ segments: [...orderedSegments] });
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
case "done":
|
|
159
|
-
break;
|
|
160
|
-
case "chat_error":
|
|
161
|
-
throw new Error(data.message ?? "Chat error");
|
|
162
|
-
}
|
|
163
|
-
} catch (parseErr) {
|
|
164
|
-
if (parseErr instanceof Error && parseErr.message === "Chat error") {
|
|
165
|
-
throw parseErr;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
pendingEvent = "";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
setMessages((prev) => [...prev, {
|
|
173
|
-
role: "assistant",
|
|
174
|
-
content: fullText,
|
|
175
|
-
toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
|
|
176
|
-
segments: orderedSegments.length > 0 ? orderedSegments : undefined,
|
|
177
|
-
timestamp: new Date().toISOString(),
|
|
178
|
-
}]);
|
|
179
|
-
} catch (err) {
|
|
180
|
-
setMessages((prev) => [...prev, {
|
|
181
|
-
role: "assistant",
|
|
182
|
-
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
183
|
-
timestamp: new Date().toISOString(),
|
|
184
|
-
}]);
|
|
185
|
-
} finally {
|
|
186
|
-
setLoading(false);
|
|
187
|
-
setStreaming(null);
|
|
188
|
-
}
|
|
189
|
-
}, [sessionId, input, loading, undoLast]);
|
|
190
|
-
|
|
191
|
-
return { messages, input, loading, streaming, loaded, setInput, sendMessage };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export function ChatProvider({ state, anchorItems, children }: { state: ChatState; anchorItems?: AnchorItem[]; children: React.ReactNode }) {
|
|
195
|
-
const value = useMemo(() => ({ state, anchorItems }), [state, anchorItems]);
|
|
27
|
+
export function ChatProvider({ state, anchorItems, analyzedAt, children }: { state: ChatState; anchorItems?: AnchorItem[]; analyzedAt?: string; children: React.ReactNode }) {
|
|
28
|
+
const value = useMemo(() => ({ state, anchorItems, analyzedAt }), [state, anchorItems, analyzedAt]);
|
|
196
29
|
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
|
197
30
|
}
|
|
198
31
|
|
|
@@ -239,6 +72,33 @@ function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
|
|
|
239
72
|
return segs;
|
|
240
73
|
}
|
|
241
74
|
|
|
75
|
+
function ThrottledMarkdown({ content, onAnchorClick, activeId }: {
|
|
76
|
+
content: string;
|
|
77
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
78
|
+
activeId?: string | null;
|
|
79
|
+
}) {
|
|
80
|
+
const [rendered, setRendered] = useState(content);
|
|
81
|
+
const pendingRef = useRef(content);
|
|
82
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
pendingRef.current = content;
|
|
86
|
+
if (!timerRef.current) {
|
|
87
|
+
timerRef.current = setTimeout(() => {
|
|
88
|
+
setRendered(pendingRef.current);
|
|
89
|
+
timerRef.current = null;
|
|
90
|
+
}, 150);
|
|
91
|
+
}
|
|
92
|
+
return () => {};
|
|
93
|
+
}, [content]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
return <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{rendered}</Markdown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
242
102
|
function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
|
|
243
103
|
segments: ChatSegment[];
|
|
244
104
|
activeToolName?: string;
|
|
@@ -254,11 +114,16 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
|
|
|
254
114
|
if (seg.type === "tool_call") {
|
|
255
115
|
return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
|
|
256
116
|
}
|
|
257
|
-
|
|
117
|
+
if (!seg.content) return null;
|
|
118
|
+
return (
|
|
258
119
|
<div key={`text-${i}`} className="text-xs leading-relaxed">
|
|
259
|
-
|
|
120
|
+
{isStreaming ? (
|
|
121
|
+
<ThrottledMarkdown content={seg.content} onAnchorClick={onAnchorClick} activeId={activeId} />
|
|
122
|
+
) : (
|
|
123
|
+
<Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
|
|
124
|
+
)}
|
|
260
125
|
</div>
|
|
261
|
-
)
|
|
126
|
+
);
|
|
262
127
|
})}
|
|
263
128
|
{activeToolName && (
|
|
264
129
|
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
|
|
@@ -317,6 +182,7 @@ export function ChatMessages({ onAnchorClick, activeId }: {
|
|
|
317
182
|
|
|
318
183
|
if (!ctx) return null;
|
|
319
184
|
const { messages, streaming, loaded, loading } = ctx.state;
|
|
185
|
+
const { analyzedAt } = ctx;
|
|
320
186
|
const hasMessages = messages.length > 0 || loading;
|
|
321
187
|
|
|
322
188
|
if (!hasMessages && loaded) {
|
|
@@ -330,26 +196,51 @@ export function ChatMessages({ onAnchorClick, activeId }: {
|
|
|
330
196
|
|
|
331
197
|
if (!hasMessages) return null;
|
|
332
198
|
|
|
199
|
+
let shownOutdatedDivider = false;
|
|
200
|
+
|
|
333
201
|
return (
|
|
334
202
|
<div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
|
|
335
203
|
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
|
|
336
204
|
{messages.map((msg, i) => {
|
|
205
|
+
const isFromPreviousAnalysis = analyzedAt && msg.timestamp && msg.timestamp < analyzedAt;
|
|
206
|
+
let divider = null;
|
|
207
|
+
if (isFromPreviousAnalysis && !shownOutdatedDivider) {
|
|
208
|
+
shownOutdatedDivider = true;
|
|
209
|
+
divider = (
|
|
210
|
+
<div className="flex items-center gap-2 py-1">
|
|
211
|
+
<div className="flex-1 h-px bg-yellow-500/20" />
|
|
212
|
+
<span className="text-[10px] text-yellow-600/60 dark:text-yellow-400/50 shrink-0">Previous analysis</span>
|
|
213
|
+
<div className="flex-1 h-px bg-yellow-500/20" />
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
337
217
|
if (msg.role === "user") {
|
|
338
218
|
return (
|
|
339
|
-
<div key={`user-${i}`}
|
|
340
|
-
|
|
341
|
-
|
|
219
|
+
<div key={`user-${i}`}>
|
|
220
|
+
{divider}
|
|
221
|
+
<div className="flex justify-end">
|
|
222
|
+
<div className={`max-w-[80%] rounded-xl rounded-br-sm px-3.5 py-2 text-[11px] leading-relaxed ${
|
|
223
|
+
isFromPreviousAnalysis
|
|
224
|
+
? "bg-foreground/60 text-background"
|
|
225
|
+
: "bg-foreground text-background"
|
|
226
|
+
}`}>
|
|
227
|
+
{msg.content}
|
|
228
|
+
</div>
|
|
342
229
|
</div>
|
|
343
230
|
</div>
|
|
344
231
|
);
|
|
345
232
|
}
|
|
346
233
|
return (
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
234
|
+
<div key={`assistant-${i}`}>
|
|
235
|
+
{divider}
|
|
236
|
+
<div className={isFromPreviousAnalysis ? "opacity-60" : ""}>
|
|
237
|
+
<AssistantMessage
|
|
238
|
+
segments={segmentsFromMessage(msg)}
|
|
239
|
+
onAnchorClick={onAnchorClick}
|
|
240
|
+
activeId={activeId}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
353
244
|
);
|
|
354
245
|
})}
|
|
355
246
|
|