newpr 0.1.3 → 0.3.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/package.json +11 -1
- package/src/analyzer/pipeline.ts +37 -15
- package/src/analyzer/progress.ts +2 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/preflight.ts +126 -0
- package/src/github/fetch-pr.ts +53 -1
- package/src/history/store.ts +107 -1
- package/src/history/types.ts +1 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +80 -19
- package/src/llm/response-parser.ts +13 -1
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +14 -0
- package/src/types/output.ts +50 -0
- package/src/web/client/App.tsx +33 -5
- package/src/web/client/components/AppShell.tsx +107 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +217 -77
- package/src/web/client/components/DiffViewer.tsx +713 -0
- package/src/web/client/components/InputScreen.tsx +178 -27
- package/src/web/client/components/LoadingTimeline.tsx +19 -6
- package/src/web/client/components/Markdown.tsx +220 -41
- package/src/web/client/components/ResultsScreen.tsx +109 -73
- package/src/web/client/components/ReviewModal.tsx +187 -0
- package/src/web/client/components/SettingsPanel.tsx +62 -86
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +62 -40
- package/src/web/client/panels/StoryPanel.tsx +43 -23
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +856 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +66 -4
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/workspace/agent.ts +22 -6
- package/src/workspace/explore.ts +41 -16
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
2
|
-
import { Sun, Moon, Monitor, Plus,
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Sun, Moon, Monitor, Plus, Settings, ArrowUp } from "lucide-react";
|
|
3
3
|
import type { SessionRecord } from "../../../history/types.ts";
|
|
4
4
|
import type { GithubUser } from "../hooks/useGithubUser.ts";
|
|
5
5
|
import { SettingsPanel } from "./SettingsPanel.tsx";
|
|
@@ -13,9 +13,9 @@ const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
|
|
|
13
13
|
const LEFT_MIN = 180;
|
|
14
14
|
const LEFT_MAX = 400;
|
|
15
15
|
const LEFT_DEFAULT = 256;
|
|
16
|
-
const RIGHT_MIN =
|
|
17
|
-
const RIGHT_MAX =
|
|
18
|
-
const RIGHT_DEFAULT =
|
|
16
|
+
const RIGHT_MIN = 400;
|
|
17
|
+
const RIGHT_MAX = 1200;
|
|
18
|
+
const RIGHT_DEFAULT = 560;
|
|
19
19
|
|
|
20
20
|
const RISK_DOT: Record<string, string> = {
|
|
21
21
|
low: "bg-green-500",
|
|
@@ -24,6 +24,13 @@ const RISK_DOT: Record<string, string> = {
|
|
|
24
24
|
critical: "bg-red-600",
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
const STATE_LABEL: Record<string, { text: string; class: string }> = {
|
|
28
|
+
open: { text: "Open", class: "text-green-600 dark:text-green-400" },
|
|
29
|
+
merged: { text: "Merged", class: "text-purple-600 dark:text-purple-400" },
|
|
30
|
+
closed: { text: "Closed", class: "text-red-600 dark:text-red-400" },
|
|
31
|
+
draft: { text: "Draft", class: "text-neutral-500" },
|
|
32
|
+
};
|
|
33
|
+
|
|
27
34
|
function formatTimeAgo(isoDate: string): string {
|
|
28
35
|
const diff = Date.now() - new Date(isoDate).getTime();
|
|
29
36
|
const minutes = Math.floor(diff / 60000);
|
|
@@ -43,6 +50,8 @@ export function AppShell({
|
|
|
43
50
|
onSessionSelect,
|
|
44
51
|
onNewAnalysis,
|
|
45
52
|
detailPanel,
|
|
53
|
+
bottomBar,
|
|
54
|
+
activeSessionId,
|
|
46
55
|
children,
|
|
47
56
|
}: {
|
|
48
57
|
theme: Theme;
|
|
@@ -52,11 +61,27 @@ export function AppShell({
|
|
|
52
61
|
onSessionSelect: (sessionId: string) => void;
|
|
53
62
|
onNewAnalysis: () => void;
|
|
54
63
|
detailPanel?: React.ReactNode;
|
|
64
|
+
bottomBar?: React.ReactNode;
|
|
65
|
+
activeSessionId?: string | null;
|
|
55
66
|
children: React.ReactNode;
|
|
56
67
|
}) {
|
|
57
68
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
58
69
|
const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
|
|
59
70
|
const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
|
|
71
|
+
const [showScrollTop, setShowScrollTop] = useState(false);
|
|
72
|
+
const mainRef = useRef<HTMLElement>(null);
|
|
73
|
+
const prevDetailPanel = useRef(detailPanel);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const wasNull = prevDetailPanel.current == null;
|
|
77
|
+
prevDetailPanel.current = detailPanel;
|
|
78
|
+
if (wasNull && detailPanel != null) {
|
|
79
|
+
const available = window.innerWidth - leftWidth - 2;
|
|
80
|
+
const half = Math.floor(available * 0.55);
|
|
81
|
+
setRightWidth(Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, half)));
|
|
82
|
+
}
|
|
83
|
+
}, [detailPanel, leftWidth]);
|
|
84
|
+
|
|
60
85
|
const Icon = THEME_ICON[theme];
|
|
61
86
|
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]!;
|
|
62
87
|
|
|
@@ -64,104 +89,128 @@ export function AppShell({
|
|
|
64
89
|
setLeftWidth((w) => Math.min(LEFT_MAX, Math.max(LEFT_MIN, w + delta)));
|
|
65
90
|
}, []);
|
|
66
91
|
|
|
92
|
+
const CENTER_MIN = 400;
|
|
93
|
+
|
|
67
94
|
const handleRightResize = useCallback((delta: number) => {
|
|
68
|
-
setRightWidth((w) =>
|
|
95
|
+
setRightWidth((w) => {
|
|
96
|
+
const available = window.innerWidth - leftWidth - 2;
|
|
97
|
+
const max = Math.min(RIGHT_MAX, available - CENTER_MIN);
|
|
98
|
+
return Math.min(max, Math.max(RIGHT_MIN, w + delta));
|
|
99
|
+
});
|
|
100
|
+
}, [leftWidth]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const el = mainRef.current;
|
|
104
|
+
if (!el) return;
|
|
105
|
+
const onScroll = () => setShowScrollTop(el.scrollTop > 300);
|
|
106
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
107
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const scrollToTop = useCallback(() => {
|
|
111
|
+
mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
69
112
|
}, []);
|
|
70
113
|
|
|
71
114
|
return (
|
|
72
115
|
<div className="flex h-screen bg-background overflow-hidden">
|
|
73
116
|
<aside className="flex flex-col shrink-0 border-r bg-background" style={{ width: leftWidth }}>
|
|
74
|
-
<div className="flex h-
|
|
117
|
+
<div className="flex h-12 items-center justify-between px-4 shrink-0">
|
|
75
118
|
<button
|
|
76
119
|
type="button"
|
|
77
120
|
onClick={onNewAnalysis}
|
|
78
|
-
className="flex items-center gap-
|
|
121
|
+
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
|
79
122
|
>
|
|
80
|
-
<span className="text-
|
|
81
|
-
<span className="text-[10px] text-muted-foreground">v0.1.0</span>
|
|
123
|
+
<span className="text-xs font-semibold tracking-tight font-mono">newpr</span>
|
|
82
124
|
</button>
|
|
83
125
|
<button
|
|
84
126
|
type="button"
|
|
85
127
|
onClick={onNewAnalysis}
|
|
86
|
-
className="flex h-
|
|
128
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/50 hover:bg-accent hover:text-foreground transition-colors"
|
|
87
129
|
title="New analysis"
|
|
88
130
|
>
|
|
89
|
-
<Plus className="h-
|
|
131
|
+
<Plus className="h-3.5 w-3.5" />
|
|
90
132
|
</button>
|
|
91
133
|
</div>
|
|
92
134
|
|
|
93
|
-
<div className="flex-1 overflow-y-auto">
|
|
94
|
-
{sessions.length > 0
|
|
95
|
-
<div className="
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<div className="space-y-0.5">
|
|
100
|
-
{sessions.map((s) => (
|
|
135
|
+
<div className="flex-1 overflow-y-auto px-2">
|
|
136
|
+
{sessions.length > 0 ? (
|
|
137
|
+
<div className="space-y-px">
|
|
138
|
+
{sessions.map((s) => {
|
|
139
|
+
const isActive = activeSessionId === s.id;
|
|
140
|
+
return (
|
|
101
141
|
<button
|
|
102
142
|
key={s.id}
|
|
103
143
|
type="button"
|
|
104
144
|
onClick={() => onSessionSelect(s.id)}
|
|
105
|
-
className=
|
|
145
|
+
className={`w-full flex items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors group ${
|
|
146
|
+
isActive
|
|
147
|
+
? "bg-accent text-foreground"
|
|
148
|
+
: "hover:bg-accent/40"
|
|
149
|
+
}`}
|
|
106
150
|
>
|
|
107
|
-
<span className={`mt-1.5
|
|
151
|
+
<span className={`mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
|
|
108
152
|
<div className="flex-1 min-w-0">
|
|
109
|
-
<div className=
|
|
153
|
+
<div className={`text-xs truncate leading-tight ${isActive ? "font-medium" : "text-foreground/80 group-hover:text-foreground"} transition-colors`}>
|
|
110
154
|
{s.pr_title}
|
|
111
155
|
</div>
|
|
112
|
-
<div className="flex items-center gap-1
|
|
113
|
-
<span className="truncate">{s.repo.split("/").pop()}</span>
|
|
114
|
-
<span>#{s.pr_number}</span>
|
|
115
|
-
|
|
116
|
-
|
|
156
|
+
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/50">
|
|
157
|
+
<span className="font-mono truncate">{s.repo.split("/").pop()}</span>
|
|
158
|
+
<span className="font-mono">#{s.pr_number}</span>
|
|
159
|
+
{s.pr_state && STATE_LABEL[s.pr_state] && (
|
|
160
|
+
<>
|
|
161
|
+
<span className="text-muted-foreground/20 mx-0.5">·</span>
|
|
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>
|
|
117
166
|
<span>{formatTimeAgo(s.analyzed_at)}</span>
|
|
118
167
|
</div>
|
|
119
168
|
</div>
|
|
120
169
|
</button>
|
|
121
|
-
)
|
|
122
|
-
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-2 opacity-40">
|
|
175
|
+
<p className="text-[11px] text-muted-foreground">No analyses yet</p>
|
|
123
176
|
</div>
|
|
124
177
|
)}
|
|
125
178
|
</div>
|
|
126
179
|
|
|
127
|
-
<div className="border-t px-
|
|
180
|
+
<div className="shrink-0 border-t px-2 py-2 space-y-1">
|
|
128
181
|
{githubUser && (
|
|
129
182
|
<a
|
|
130
183
|
href={githubUser.html_url}
|
|
131
184
|
target="_blank"
|
|
132
185
|
rel="noopener noreferrer"
|
|
133
|
-
className="flex items-center gap-2
|
|
186
|
+
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-accent/40 transition-colors"
|
|
134
187
|
>
|
|
135
188
|
<img
|
|
136
189
|
src={githubUser.avatar_url}
|
|
137
190
|
alt={githubUser.login}
|
|
138
|
-
className="h-
|
|
191
|
+
className="h-5 w-5 rounded-full"
|
|
139
192
|
/>
|
|
140
|
-
<
|
|
141
|
-
<div className="text-xs font-medium truncate">{githubUser.name ?? githubUser.login}</div>
|
|
142
|
-
{githubUser.name && (
|
|
143
|
-
<div className="text-[10px] text-muted-foreground truncate">@{githubUser.login}</div>
|
|
144
|
-
)}
|
|
145
|
-
</div>
|
|
193
|
+
<span className="text-[11px] font-medium truncate flex-1">{githubUser.name ?? githubUser.login}</span>
|
|
146
194
|
</a>
|
|
147
195
|
)}
|
|
148
|
-
<div className="flex items-center
|
|
196
|
+
<div className="flex items-center gap-1 px-1">
|
|
149
197
|
<button
|
|
150
198
|
type="button"
|
|
151
199
|
onClick={() => onThemeChange(next)}
|
|
152
|
-
className="flex items-center gap-
|
|
200
|
+
className="flex items-center gap-1.5 px-1.5 py-1 rounded-md text-[11px] text-muted-foreground/50 hover:text-foreground hover:bg-accent/40 transition-colors"
|
|
153
201
|
title={`Switch to ${next} mode`}
|
|
154
202
|
>
|
|
155
|
-
<Icon className="h-3
|
|
203
|
+
<Icon className="h-3 w-3" />
|
|
156
204
|
<span className="capitalize">{theme}</span>
|
|
157
205
|
</button>
|
|
206
|
+
<div className="flex-1" />
|
|
158
207
|
<button
|
|
159
208
|
type="button"
|
|
160
209
|
onClick={() => setSettingsOpen(true)}
|
|
161
|
-
className="flex h-
|
|
210
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/40 hover:bg-accent/40 hover:text-foreground transition-colors"
|
|
162
211
|
title="Settings"
|
|
163
212
|
>
|
|
164
|
-
<Settings className="h-3
|
|
213
|
+
<Settings className="h-3 w-3" />
|
|
165
214
|
</button>
|
|
166
215
|
</div>
|
|
167
216
|
</div>
|
|
@@ -169,12 +218,23 @@ export function AppShell({
|
|
|
169
218
|
|
|
170
219
|
<ResizeHandle onResize={handleLeftResize} side="right" />
|
|
171
220
|
|
|
172
|
-
<div className="flex-1 flex flex-col
|
|
173
|
-
<main className="flex-1 overflow-y-auto">
|
|
174
|
-
<div className="mx-auto max-w-
|
|
221
|
+
<div className="flex-1 flex flex-col overflow-hidden relative" style={{ minWidth: 400 }}>
|
|
222
|
+
<main ref={mainRef} className="flex-1 overflow-y-auto">
|
|
223
|
+
<div className="mx-auto max-w-5xl px-10 py-10">
|
|
175
224
|
{children}
|
|
176
225
|
</div>
|
|
177
226
|
</main>
|
|
227
|
+
{bottomBar}
|
|
228
|
+
{showScrollTop && (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={scrollToTop}
|
|
232
|
+
className="absolute bottom-3 right-4 z-20 flex h-8 w-8 items-center justify-center rounded-full border bg-background shadow-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
|
233
|
+
style={{ bottom: bottomBar ? 76 : 12 }}
|
|
234
|
+
>
|
|
235
|
+
<ArrowUp className="h-3.5 w-3.5" />
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
178
238
|
</div>
|
|
179
239
|
|
|
180
240
|
{detailPanel && (
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react";
|
|
2
|
+
import { Loader2, ChevronRight, CornerDownLeft } from "lucide-react";
|
|
3
|
+
import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
|
|
4
|
+
import { Markdown } from "./Markdown.tsx";
|
|
5
|
+
import { TipTapEditor, getTextWithAnchors, type AnchorItem, type CommandItem } from "./TipTapEditor.tsx";
|
|
6
|
+
import type { useEditor } from "@tiptap/react";
|
|
7
|
+
|
|
8
|
+
export interface ChatState {
|
|
9
|
+
messages: ChatMessage[];
|
|
10
|
+
input: string;
|
|
11
|
+
loading: boolean;
|
|
12
|
+
streaming: { segments: ChatSegment[]; activeToolName?: string } | null;
|
|
13
|
+
loaded: boolean;
|
|
14
|
+
setInput: (v: string) => void;
|
|
15
|
+
sendMessage: (text?: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ChatContextValue {
|
|
19
|
+
state: ChatState;
|
|
20
|
+
anchorItems?: AnchorItem[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
export function useChatState(sessionId?: string | null): ChatState {
|
|
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);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (prevSessionId.current !== sessionId) {
|
|
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]);
|
|
196
|
+
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ToolCallDisplay({ tc }: { tc: ChatToolCall }) {
|
|
200
|
+
const [open, setOpen] = useState(false);
|
|
201
|
+
const truncated = tc.result && tc.result.length > 200;
|
|
202
|
+
const displayResult = truncated && !open ? `${tc.result!.slice(0, 200)}…` : tc.result;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div className="text-[11px]">
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => setOpen(!open)}
|
|
209
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-accent/60 text-muted-foreground/60 hover:text-foreground hover:bg-accent transition-colors text-left"
|
|
210
|
+
>
|
|
211
|
+
<ChevronRight className={`h-2.5 w-2.5 shrink-0 transition-transform ${open ? "rotate-90" : ""}`} />
|
|
212
|
+
<span className="font-mono">{tc.name}</span>
|
|
213
|
+
{Object.keys(tc.arguments).length > 0 && (
|
|
214
|
+
<span className="text-muted-foreground/30 truncate max-w-[200px]">
|
|
215
|
+
{Object.entries(tc.arguments).map(([k, v]) => `${k}: ${String(v)}`).join(", ")}
|
|
216
|
+
</span>
|
|
217
|
+
)}
|
|
218
|
+
</button>
|
|
219
|
+
{open && tc.result && (
|
|
220
|
+
<pre className="mt-1 ml-1 px-2.5 py-2 text-[10px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-md bg-accent/30">
|
|
221
|
+
{displayResult}
|
|
222
|
+
</pre>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function segmentsFromMessage(msg: ChatMessage): ChatSegment[] {
|
|
229
|
+
if (msg.segments && msg.segments.length > 0) return msg.segments;
|
|
230
|
+
const segs: ChatSegment[] = [];
|
|
231
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
232
|
+
for (const tc of msg.toolCalls) {
|
|
233
|
+
segs.push({ type: "tool_call", toolCall: tc });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (msg.content) {
|
|
237
|
+
segs.push({ type: "text", content: msg.content });
|
|
238
|
+
}
|
|
239
|
+
return segs;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick, activeId }: {
|
|
243
|
+
segments: ChatSegment[];
|
|
244
|
+
activeToolName?: string;
|
|
245
|
+
isStreaming?: boolean;
|
|
246
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
247
|
+
activeId?: string | null;
|
|
248
|
+
}) {
|
|
249
|
+
const hasContent = segments.some((s) => s.type === "text" && s.content);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div className="space-y-2">
|
|
253
|
+
{segments.map((seg, i) => {
|
|
254
|
+
if (seg.type === "tool_call") {
|
|
255
|
+
return <ToolCallDisplay key={seg.toolCall.id} tc={seg.toolCall} />;
|
|
256
|
+
}
|
|
257
|
+
return seg.content ? (
|
|
258
|
+
<div key={`text-${i}`} className="text-xs leading-relaxed">
|
|
259
|
+
<Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
|
|
260
|
+
</div>
|
|
261
|
+
) : null;
|
|
262
|
+
})}
|
|
263
|
+
{activeToolName && (
|
|
264
|
+
<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">
|
|
265
|
+
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
|
266
|
+
<span className="font-mono">{activeToolName}</span>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
{isStreaming && !hasContent && !activeToolName && segments.length === 0 && (
|
|
270
|
+
<div className="flex items-center gap-1.5">
|
|
271
|
+
<span className="flex gap-1">
|
|
272
|
+
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30 animate-pulse" />
|
|
273
|
+
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:150ms]" />
|
|
274
|
+
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:300ms]" />
|
|
275
|
+
</span>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function ChatMessages({ onAnchorClick, activeId }: {
|
|
283
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
284
|
+
activeId?: string | null;
|
|
285
|
+
}) {
|
|
286
|
+
const ctx = useContext(ChatContext);
|
|
287
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
288
|
+
const isNearBottomRef = useRef(true);
|
|
289
|
+
const mainElRef = useRef<HTMLElement | null>(null);
|
|
290
|
+
const scrollListenerRef = useRef<(() => void) | null>(null);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (scrollListenerRef.current) return;
|
|
294
|
+
const el = containerRef.current?.closest("main") as HTMLElement | null;
|
|
295
|
+
if (!el) return;
|
|
296
|
+
mainElRef.current = el;
|
|
297
|
+
const onScroll = () => {
|
|
298
|
+
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
|
299
|
+
};
|
|
300
|
+
scrollListenerRef.current = onScroll;
|
|
301
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
302
|
+
return () => {
|
|
303
|
+
el.removeEventListener("scroll", onScroll);
|
|
304
|
+
scrollListenerRef.current = null;
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const el = mainElRef.current ?? containerRef.current?.closest("main") as HTMLElement | null;
|
|
310
|
+
if (el) {
|
|
311
|
+
mainElRef.current = el;
|
|
312
|
+
if (isNearBottomRef.current) {
|
|
313
|
+
el.scrollTop = el.scrollHeight;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}, [ctx?.state.messages, ctx?.state.streaming]);
|
|
317
|
+
|
|
318
|
+
if (!ctx) return null;
|
|
319
|
+
const { messages, streaming, loaded, loading } = ctx.state;
|
|
320
|
+
const hasMessages = messages.length > 0 || loading;
|
|
321
|
+
|
|
322
|
+
if (!hasMessages && loaded) {
|
|
323
|
+
return (
|
|
324
|
+
<div className="border-t mt-6 pt-6 text-center">
|
|
325
|
+
<p className="text-[11px] text-muted-foreground/40">Ask anything about this PR</p>
|
|
326
|
+
<p className="text-[10px] text-muted-foreground/20 mt-1">@ to reference files · / for commands</p>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!hasMessages) return null;
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div ref={containerRef} className="border-t mt-6 pt-5 space-y-5">
|
|
335
|
+
<div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">Chat</div>
|
|
336
|
+
{messages.map((msg, i) => {
|
|
337
|
+
if (msg.role === "user") {
|
|
338
|
+
return (
|
|
339
|
+
<div key={`user-${i}`} className="flex justify-end">
|
|
340
|
+
<div className="max-w-[80%] rounded-xl rounded-br-sm bg-foreground text-background px-3.5 py-2 text-[11px] leading-relaxed">
|
|
341
|
+
{msg.content}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return (
|
|
347
|
+
<AssistantMessage
|
|
348
|
+
key={`assistant-${i}`}
|
|
349
|
+
segments={segmentsFromMessage(msg)}
|
|
350
|
+
onAnchorClick={onAnchorClick}
|
|
351
|
+
activeId={activeId}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
})}
|
|
355
|
+
|
|
356
|
+
{streaming && (
|
|
357
|
+
<AssistantMessage
|
|
358
|
+
segments={streaming.segments}
|
|
359
|
+
activeToolName={streaming.activeToolName}
|
|
360
|
+
onAnchorClick={onAnchorClick}
|
|
361
|
+
activeId={activeId}
|
|
362
|
+
isStreaming
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function ChatInput() {
|
|
370
|
+
const ctx = useContext(ChatContext);
|
|
371
|
+
const editorRef = useRef<ReturnType<typeof useEditor>>(null);
|
|
372
|
+
|
|
373
|
+
const handleSubmit = useCallback(() => {
|
|
374
|
+
if (!ctx) return;
|
|
375
|
+
const text = editorRef.current ? getTextWithAnchors(editorRef.current) : "";
|
|
376
|
+
if (!text) return;
|
|
377
|
+
editorRef.current?.commands.clearContent();
|
|
378
|
+
ctx.state.sendMessage(text);
|
|
379
|
+
}, [ctx]);
|
|
380
|
+
|
|
381
|
+
const chatCommands = useMemo<CommandItem[]>(() => [
|
|
382
|
+
{ id: "undo", label: "/undo", description: "Remove last exchange" },
|
|
383
|
+
], []);
|
|
384
|
+
|
|
385
|
+
if (!ctx) return null;
|
|
386
|
+
const { loading } = ctx.state;
|
|
387
|
+
const { anchorItems } = ctx;
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<div className="px-10 pb-3 pt-2 border-t bg-background">
|
|
391
|
+
<div className="mx-auto max-w-5xl">
|
|
392
|
+
<div className="relative rounded-xl border bg-background px-4 py-2.5 pr-12 focus-within:border-foreground/15 focus-within:shadow-sm transition-all">
|
|
393
|
+
<TipTapEditor
|
|
394
|
+
editorRef={editorRef}
|
|
395
|
+
placeholder="Ask about this PR..."
|
|
396
|
+
disabled={loading}
|
|
397
|
+
submitOnEnter
|
|
398
|
+
onSubmit={handleSubmit}
|
|
399
|
+
className="max-h-[120px] overflow-y-auto"
|
|
400
|
+
anchorItems={anchorItems}
|
|
401
|
+
commands={chatCommands}
|
|
402
|
+
/>
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
onClick={handleSubmit}
|
|
406
|
+
disabled={loading}
|
|
407
|
+
className="absolute right-2.5 bottom-2 flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background transition-opacity disabled:opacity-20 hover:opacity-80"
|
|
408
|
+
>
|
|
409
|
+
{loading ? (
|
|
410
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
411
|
+
) : (
|
|
412
|
+
<CornerDownLeft className="h-3.5 w-3.5" />
|
|
413
|
+
)}
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
<div className="flex items-center justify-between mt-1.5 px-1">
|
|
417
|
+
<span className="text-[10px] text-muted-foreground/25">
|
|
418
|
+
@ to reference · / for commands
|
|
419
|
+
</span>
|
|
420
|
+
<span className="text-[10px] text-muted-foreground/25">
|
|
421
|
+
Enter to send
|
|
422
|
+
</span>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|