newpr 0.3.0 → 0.4.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 +1 -1
- package/src/analyzer/pipeline.ts +0 -4
- package/src/cli/args.ts +1 -1
- package/src/cli/index.ts +2 -1
- package/src/llm/prompts.ts +82 -27
- package/src/types/config.ts +1 -1
- package/src/version.ts +23 -0
- package/src/web/client/App.tsx +46 -0
- package/src/web/client/components/AppShell.tsx +173 -45
- package/src/web/client/components/ChatSection.tsx +2 -170
- package/src/web/client/components/DetailPane.tsx +1 -0
- package/src/web/client/components/DiffViewer.tsx +13 -1
- package/src/web/client/components/InputScreen.tsx +3 -0
- package/src/web/client/components/Markdown.tsx +66 -16
- package/src/web/client/components/SettingsPanel.tsx +1 -1
- package/src/web/client/hooks/useBackgroundAnalyses.ts +147 -0
- package/src/web/client/hooks/useChatStore.ts +244 -0
- package/src/web/client/hooks/useFeatures.ts +2 -1
- package/src/web/index.html +1 -0
- package/src/web/server/routes.ts +41 -2
- package/src/web/server/session-manager.ts +34 -0
- package/src/web/server.ts +5 -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 && (
|
|
@@ -4,14 +4,13 @@ import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/outp
|
|
|
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
|
|
|
@@ -22,174 +21,7 @@ interface ChatContextValue {
|
|
|
22
21
|
|
|
23
22
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
|
24
23
|
|
|
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);
|
|
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
|
-
}
|
|
24
|
+
export { useChatStore as useChatState };
|
|
193
25
|
|
|
194
26
|
export function ChatProvider({ state, anchorItems, children }: { state: ChatState; anchorItems?: AnchorItem[]; children: React.ReactNode }) {
|
|
195
27
|
const value = useMemo(() => ({ state, anchorItems }), [state, anchorItems]);
|
|
@@ -456,7 +456,19 @@ export function DiffViewer({
|
|
|
456
456
|
if (!scrollTarget) scrollTarget = el;
|
|
457
457
|
}
|
|
458
458
|
}
|
|
459
|
-
scrollTarget
|
|
459
|
+
if (scrollTarget) {
|
|
460
|
+
let scrollParent = scrollTarget.parentElement;
|
|
461
|
+
while (scrollParent) {
|
|
462
|
+
const style = getComputedStyle(scrollParent);
|
|
463
|
+
if (style.overflowY === "auto" || style.overflowY === "scroll") break;
|
|
464
|
+
scrollParent = scrollParent.parentElement;
|
|
465
|
+
}
|
|
466
|
+
if (scrollParent) {
|
|
467
|
+
const parentRect = scrollParent.getBoundingClientRect();
|
|
468
|
+
const targetRect = scrollTarget.getBoundingClientRect();
|
|
469
|
+
scrollParent.scrollTop += targetRect.top - parentRect.top - parentRect.height / 2;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
460
472
|
}, 100);
|
|
461
473
|
return () => clearTimeout(timer);
|
|
462
474
|
}, [scrollToLine, scrollToLineEnd, patch]);
|
|
@@ -82,10 +82,12 @@ export function InputScreen({
|
|
|
82
82
|
onSubmit,
|
|
83
83
|
sessions,
|
|
84
84
|
onSessionSelect,
|
|
85
|
+
version,
|
|
85
86
|
}: {
|
|
86
87
|
onSubmit: (pr: string) => void;
|
|
87
88
|
sessions?: SessionRecord[];
|
|
88
89
|
onSessionSelect?: (id: string) => void;
|
|
90
|
+
version?: string;
|
|
89
91
|
}) {
|
|
90
92
|
const [value, setValue] = useState("");
|
|
91
93
|
const [focused, setFocused] = useState(false);
|
|
@@ -112,6 +114,7 @@ export function InputScreen({
|
|
|
112
114
|
<div className="space-y-2">
|
|
113
115
|
<div className="flex items-baseline gap-2">
|
|
114
116
|
<h1 className="text-sm font-semibold tracking-tight font-mono">newpr</h1>
|
|
117
|
+
{version && <span className="text-[10px] text-muted-foreground/30">v{version}</span>}
|
|
115
118
|
<span className="text-[10px] text-muted-foreground/40">AI code review</span>
|
|
116
119
|
</div>
|
|
117
120
|
<p className="text-xs text-muted-foreground">
|
|
@@ -4,7 +4,6 @@ import remarkGfm from "remark-gfm";
|
|
|
4
4
|
import remarkMath from "remark-math";
|
|
5
5
|
import rehypeRaw from "rehype-raw";
|
|
6
6
|
import rehypeKatex from "rehype-katex";
|
|
7
|
-
import "katex/dist/katex.min.css";
|
|
8
7
|
import type { Components } from "react-markdown";
|
|
9
8
|
import type { Highlighter } from "shiki";
|
|
10
9
|
import { ensureHighlighter, getHighlighterSync, langFromClassName } from "../lib/shiki.ts";
|
|
@@ -96,8 +95,7 @@ function MediaEmbed({ src }: { src: string }) {
|
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
const ANCHOR_RE = /\[\[(group|file):([^\]]+)\]\]/g;
|
|
99
|
-
|
|
100
|
-
const LINE_ANCHOR_BARE_RE = /\[\[line:([^\]]+)\]\]/g;
|
|
98
|
+
|
|
101
99
|
const BOLD_CJK_RE = /\*\*(.+?)\*\*/g;
|
|
102
100
|
|
|
103
101
|
function hasCJK(text: string): boolean {
|
|
@@ -120,17 +118,65 @@ function inlineMarkdownToHtml(text: string): string {
|
|
|
120
118
|
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
121
119
|
}
|
|
122
120
|
|
|
121
|
+
function replaceLineAnchors(text: string): string {
|
|
122
|
+
const OPEN = "[[line:";
|
|
123
|
+
let result = "";
|
|
124
|
+
let i = 0;
|
|
125
|
+
while (i < text.length) {
|
|
126
|
+
const start = text.indexOf(OPEN, i);
|
|
127
|
+
if (start === -1) {
|
|
128
|
+
result += text.slice(i);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
result += text.slice(i, start);
|
|
132
|
+
|
|
133
|
+
const idStart = start + OPEN.length;
|
|
134
|
+
const closeBracket = text.indexOf("]", idStart);
|
|
135
|
+
if (closeBracket === -1) {
|
|
136
|
+
result += text.slice(start);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const id = text.slice(idStart, closeBracket);
|
|
141
|
+
let afterClose = closeBracket + 1;
|
|
142
|
+
|
|
143
|
+
if (text[afterClose] === "]") afterClose++;
|
|
144
|
+
|
|
145
|
+
let label: string | null = null;
|
|
146
|
+
if (text[afterClose] === "(") {
|
|
147
|
+
let depth = 1;
|
|
148
|
+
let end = afterClose + 1;
|
|
149
|
+
while (end < text.length && depth > 0) {
|
|
150
|
+
if (text[end] === "(") depth++;
|
|
151
|
+
else if (text[end] === ")") depth--;
|
|
152
|
+
end++;
|
|
153
|
+
}
|
|
154
|
+
if (depth === 0) {
|
|
155
|
+
label = text.slice(afterClose + 1, end - 1);
|
|
156
|
+
afterClose = end;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (text[afterClose] === "]") afterClose++;
|
|
161
|
+
|
|
162
|
+
const encoded = encodeURIComponent(id);
|
|
163
|
+
if (label) {
|
|
164
|
+
result += `<span data-line-ref="${encoded}">${inlineMarkdownToHtml(label)}</span>`;
|
|
165
|
+
} else {
|
|
166
|
+
result += `<span data-line-ref="${encoded}">${formatLineLabel(id)}</span>`;
|
|
167
|
+
}
|
|
168
|
+
i = afterClose;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
123
173
|
function preprocess(text: string): string {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const encoded = encodeURIComponent(id);
|
|
131
|
-
const label = formatLineLabel(id);
|
|
132
|
-
return `<span data-line-ref="${encoded}">${label}</span>`;
|
|
133
|
-
})
|
|
174
|
+
const mathBlocks: string[] = [];
|
|
175
|
+
const preserved = text
|
|
176
|
+
.replace(/\$\$[\s\S]+?\$\$/g, (m) => { mathBlocks.push(m); return `\x00MATH_BLOCK_${mathBlocks.length - 1}\x00`; })
|
|
177
|
+
.replace(/\$(?!\$)(.+?)\$/g, (m) => { mathBlocks.push(m); return `\x00MATH_BLOCK_${mathBlocks.length - 1}\x00`; });
|
|
178
|
+
|
|
179
|
+
const processed = replaceLineAnchors(preserved)
|
|
134
180
|
.replace(ANCHOR_RE, (_, kind, id) => {
|
|
135
181
|
const encoded = encodeURIComponent(id);
|
|
136
182
|
return ``;
|
|
@@ -139,6 +185,8 @@ function preprocess(text: string): string {
|
|
|
139
185
|
if (hasCJK(inner)) return `<strong>${inner}</strong>`;
|
|
140
186
|
return match;
|
|
141
187
|
});
|
|
188
|
+
|
|
189
|
+
return processed.replace(/\x00MATH_BLOCK_(\d+)\x00/g, (_, idx) => mathBlocks[Number(idx)]!);
|
|
142
190
|
}
|
|
143
191
|
|
|
144
192
|
export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
@@ -178,7 +226,8 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
178
226
|
<pre className="bg-muted rounded-lg p-4 overflow-x-auto mb-3 whitespace-pre text-xs font-mono [&>span>pre]:!bg-transparent [&>span>pre]:!p-0 [&>span>pre]:!m-0">{children}</pre>
|
|
179
227
|
),
|
|
180
228
|
span: ({ children, ...props }) => {
|
|
181
|
-
const
|
|
229
|
+
const allProps = props as Record<string, unknown>;
|
|
230
|
+
const lineRef = allProps["data-line-ref"] as string | undefined;
|
|
182
231
|
if (lineRef && onAnchorClick) {
|
|
183
232
|
const id = decodeURIComponent(lineRef);
|
|
184
233
|
const isActive = activeId === `line:${id}`;
|
|
@@ -201,7 +250,8 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
201
250
|
if (lineRef) {
|
|
202
251
|
return <span className="underline decoration-foreground/10 decoration-1 underline-offset-[3px]">{children}</span>;
|
|
203
252
|
}
|
|
204
|
-
|
|
253
|
+
const { node, ...rest } = allProps as Record<string, unknown> & { node?: unknown };
|
|
254
|
+
return <span {...rest as React.HTMLAttributes<HTMLSpanElement>}>{children}</span>;
|
|
205
255
|
},
|
|
206
256
|
a: ({ href, children }) => {
|
|
207
257
|
if (href && isMediaUrl(href)) {
|
|
@@ -284,5 +334,5 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
|
|
|
284
334
|
td: ({ children }) => <td className="border-b border-border px-3 py-1.5 text-sm">{children}</td>,
|
|
285
335
|
};
|
|
286
336
|
|
|
287
|
-
return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }]
|
|
337
|
+
return <ReactMarkdown remarkPlugins={[[remarkMath, { singleDollarTextMath: true }], remarkGfm]} rehypePlugins={[[rehypeRaw, { passThrough: ["math", "inlineMath"] }], [rehypeKatex, { throwOnError: false, strict: false, output: "html" }]]} components={components}>{processed}</ReactMarkdown>;
|
|
288
338
|
}
|