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.
Files changed (35) hide show
  1. package/README.md +135 -103
  2. package/package.json +2 -2
  3. package/src/analyzer/pipeline.ts +1 -4
  4. package/src/cli/args.ts +1 -1
  5. package/src/cli/index.ts +2 -1
  6. package/src/github/fetch-pr.ts +1 -0
  7. package/src/history/store.ts +25 -1
  8. package/src/llm/prompts.ts +82 -27
  9. package/src/llm/slides.ts +381 -0
  10. package/src/types/config.ts +1 -1
  11. package/src/types/github.ts +1 -0
  12. package/src/types/output.ts +26 -0
  13. package/src/version.ts +23 -0
  14. package/src/web/client/App.tsx +51 -1
  15. package/src/web/client/components/AppShell.tsx +173 -45
  16. package/src/web/client/components/ChatSection.tsx +76 -185
  17. package/src/web/client/components/DetailPane.tsx +1 -0
  18. package/src/web/client/components/DiffViewer.tsx +200 -4
  19. package/src/web/client/components/InputScreen.tsx +3 -0
  20. package/src/web/client/components/Markdown.tsx +66 -16
  21. package/src/web/client/components/ResultsScreen.tsx +32 -2
  22. package/src/web/client/components/SettingsPanel.tsx +1 -1
  23. package/src/web/client/hooks/useBackgroundAnalyses.ts +152 -0
  24. package/src/web/client/hooks/useChatStore.ts +247 -0
  25. package/src/web/client/hooks/useFeatures.ts +2 -1
  26. package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
  27. package/src/web/client/lib/notify.ts +21 -0
  28. package/src/web/client/panels/SlidesPanel.tsx +316 -0
  29. package/src/web/index.html +1 -0
  30. package/src/web/server/routes.ts +226 -4
  31. package/src/web/server/session-manager.ts +34 -0
  32. package/src/web/server.ts +20 -1
  33. package/src/web/styles/built.css +1 -1
  34. package/src/workspace/explore.ts +39 -6
  35. 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
- <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 && (
@@ -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 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);
25
+ export { useChatStore as useChatState };
32
26
 
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]);
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
- return seg.content ? (
117
+ if (!seg.content) return null;
118
+ return (
258
119
  <div key={`text-${i}`} className="text-xs leading-relaxed">
259
- <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{seg.content}</Markdown>
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
- ) : null;
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}`} 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}
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
- <AssistantMessage
348
- key={`assistant-${i}`}
349
- segments={segmentsFromMessage(msg)}
350
- onAnchorClick={onAnchorClick}
351
- activeId={activeId}
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
 
@@ -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}