newpr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -0
- package/package.json +78 -0
- package/src/analyzer/errors.ts +22 -0
- package/src/analyzer/pipeline.ts +299 -0
- package/src/analyzer/progress.ts +69 -0
- package/src/cli/args.ts +192 -0
- package/src/cli/auth.ts +82 -0
- package/src/cli/history-cmd.ts +64 -0
- package/src/cli/index.ts +115 -0
- package/src/cli/pretty.ts +79 -0
- package/src/config/index.ts +103 -0
- package/src/config/store.ts +50 -0
- package/src/diff/chunker.ts +30 -0
- package/src/diff/parser.ts +116 -0
- package/src/diff/stats.ts +37 -0
- package/src/github/auth.ts +16 -0
- package/src/github/fetch-diff.ts +24 -0
- package/src/github/fetch-pr.ts +90 -0
- package/src/github/parse-pr.ts +39 -0
- package/src/history/store.ts +96 -0
- package/src/history/types.ts +15 -0
- package/src/llm/claude-code-client.ts +134 -0
- package/src/llm/client.ts +240 -0
- package/src/llm/prompts.ts +176 -0
- package/src/llm/response-parser.ts +71 -0
- package/src/tui/App.tsx +97 -0
- package/src/tui/Footer.tsx +34 -0
- package/src/tui/Header.tsx +27 -0
- package/src/tui/HelpOverlay.tsx +46 -0
- package/src/tui/InputBar.tsx +65 -0
- package/src/tui/Loading.tsx +192 -0
- package/src/tui/Shell.tsx +384 -0
- package/src/tui/TabBar.tsx +31 -0
- package/src/tui/commands.ts +75 -0
- package/src/tui/narrative-parser.ts +143 -0
- package/src/tui/panels/FilesPanel.tsx +134 -0
- package/src/tui/panels/GroupsPanel.tsx +140 -0
- package/src/tui/panels/NarrativePanel.tsx +102 -0
- package/src/tui/panels/StoryPanel.tsx +296 -0
- package/src/tui/panels/SummaryPanel.tsx +59 -0
- package/src/tui/panels/WalkthroughPanel.tsx +149 -0
- package/src/tui/render.tsx +62 -0
- package/src/tui/theme.ts +44 -0
- package/src/types/config.ts +19 -0
- package/src/types/diff.ts +36 -0
- package/src/types/github.ts +28 -0
- package/src/types/output.ts +59 -0
- package/src/web/client/App.tsx +121 -0
- package/src/web/client/components/AppShell.tsx +203 -0
- package/src/web/client/components/DetailPane.tsx +141 -0
- package/src/web/client/components/ErrorScreen.tsx +119 -0
- package/src/web/client/components/InputScreen.tsx +41 -0
- package/src/web/client/components/LoadingTimeline.tsx +179 -0
- package/src/web/client/components/Markdown.tsx +109 -0
- package/src/web/client/components/ResizeHandle.tsx +45 -0
- package/src/web/client/components/ResultsScreen.tsx +185 -0
- package/src/web/client/components/SettingsPanel.tsx +299 -0
- package/src/web/client/hooks/useAnalysis.ts +153 -0
- package/src/web/client/hooks/useGithubUser.ts +24 -0
- package/src/web/client/hooks/useSessions.ts +17 -0
- package/src/web/client/hooks/useTheme.ts +34 -0
- package/src/web/client/main.tsx +12 -0
- package/src/web/client/panels/FilesPanel.tsx +85 -0
- package/src/web/client/panels/GroupsPanel.tsx +62 -0
- package/src/web/client/panels/NarrativePanel.tsx +9 -0
- package/src/web/client/panels/StoryPanel.tsx +54 -0
- package/src/web/client/panels/SummaryPanel.tsx +20 -0
- package/src/web/components/ui/button.tsx +46 -0
- package/src/web/components/ui/card.tsx +37 -0
- package/src/web/components/ui/scroll-area.tsx +39 -0
- package/src/web/components/ui/tabs.tsx +52 -0
- package/src/web/index.html +14 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/server/routes.ts +202 -0
- package/src/web/server/session-manager.ts +147 -0
- package/src/web/server.ts +96 -0
- package/src/web/styles/globals.css +91 -0
- package/src/workspace/agent.ts +317 -0
- package/src/workspace/explore.ts +82 -0
- package/src/workspace/repo-cache.ts +69 -0
- package/src/workspace/types.ts +30 -0
- package/src/workspace/worktree.ts +129 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type FileStatus = "added" | "modified" | "deleted" | "renamed";
|
|
2
|
+
|
|
3
|
+
export type GroupType =
|
|
4
|
+
| "feature"
|
|
5
|
+
| "refactor"
|
|
6
|
+
| "bugfix"
|
|
7
|
+
| "chore"
|
|
8
|
+
| "docs"
|
|
9
|
+
| "test"
|
|
10
|
+
| "config";
|
|
11
|
+
|
|
12
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
13
|
+
|
|
14
|
+
export interface PrMeta {
|
|
15
|
+
pr_number: number;
|
|
16
|
+
pr_title: string;
|
|
17
|
+
pr_url: string;
|
|
18
|
+
base_branch: string;
|
|
19
|
+
head_branch: string;
|
|
20
|
+
author: string;
|
|
21
|
+
author_avatar?: string;
|
|
22
|
+
author_url?: string;
|
|
23
|
+
total_files_changed: number;
|
|
24
|
+
total_additions: number;
|
|
25
|
+
total_deletions: number;
|
|
26
|
+
analyzed_at: string;
|
|
27
|
+
model_used: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PrSummary {
|
|
31
|
+
purpose: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
impact: string;
|
|
34
|
+
risk_level: RiskLevel;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FileGroup {
|
|
38
|
+
name: string;
|
|
39
|
+
type: GroupType;
|
|
40
|
+
description: string;
|
|
41
|
+
files: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FileChange {
|
|
45
|
+
path: string;
|
|
46
|
+
status: FileStatus;
|
|
47
|
+
additions: number;
|
|
48
|
+
deletions: number;
|
|
49
|
+
summary: string;
|
|
50
|
+
groups: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NewprOutput {
|
|
54
|
+
meta: PrMeta;
|
|
55
|
+
summary: PrSummary;
|
|
56
|
+
groups: FileGroup[];
|
|
57
|
+
files: FileChange[];
|
|
58
|
+
narrative: string;
|
|
59
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
2
|
+
import { useAnalysis } from "./hooks/useAnalysis.ts";
|
|
3
|
+
import { useTheme } from "./hooks/useTheme.ts";
|
|
4
|
+
import { useSessions } from "./hooks/useSessions.ts";
|
|
5
|
+
import { useGithubUser } from "./hooks/useGithubUser.ts";
|
|
6
|
+
import { AppShell } from "./components/AppShell.tsx";
|
|
7
|
+
import { InputScreen } from "./components/InputScreen.tsx";
|
|
8
|
+
import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
|
|
9
|
+
import { ResultsScreen } from "./components/ResultsScreen.tsx";
|
|
10
|
+
import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
11
|
+
import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
|
|
12
|
+
|
|
13
|
+
function getUrlParam(key: string): string | null {
|
|
14
|
+
return new URLSearchParams(window.location.search).get(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function setUrlParams(params: Record<string, string | null>) {
|
|
18
|
+
const url = new URL(window.location.href);
|
|
19
|
+
for (const [k, v] of Object.entries(params)) {
|
|
20
|
+
if (v === null) {
|
|
21
|
+
url.searchParams.delete(k);
|
|
22
|
+
} else {
|
|
23
|
+
url.searchParams.set(k, v);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
window.history.replaceState(null, "", url.toString());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function App() {
|
|
30
|
+
const analysis = useAnalysis();
|
|
31
|
+
const themeCtx = useTheme();
|
|
32
|
+
const { sessions, refresh: refreshSessions } = useSessions();
|
|
33
|
+
const githubUser = useGithubUser();
|
|
34
|
+
const initialLoadDone = useRef(false);
|
|
35
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (initialLoadDone.current) return;
|
|
39
|
+
initialLoadDone.current = true;
|
|
40
|
+
const sid = getUrlParam("session");
|
|
41
|
+
if (sid) {
|
|
42
|
+
analysis.loadStoredSession(sid);
|
|
43
|
+
}
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (analysis.phase === "done" && analysis.sessionId) {
|
|
48
|
+
const url = new URL(window.location.href);
|
|
49
|
+
url.searchParams.set("session", analysis.sessionId);
|
|
50
|
+
window.history.replaceState(null, "", url.toString());
|
|
51
|
+
refreshSessions();
|
|
52
|
+
} else if (analysis.phase === "idle") {
|
|
53
|
+
setUrlParams({ session: null, tab: null });
|
|
54
|
+
setActiveId(null);
|
|
55
|
+
}
|
|
56
|
+
}, [analysis.phase, analysis.sessionId]);
|
|
57
|
+
|
|
58
|
+
const handleAnchorClick = useCallback((kind: "group" | "file", id: string) => {
|
|
59
|
+
const key = `${kind}:${id}`;
|
|
60
|
+
setActiveId((prev) => prev === key ? null : key);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const detailTarget = useMemo(() => {
|
|
64
|
+
if (!activeId || !analysis.result) return null;
|
|
65
|
+
const [kind, ...rest] = activeId.split(":");
|
|
66
|
+
const id = rest.join(":");
|
|
67
|
+
return resolveDetail(kind as "group" | "file", id, analysis.result.groups, analysis.result.files);
|
|
68
|
+
}, [activeId, analysis.result]);
|
|
69
|
+
|
|
70
|
+
function handleSessionSelect(id: string) {
|
|
71
|
+
setActiveId(null);
|
|
72
|
+
analysis.loadStoredSession(id);
|
|
73
|
+
setUrlParams({ session: id, tab: null });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleNewAnalysis() {
|
|
77
|
+
setActiveId(null);
|
|
78
|
+
analysis.reset();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const detailPanel = detailTarget ? (
|
|
82
|
+
<DetailPane target={detailTarget} onClose={() => setActiveId(null)} />
|
|
83
|
+
) : null;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<AppShell
|
|
87
|
+
theme={themeCtx.theme}
|
|
88
|
+
onThemeChange={themeCtx.setTheme}
|
|
89
|
+
sessions={sessions}
|
|
90
|
+
githubUser={githubUser}
|
|
91
|
+
onSessionSelect={handleSessionSelect}
|
|
92
|
+
onNewAnalysis={handleNewAnalysis}
|
|
93
|
+
detailPanel={detailPanel}
|
|
94
|
+
>
|
|
95
|
+
{analysis.phase === "idle" && (
|
|
96
|
+
<InputScreen onSubmit={(pr) => analysis.start(pr)} />
|
|
97
|
+
)}
|
|
98
|
+
{analysis.phase === "loading" && (
|
|
99
|
+
<LoadingTimeline
|
|
100
|
+
events={analysis.events}
|
|
101
|
+
startedAt={analysis.startedAt!}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
{analysis.phase === "done" && analysis.result && (
|
|
105
|
+
<ResultsScreen
|
|
106
|
+
data={analysis.result}
|
|
107
|
+
onBack={handleNewAnalysis}
|
|
108
|
+
activeId={activeId}
|
|
109
|
+
onAnchorClick={handleAnchorClick}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
{analysis.phase === "error" && (
|
|
113
|
+
<ErrorScreen
|
|
114
|
+
error={analysis.error ?? "An unknown error occurred"}
|
|
115
|
+
onRetry={analysis.lastPrInput ? () => analysis.start(analysis.lastPrInput!) : undefined}
|
|
116
|
+
onBack={handleNewAnalysis}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
</AppShell>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { Sun, Moon, Monitor, Plus, Clock, Settings } from "lucide-react";
|
|
3
|
+
import type { SessionRecord } from "../../../history/types.ts";
|
|
4
|
+
import type { GithubUser } from "../hooks/useGithubUser.ts";
|
|
5
|
+
import { SettingsPanel } from "./SettingsPanel.tsx";
|
|
6
|
+
import { ResizeHandle } from "./ResizeHandle.tsx";
|
|
7
|
+
|
|
8
|
+
type Theme = "light" | "dark" | "system";
|
|
9
|
+
|
|
10
|
+
const THEME_CYCLE: Theme[] = ["light", "dark", "system"];
|
|
11
|
+
const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
|
|
12
|
+
|
|
13
|
+
const LEFT_MIN = 180;
|
|
14
|
+
const LEFT_MAX = 400;
|
|
15
|
+
const LEFT_DEFAULT = 256;
|
|
16
|
+
const RIGHT_MIN = 240;
|
|
17
|
+
const RIGHT_MAX = 520;
|
|
18
|
+
const RIGHT_DEFAULT = 320;
|
|
19
|
+
|
|
20
|
+
const RISK_DOT: Record<string, string> = {
|
|
21
|
+
low: "bg-green-500",
|
|
22
|
+
medium: "bg-yellow-500",
|
|
23
|
+
high: "bg-red-500",
|
|
24
|
+
critical: "bg-red-600",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function formatTimeAgo(isoDate: string): string {
|
|
28
|
+
const diff = Date.now() - new Date(isoDate).getTime();
|
|
29
|
+
const minutes = Math.floor(diff / 60000);
|
|
30
|
+
if (minutes < 1) return "just now";
|
|
31
|
+
if (minutes < 60) return `${minutes}m`;
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
if (hours < 24) return `${hours}h`;
|
|
34
|
+
const days = Math.floor(hours / 24);
|
|
35
|
+
return `${days}d`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AppShell({
|
|
39
|
+
theme,
|
|
40
|
+
onThemeChange,
|
|
41
|
+
sessions,
|
|
42
|
+
githubUser,
|
|
43
|
+
onSessionSelect,
|
|
44
|
+
onNewAnalysis,
|
|
45
|
+
detailPanel,
|
|
46
|
+
children,
|
|
47
|
+
}: {
|
|
48
|
+
theme: Theme;
|
|
49
|
+
onThemeChange: (t: Theme) => void;
|
|
50
|
+
sessions: SessionRecord[];
|
|
51
|
+
githubUser: GithubUser | null;
|
|
52
|
+
onSessionSelect: (sessionId: string) => void;
|
|
53
|
+
onNewAnalysis: () => void;
|
|
54
|
+
detailPanel?: React.ReactNode;
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
}) {
|
|
57
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
58
|
+
const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
|
|
59
|
+
const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
|
|
60
|
+
const Icon = THEME_ICON[theme];
|
|
61
|
+
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]!;
|
|
62
|
+
|
|
63
|
+
const handleLeftResize = useCallback((delta: number) => {
|
|
64
|
+
setLeftWidth((w) => Math.min(LEFT_MAX, Math.max(LEFT_MIN, w + delta)));
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const handleRightResize = useCallback((delta: number) => {
|
|
68
|
+
setRightWidth((w) => Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, w + delta)));
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex h-screen bg-background overflow-hidden">
|
|
73
|
+
<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">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={onNewAnalysis}
|
|
78
|
+
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
|
79
|
+
>
|
|
80
|
+
<span className="text-sm font-semibold tracking-tight">newpr</span>
|
|
81
|
+
<span className="text-[10px] text-muted-foreground">v0.1.0</span>
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
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"
|
|
87
|
+
title="New analysis"
|
|
88
|
+
>
|
|
89
|
+
<Plus className="h-4 w-4" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
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) => (
|
|
101
|
+
<button
|
|
102
|
+
key={s.id}
|
|
103
|
+
type="button"
|
|
104
|
+
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"
|
|
106
|
+
>
|
|
107
|
+
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${RISK_DOT[s.risk_level] ?? RISK_DOT.medium}`} />
|
|
108
|
+
<div className="flex-1 min-w-0">
|
|
109
|
+
<div className="text-sm truncate group-hover:text-foreground transition-colors">
|
|
110
|
+
{s.pr_title}
|
|
111
|
+
</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" />
|
|
117
|
+
<span>{formatTimeAgo(s.analyzed_at)}</span>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</button>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="border-t px-3 py-3 space-y-2">
|
|
128
|
+
{githubUser && (
|
|
129
|
+
<a
|
|
130
|
+
href={githubUser.html_url}
|
|
131
|
+
target="_blank"
|
|
132
|
+
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"
|
|
134
|
+
>
|
|
135
|
+
<img
|
|
136
|
+
src={githubUser.avatar_url}
|
|
137
|
+
alt={githubUser.login}
|
|
138
|
+
className="h-6 w-6 rounded-full"
|
|
139
|
+
/>
|
|
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>
|
|
146
|
+
</a>
|
|
147
|
+
)}
|
|
148
|
+
<div className="flex items-center justify-between px-1.5">
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
onClick={() => onThemeChange(next)}
|
|
152
|
+
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
153
|
+
title={`Switch to ${next} mode`}
|
|
154
|
+
>
|
|
155
|
+
<Icon className="h-3.5 w-3.5" />
|
|
156
|
+
<span className="capitalize">{theme}</span>
|
|
157
|
+
</button>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
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"
|
|
162
|
+
title="Settings"
|
|
163
|
+
>
|
|
164
|
+
<Settings className="h-3.5 w-3.5" />
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</aside>
|
|
169
|
+
|
|
170
|
+
<ResizeHandle onResize={handleLeftResize} side="right" />
|
|
171
|
+
|
|
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">
|
|
175
|
+
{children}
|
|
176
|
+
</div>
|
|
177
|
+
</main>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{detailPanel && (
|
|
181
|
+
<>
|
|
182
|
+
<ResizeHandle onResize={handleRightResize} side="left" />
|
|
183
|
+
<aside className="shrink-0 border-l bg-background overflow-y-auto" style={{ width: rightWidth }}>
|
|
184
|
+
{detailPanel}
|
|
185
|
+
</aside>
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{settingsOpen && (
|
|
190
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
191
|
+
<div
|
|
192
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
193
|
+
onClick={() => setSettingsOpen(false)}
|
|
194
|
+
onKeyDown={(e) => { if (e.key === "Escape") setSettingsOpen(false); }}
|
|
195
|
+
/>
|
|
196
|
+
<div className="relative z-10 w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-xl border bg-background p-6 shadow-lg">
|
|
197
|
+
<SettingsPanel onClose={() => setSettingsOpen(false)} />
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Layers, FileText, Plus, Pencil, Trash2, ArrowRight, X } from "lucide-react";
|
|
2
|
+
import type { FileGroup, FileChange, FileStatus } from "../../../types/output.ts";
|
|
3
|
+
|
|
4
|
+
export interface DetailTarget {
|
|
5
|
+
kind: "group" | "file";
|
|
6
|
+
group?: FileGroup;
|
|
7
|
+
file?: FileChange;
|
|
8
|
+
files: FileChange[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STATUS_ICON: Record<FileStatus, typeof Plus> = {
|
|
12
|
+
added: Plus,
|
|
13
|
+
modified: Pencil,
|
|
14
|
+
deleted: Trash2,
|
|
15
|
+
renamed: ArrowRight,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const STATUS_COLOR: Record<FileStatus, string> = {
|
|
19
|
+
added: "text-green-500",
|
|
20
|
+
modified: "text-yellow-500",
|
|
21
|
+
deleted: "text-red-500",
|
|
22
|
+
renamed: "text-blue-500",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TYPE_COLORS: Record<string, string> = {
|
|
26
|
+
feature: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
|
27
|
+
refactor: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
|
|
28
|
+
bugfix: "bg-red-500/10 text-red-600 dark:text-red-400",
|
|
29
|
+
chore: "bg-gray-500/10 text-gray-600 dark:text-gray-400",
|
|
30
|
+
docs: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
|
|
31
|
+
test: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
|
32
|
+
config: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function resolveDetail(
|
|
36
|
+
kind: "group" | "file",
|
|
37
|
+
id: string,
|
|
38
|
+
groups: FileGroup[],
|
|
39
|
+
files: FileChange[],
|
|
40
|
+
): DetailTarget | null {
|
|
41
|
+
if (kind === "group") {
|
|
42
|
+
const group = groups.find((g) => g.name === id);
|
|
43
|
+
if (!group) return null;
|
|
44
|
+
const groupFiles = files.filter((f) => group.files.includes(f.path));
|
|
45
|
+
return { kind: "group", group, files: groupFiles };
|
|
46
|
+
}
|
|
47
|
+
const file = files.find((f) => f.path === id);
|
|
48
|
+
if (!file) return null;
|
|
49
|
+
return { kind: "file", file, files: [file] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function DetailPane({ target, onClose }: { target: DetailTarget | null; onClose?: () => void }) {
|
|
53
|
+
if (!target) return null;
|
|
54
|
+
|
|
55
|
+
if (target.kind === "group" && target.group) {
|
|
56
|
+
const g = target.group;
|
|
57
|
+
return (
|
|
58
|
+
<div className="p-4 space-y-4">
|
|
59
|
+
<div className="flex items-start justify-between gap-2">
|
|
60
|
+
<div className="space-y-2 min-w-0">
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
<Layers className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
63
|
+
<h4 className="text-sm font-semibold break-words">{g.name}</h4>
|
|
64
|
+
</div>
|
|
65
|
+
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${TYPE_COLORS[g.type] ?? TYPE_COLORS.chore}`}>
|
|
66
|
+
{g.type}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
{onClose && (
|
|
70
|
+
<button type="button" onClick={onClose} className="shrink-0 p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
|
|
71
|
+
<X className="h-3.5 w-3.5" />
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
<p className="text-sm text-muted-foreground leading-relaxed break-words">{g.description}</p>
|
|
76
|
+
<div className="border-t pt-3">
|
|
77
|
+
<div className="text-xs text-muted-foreground mb-2">{target.files.length} files</div>
|
|
78
|
+
<div className="space-y-2">
|
|
79
|
+
{target.files.map((f) => {
|
|
80
|
+
const Icon = STATUS_ICON[f.status];
|
|
81
|
+
return (
|
|
82
|
+
<div key={f.path} className="space-y-1">
|
|
83
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
84
|
+
<Icon className={`h-3 w-3 shrink-0 ${STATUS_COLOR[f.status]}`} />
|
|
85
|
+
<span className="text-xs font-mono truncate" title={f.path}>{f.path}</span>
|
|
86
|
+
<span className="text-xs text-green-500 shrink-0">+{f.additions}</span>
|
|
87
|
+
<span className="text-xs text-red-500 shrink-0">−{f.deletions}</span>
|
|
88
|
+
</div>
|
|
89
|
+
<p className="text-xs text-muted-foreground pl-5 break-words">{f.summary}</p>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (target.kind === "file" && target.file) {
|
|
100
|
+
const f = target.file;
|
|
101
|
+
const Icon = STATUS_ICON[f.status];
|
|
102
|
+
return (
|
|
103
|
+
<div className="p-4 space-y-4">
|
|
104
|
+
<div className="flex items-start justify-between gap-2">
|
|
105
|
+
<div className="space-y-2 min-w-0">
|
|
106
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
107
|
+
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
108
|
+
<span className="text-sm font-mono font-medium break-all">{f.path}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex items-center gap-3">
|
|
111
|
+
<div className="flex items-center gap-1.5">
|
|
112
|
+
<Icon className={`h-3 w-3 ${STATUS_COLOR[f.status]}`} />
|
|
113
|
+
<span className="text-xs text-muted-foreground">{f.status}</span>
|
|
114
|
+
</div>
|
|
115
|
+
<span className="text-xs text-green-500">+{f.additions}</span>
|
|
116
|
+
<span className="text-xs text-red-500">−{f.deletions}</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
{onClose && (
|
|
120
|
+
<button type="button" onClick={onClose} className="shrink-0 p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
|
|
121
|
+
<X className="h-3.5 w-3.5" />
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
<p className="text-sm text-muted-foreground leading-relaxed break-words">{f.summary}</p>
|
|
126
|
+
{f.groups.length > 0 && (
|
|
127
|
+
<div className="border-t pt-3">
|
|
128
|
+
<div className="text-xs text-muted-foreground mb-2">Groups</div>
|
|
129
|
+
<div className="flex flex-wrap gap-1.5">
|
|
130
|
+
{f.groups.map((g) => (
|
|
131
|
+
<span key={g} className="text-xs bg-muted px-2 py-0.5 rounded-full">{g}</span>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { AlertCircle, RotateCcw, ArrowLeft } from "lucide-react";
|
|
3
|
+
import { Button } from "../../components/ui/button.tsx";
|
|
4
|
+
|
|
5
|
+
function categorizeError(message: string): { title: string; hint: string; retryable: boolean } {
|
|
6
|
+
const lower = message.toLowerCase();
|
|
7
|
+
|
|
8
|
+
if (lower.includes("rate limit") || lower.includes("429")) {
|
|
9
|
+
return {
|
|
10
|
+
title: "Rate limit reached",
|
|
11
|
+
hint: "The API rate limit has been exceeded. Wait a moment before retrying.",
|
|
12
|
+
retryable: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (lower.includes("timeout") || lower.includes("timed out")) {
|
|
16
|
+
return {
|
|
17
|
+
title: "Request timed out",
|
|
18
|
+
hint: "The analysis took too long. This can happen with very large PRs.",
|
|
19
|
+
retryable: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (lower.includes("network") || lower.includes("fetch") || lower.includes("econnrefused")) {
|
|
23
|
+
return {
|
|
24
|
+
title: "Connection failed",
|
|
25
|
+
hint: "Could not reach the server. Check your network connection.",
|
|
26
|
+
retryable: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("token")) {
|
|
30
|
+
return {
|
|
31
|
+
title: "Authentication error",
|
|
32
|
+
hint: "Your GitHub token may be expired or invalid. Run `newpr auth` to reconfigure.",
|
|
33
|
+
retryable: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (lower.includes("404") || lower.includes("not found")) {
|
|
37
|
+
return {
|
|
38
|
+
title: "PR not found",
|
|
39
|
+
hint: "The pull request could not be found. Check the URL and make sure you have access.",
|
|
40
|
+
retryable: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (lower.includes("openrouter") || lower.includes("api key")) {
|
|
44
|
+
return {
|
|
45
|
+
title: "API key error",
|
|
46
|
+
hint: "Your OpenRouter API key may be missing or invalid. Set OPENROUTER_API_KEY in your environment.",
|
|
47
|
+
retryable: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
title: "Analysis failed",
|
|
53
|
+
hint: "Something went wrong during the analysis.",
|
|
54
|
+
retryable: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ErrorScreen({
|
|
59
|
+
error,
|
|
60
|
+
onRetry,
|
|
61
|
+
onBack,
|
|
62
|
+
}: {
|
|
63
|
+
error: string;
|
|
64
|
+
onRetry?: () => void;
|
|
65
|
+
onBack: () => void;
|
|
66
|
+
}) {
|
|
67
|
+
const [retrying, setRetrying] = useState(false);
|
|
68
|
+
const { title, hint, retryable } = categorizeError(error);
|
|
69
|
+
|
|
70
|
+
function handleRetry() {
|
|
71
|
+
if (!onRetry) return;
|
|
72
|
+
setRetrying(true);
|
|
73
|
+
onRetry();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col items-center justify-center py-24">
|
|
78
|
+
<div className="w-full max-w-md flex flex-col items-center gap-6">
|
|
79
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10">
|
|
80
|
+
<AlertCircle className="h-6 w-6 text-red-500" />
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
84
|
+
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
|
85
|
+
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
|
86
|
+
{hint}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="w-full rounded-lg border bg-muted/50 px-4 py-3">
|
|
91
|
+
<p className="text-xs font-mono text-muted-foreground break-all leading-relaxed">
|
|
92
|
+
{error}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="flex items-center gap-3">
|
|
97
|
+
{retryable && onRetry && (
|
|
98
|
+
<Button
|
|
99
|
+
onClick={handleRetry}
|
|
100
|
+
disabled={retrying}
|
|
101
|
+
size="default"
|
|
102
|
+
>
|
|
103
|
+
<RotateCcw className={`mr-2 h-3.5 w-3.5 ${retrying ? "animate-spin" : ""}`} />
|
|
104
|
+
{retrying ? "Retrying..." : "Try again"}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
<Button
|
|
108
|
+
variant="ghost"
|
|
109
|
+
onClick={onBack}
|
|
110
|
+
size="default"
|
|
111
|
+
>
|
|
112
|
+
<ArrowLeft className="mr-2 h-3.5 w-3.5" />
|
|
113
|
+
Back
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ArrowRight } from "lucide-react";
|
|
3
|
+
import { Button } from "../../components/ui/button.tsx";
|
|
4
|
+
|
|
5
|
+
export function InputScreen({ onSubmit }: { onSubmit: (pr: string) => void }) {
|
|
6
|
+
const [value, setValue] = useState("");
|
|
7
|
+
|
|
8
|
+
function handleSubmit(e: React.FormEvent) {
|
|
9
|
+
e.preventDefault();
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
if (trimmed) onSubmit(trimmed);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-col items-center gap-12 py-24">
|
|
16
|
+
<div className="flex flex-col items-center gap-3">
|
|
17
|
+
<h1 className="text-4xl font-bold tracking-tight">newpr</h1>
|
|
18
|
+
<p className="text-muted-foreground text-center max-w-md">
|
|
19
|
+
AI-powered PR review tool. Paste a PR URL to get a comprehensive analysis.
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<form onSubmit={handleSubmit} className="w-full max-w-xl">
|
|
24
|
+
<div className="flex gap-2">
|
|
25
|
+
<input
|
|
26
|
+
type="text"
|
|
27
|
+
value={value}
|
|
28
|
+
onChange={(e) => setValue(e.target.value)}
|
|
29
|
+
placeholder="https://github.com/owner/repo/pull/123"
|
|
30
|
+
className="flex-1 h-11 rounded-lg border bg-background px-4 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
31
|
+
autoFocus
|
|
32
|
+
/>
|
|
33
|
+
<Button type="submit" size="lg" disabled={!value.trim()}>
|
|
34
|
+
Analyze
|
|
35
|
+
<ArrowRight className="ml-2 h-4 w-4" />
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
</form>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|