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.
@@ -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
- <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 (
141
- <button
142
- key={s.id}
143
- type="button"
144
- onClick={() => onSessionSelect(s.id)}
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
- }`}
150
- >
151
- <span className={`mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
152
- <div className="flex-1 min-w-0">
153
- <div className={`text-xs truncate leading-tight ${isActive ? "font-medium" : "text-foreground/80 group-hover:text-foreground"} transition-colors`}>
154
- {s.pr_title}
155
- </div>
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>
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
- </button>
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>
176
- </div>
177
- )}
178
- </div>
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 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
- }
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]);
@@ -164,6 +164,7 @@ function FileDetail({
164
164
  )}
165
165
  {patch && (
166
166
  <DiffViewer
167
+ key={`${file.path}-${scrollToLine ?? 0}-${scrollToLineEnd ?? 0}`}
167
168
  patch={patch}
168
169
  filePath={file.path}
169
170
  sessionId={sessionId}
@@ -456,7 +456,19 @@ export function DiffViewer({
456
456
  if (!scrollTarget) scrollTarget = el;
457
457
  }
458
458
  }
459
- scrollTarget?.scrollIntoView({ behavior: "instant", block: "center" });
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
- const LINE_ANCHOR_WITH_TEXT_RE = /\[\[line:([^\]]+)\]\]\(([^)]+)\)/g;
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
- return text
125
- .replace(LINE_ANCHOR_WITH_TEXT_RE, (_, id, label) => {
126
- const encoded = encodeURIComponent(id);
127
- return `<span data-line-ref="${encoded}">${inlineMarkdownToHtml(label)}</span>`;
128
- })
129
- .replace(LINE_ANCHOR_BARE_RE, (_, id) => {
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 `![${kind}:${encoded}](newpr)`;
@@ -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 lineRef = (props as Record<string, unknown>)["data-line-ref"] as string | undefined;
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
- return <span>{children}</span>;
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 }], rehypeRaw]} components={components}>{processed}</ReactMarkdown>;
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
  }
@@ -20,7 +20,7 @@ interface ConfigData {
20
20
  }
21
21
 
22
22
  const MODELS = [
23
- "anthropic/claude-sonnet-4.5",
23
+ "anthropic/claude-sonnet-4.6",
24
24
  "anthropic/claude-sonnet-4-20250514",
25
25
  "openai/gpt-4.1",
26
26
  "openai/o3",