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.
Files changed (43) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +37 -15
  3. package/src/analyzer/progress.ts +2 -0
  4. package/src/cli/index.ts +7 -2
  5. package/src/cli/preflight.ts +126 -0
  6. package/src/github/fetch-pr.ts +53 -1
  7. package/src/history/store.ts +107 -1
  8. package/src/history/types.ts +1 -0
  9. package/src/llm/client.ts +197 -0
  10. package/src/llm/prompts.ts +80 -19
  11. package/src/llm/response-parser.ts +13 -1
  12. package/src/tui/Shell.tsx +7 -2
  13. package/src/types/github.ts +14 -0
  14. package/src/types/output.ts +50 -0
  15. package/src/web/client/App.tsx +33 -5
  16. package/src/web/client/components/AppShell.tsx +107 -47
  17. package/src/web/client/components/ChatSection.tsx +427 -0
  18. package/src/web/client/components/DetailPane.tsx +217 -77
  19. package/src/web/client/components/DiffViewer.tsx +713 -0
  20. package/src/web/client/components/InputScreen.tsx +178 -27
  21. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  22. package/src/web/client/components/Markdown.tsx +220 -41
  23. package/src/web/client/components/ResultsScreen.tsx +109 -73
  24. package/src/web/client/components/ReviewModal.tsx +187 -0
  25. package/src/web/client/components/SettingsPanel.tsx +62 -86
  26. package/src/web/client/components/TipTapEditor.tsx +405 -0
  27. package/src/web/client/hooks/useAnalysis.ts +8 -1
  28. package/src/web/client/lib/shiki.ts +63 -0
  29. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  30. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  31. package/src/web/client/panels/FilesPanel.tsx +435 -54
  32. package/src/web/client/panels/GroupsPanel.tsx +62 -40
  33. package/src/web/client/panels/StoryPanel.tsx +43 -23
  34. package/src/web/components/ui/tabs.tsx +3 -3
  35. package/src/web/server/routes.ts +856 -14
  36. package/src/web/server/session-manager.ts +11 -2
  37. package/src/web/server.ts +66 -4
  38. package/src/web/styles/built.css +1 -1
  39. package/src/web/styles/globals.css +117 -1
  40. package/src/workspace/agent.ts +22 -6
  41. package/src/workspace/explore.ts +41 -16
  42. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  43. 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, Clock, Settings } from "lucide-react";
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 = 240;
17
- const RIGHT_MAX = 520;
18
- const RIGHT_DEFAULT = 320;
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) => Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, w + delta)));
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-14 items-center justify-between px-4 border-b">
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-2 hover:opacity-80 transition-opacity"
121
+ className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
79
122
  >
80
- <span className="text-sm font-semibold tracking-tight">newpr</span>
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-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
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-4 w-4" />
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="px-2 py-3">
96
- <div className="px-2 pb-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
97
- Recent
98
- </div>
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="w-full flex items-start gap-2.5 rounded-md px-2 py-2 text-left hover:bg-accent/50 transition-colors group"
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 h-2 w-2 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
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="text-sm truncate group-hover:text-foreground transition-colors">
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.5 mt-0.5 text-[11px] text-muted-foreground">
113
- <span className="truncate">{s.repo.split("/").pop()}</span>
114
- <span>#{s.pr_number}</span>
115
- <span className="text-muted-foreground/50">·</span>
116
- <Clock className="h-2.5 w-2.5" />
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
- </div>
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-3 py-3 space-y-2">
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.5 rounded-md px-1.5 py-1.5 hover:bg-accent/50 transition-colors"
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-6 w-6 rounded-full"
191
+ className="h-5 w-5 rounded-full"
139
192
  />
140
- <div className="flex-1 min-w-0">
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 justify-between px-1.5">
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-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
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.5 w-3.5" />
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-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
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.5 w-3.5" />
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 min-w-0 overflow-hidden">
173
- <main className="flex-1 overflow-y-auto">
174
- <div className="mx-auto max-w-4xl px-10 py-10">
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
+ }