newpr 0.1.3 → 0.2.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 (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -14,6 +14,7 @@ export type RiskLevel = "low" | "medium" | "high";
14
14
  export interface PrMeta {
15
15
  pr_number: number;
16
16
  pr_title: string;
17
+ pr_body?: string;
17
18
  pr_url: string;
18
19
  base_branch: string;
19
20
  head_branch: string;
@@ -56,6 +57,49 @@ export interface CartoonImage {
56
57
  generatedAt: string;
57
58
  }
58
59
 
60
+ export interface DiffComment {
61
+ id: string;
62
+ sessionId: string;
63
+ filePath: string;
64
+ line: number;
65
+ startLine?: number;
66
+ side: "old" | "new";
67
+ body: string;
68
+ author: string;
69
+ authorAvatar?: string;
70
+ createdAt: string;
71
+ githubUrl?: string;
72
+ githubCommentId?: number;
73
+ }
74
+
75
+ export interface PendingComment {
76
+ tempId: string;
77
+ filePath: string;
78
+ line: number;
79
+ side: "old" | "new";
80
+ body: string;
81
+ }
82
+
83
+ export interface ChatToolCall {
84
+ id: string;
85
+ name: string;
86
+ arguments: Record<string, unknown>;
87
+ result?: string;
88
+ }
89
+
90
+ export type ChatSegment =
91
+ | { type: "text"; content: string }
92
+ | { type: "tool_call"; toolCall: ChatToolCall };
93
+
94
+ export interface ChatMessage {
95
+ role: "user" | "assistant" | "tool";
96
+ content: string;
97
+ toolCalls?: ChatToolCall[];
98
+ segments?: ChatSegment[];
99
+ toolCallId?: string;
100
+ timestamp: string;
101
+ }
102
+
59
103
  export interface NewprOutput {
60
104
  meta: PrMeta;
61
105
  summary: PrSummary;
@@ -10,6 +10,8 @@ import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
10
10
  import { ResultsScreen } from "./components/ResultsScreen.tsx";
11
11
  import { ErrorScreen } from "./components/ErrorScreen.tsx";
12
12
  import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
13
+ import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
14
+ import type { AnchorItem } from "./components/TipTapEditor.tsx";
13
15
 
14
16
  function getUrlParam(key: string): string | null {
15
17
  return new URLSearchParams(window.location.search).get(key);
@@ -80,11 +82,28 @@ export function App() {
80
82
  analysis.reset();
81
83
  }
82
84
 
85
+ const diffSessionId = analysis.historyId ?? analysis.sessionId;
86
+ const prUrl = analysis.result?.meta.pr_url;
83
87
  const detailPanel = detailTarget ? (
84
- <DetailPane target={detailTarget} onClose={() => setActiveId(null)} />
88
+ <DetailPane target={detailTarget} sessionId={diffSessionId} prUrl={prUrl} onClose={() => setActiveId(null)} />
85
89
  ) : null;
86
90
 
91
+ const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
92
+
93
+ const anchorItems = useMemo<AnchorItem[]>(() => {
94
+ if (!analysis.result) return [];
95
+ const items: AnchorItem[] = [];
96
+ for (const g of analysis.result.groups) {
97
+ items.push({ kind: "group", id: g.name, label: g.name });
98
+ }
99
+ for (const f of analysis.result.files) {
100
+ items.push({ kind: "file", id: f.path, label: f.path });
101
+ }
102
+ return items;
103
+ }, [analysis.result]);
104
+
87
105
  return (
106
+ <ChatProvider state={chatState} anchorItems={anchorItems}>
88
107
  <AppShell
89
108
  theme={themeCtx.theme}
90
109
  onThemeChange={themeCtx.setTheme}
@@ -93,9 +112,15 @@ export function App() {
93
112
  onSessionSelect={handleSessionSelect}
94
113
  onNewAnalysis={handleNewAnalysis}
95
114
  detailPanel={detailPanel}
115
+ bottomBar={analysis.phase === "done" ? <ChatInput /> : undefined}
116
+ activeSessionId={diffSessionId}
96
117
  >
97
118
  {analysis.phase === "idle" && (
98
- <InputScreen onSubmit={(pr) => analysis.start(pr)} />
119
+ <InputScreen
120
+ onSubmit={(pr) => analysis.start(pr)}
121
+ sessions={sessions}
122
+ onSessionSelect={handleSessionSelect}
123
+ />
99
124
  )}
100
125
  {analysis.phase === "loading" && (
101
126
  <LoadingTimeline
@@ -110,7 +135,7 @@ export function App() {
110
135
  activeId={activeId}
111
136
  onAnchorClick={handleAnchorClick}
112
137
  cartoonEnabled={features.cartoon}
113
- sessionId={analysis.sessionId}
138
+ sessionId={diffSessionId}
114
139
  />
115
140
  )}
116
141
  {analysis.phase === "error" && (
@@ -121,5 +146,6 @@ export function App() {
121
146
  />
122
147
  )}
123
148
  </AppShell>
149
+ </ChatProvider>
124
150
  );
125
151
  }
@@ -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",
@@ -43,6 +43,8 @@ export function AppShell({
43
43
  onSessionSelect,
44
44
  onNewAnalysis,
45
45
  detailPanel,
46
+ bottomBar,
47
+ activeSessionId,
46
48
  children,
47
49
  }: {
48
50
  theme: Theme;
@@ -52,11 +54,27 @@ export function AppShell({
52
54
  onSessionSelect: (sessionId: string) => void;
53
55
  onNewAnalysis: () => void;
54
56
  detailPanel?: React.ReactNode;
57
+ bottomBar?: React.ReactNode;
58
+ activeSessionId?: string | null;
55
59
  children: React.ReactNode;
56
60
  }) {
57
61
  const [settingsOpen, setSettingsOpen] = useState(false);
58
62
  const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
59
63
  const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
64
+ const [showScrollTop, setShowScrollTop] = useState(false);
65
+ const mainRef = useRef<HTMLElement>(null);
66
+ const prevDetailPanel = useRef(detailPanel);
67
+
68
+ useEffect(() => {
69
+ const wasNull = prevDetailPanel.current == null;
70
+ prevDetailPanel.current = detailPanel;
71
+ if (wasNull && detailPanel != null) {
72
+ const available = window.innerWidth - leftWidth - 2;
73
+ const half = Math.floor(available * 0.55);
74
+ setRightWidth(Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, half)));
75
+ }
76
+ }, [detailPanel, leftWidth]);
77
+
60
78
  const Icon = THEME_ICON[theme];
61
79
  const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]!;
62
80
 
@@ -64,104 +82,122 @@ export function AppShell({
64
82
  setLeftWidth((w) => Math.min(LEFT_MAX, Math.max(LEFT_MIN, w + delta)));
65
83
  }, []);
66
84
 
85
+ const CENTER_MIN = 400;
86
+
67
87
  const handleRightResize = useCallback((delta: number) => {
68
- setRightWidth((w) => Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, w + delta)));
88
+ setRightWidth((w) => {
89
+ const available = window.innerWidth - leftWidth - 2;
90
+ const max = Math.min(RIGHT_MAX, available - CENTER_MIN);
91
+ return Math.min(max, Math.max(RIGHT_MIN, w + delta));
92
+ });
93
+ }, [leftWidth]);
94
+
95
+ useEffect(() => {
96
+ const el = mainRef.current;
97
+ if (!el) return;
98
+ const onScroll = () => setShowScrollTop(el.scrollTop > 300);
99
+ el.addEventListener("scroll", onScroll, { passive: true });
100
+ return () => el.removeEventListener("scroll", onScroll);
101
+ }, []);
102
+
103
+ const scrollToTop = useCallback(() => {
104
+ mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
69
105
  }, []);
70
106
 
71
107
  return (
72
108
  <div className="flex h-screen bg-background overflow-hidden">
73
109
  <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">
110
+ <div className="flex h-12 items-center justify-between px-4 shrink-0">
75
111
  <button
76
112
  type="button"
77
113
  onClick={onNewAnalysis}
78
- className="flex items-center gap-2 hover:opacity-80 transition-opacity"
114
+ className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
79
115
  >
80
- <span className="text-sm font-semibold tracking-tight">newpr</span>
81
- <span className="text-[10px] text-muted-foreground">v0.1.0</span>
116
+ <span className="text-xs font-semibold tracking-tight font-mono">newpr</span>
82
117
  </button>
83
118
  <button
84
119
  type="button"
85
120
  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"
121
+ 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
122
  title="New analysis"
88
123
  >
89
- <Plus className="h-4 w-4" />
124
+ <Plus className="h-3.5 w-3.5" />
90
125
  </button>
91
126
  </div>
92
127
 
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) => (
128
+ <div className="flex-1 overflow-y-auto px-2">
129
+ {sessions.length > 0 ? (
130
+ <div className="space-y-px">
131
+ {sessions.map((s) => {
132
+ const isActive = activeSessionId === s.id;
133
+ return (
101
134
  <button
102
135
  key={s.id}
103
136
  type="button"
104
137
  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"
138
+ className={`w-full flex items-start gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors group ${
139
+ isActive
140
+ ? "bg-accent text-foreground"
141
+ : "hover:bg-accent/40"
142
+ }`}
106
143
  >
107
- <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
144
+ <span className={`mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
108
145
  <div className="flex-1 min-w-0">
109
- <div className="text-sm truncate group-hover:text-foreground transition-colors">
146
+ <div className={`text-xs truncate leading-tight ${isActive ? "font-medium" : "text-foreground/80 group-hover:text-foreground"} transition-colors`}>
110
147
  {s.pr_title}
111
148
  </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" />
149
+ <div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/50">
150
+ <span className="font-mono truncate">{s.repo.split("/").pop()}</span>
151
+ <span className="font-mono">#{s.pr_number}</span>
152
+ <span className="text-muted-foreground/20 mx-0.5">·</span>
117
153
  <span>{formatTimeAgo(s.analyzed_at)}</span>
118
154
  </div>
119
155
  </div>
120
156
  </button>
121
- ))}
122
- </div>
157
+ );
158
+ })}
159
+ </div>
160
+ ) : (
161
+ <div className="flex flex-col items-center justify-center h-full text-center px-4 gap-2 opacity-40">
162
+ <p className="text-[11px] text-muted-foreground">No analyses yet</p>
123
163
  </div>
124
164
  )}
125
165
  </div>
126
166
 
127
- <div className="border-t px-3 py-3 space-y-2">
167
+ <div className="shrink-0 border-t px-2 py-2 space-y-1">
128
168
  {githubUser && (
129
169
  <a
130
170
  href={githubUser.html_url}
131
171
  target="_blank"
132
172
  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"
173
+ className="flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-accent/40 transition-colors"
134
174
  >
135
175
  <img
136
176
  src={githubUser.avatar_url}
137
177
  alt={githubUser.login}
138
- className="h-6 w-6 rounded-full"
178
+ className="h-5 w-5 rounded-full"
139
179
  />
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>
180
+ <span className="text-[11px] font-medium truncate flex-1">{githubUser.name ?? githubUser.login}</span>
146
181
  </a>
147
182
  )}
148
- <div className="flex items-center justify-between px-1.5">
183
+ <div className="flex items-center gap-1 px-1">
149
184
  <button
150
185
  type="button"
151
186
  onClick={() => onThemeChange(next)}
152
- className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
187
+ 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
188
  title={`Switch to ${next} mode`}
154
189
  >
155
- <Icon className="h-3.5 w-3.5" />
190
+ <Icon className="h-3 w-3" />
156
191
  <span className="capitalize">{theme}</span>
157
192
  </button>
193
+ <div className="flex-1" />
158
194
  <button
159
195
  type="button"
160
196
  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"
197
+ 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
198
  title="Settings"
163
199
  >
164
- <Settings className="h-3.5 w-3.5" />
200
+ <Settings className="h-3 w-3" />
165
201
  </button>
166
202
  </div>
167
203
  </div>
@@ -169,12 +205,23 @@ export function AppShell({
169
205
 
170
206
  <ResizeHandle onResize={handleLeftResize} side="right" />
171
207
 
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">
208
+ <div className="flex-1 flex flex-col overflow-hidden relative" style={{ minWidth: 400 }}>
209
+ <main ref={mainRef} className="flex-1 overflow-y-auto">
210
+ <div className="mx-auto max-w-5xl px-10 py-10">
175
211
  {children}
176
212
  </div>
177
213
  </main>
214
+ {bottomBar}
215
+ {showScrollTop && (
216
+ <button
217
+ type="button"
218
+ onClick={scrollToTop}
219
+ 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"
220
+ style={{ bottom: bottomBar ? 76 : 12 }}
221
+ >
222
+ <ArrowUp className="h-3.5 w-3.5" />
223
+ </button>
224
+ )}
178
225
  </div>
179
226
 
180
227
  {detailPanel && (