newpr 0.1.1 → 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.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/cli/args.ts +6 -1
- package/src/cli/index.ts +2 -2
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/cartoon.ts +128 -0
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +51 -0
- package/src/web/client/App.tsx +32 -2
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +135 -110
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/hooks/useFeatures.ts +18 -0
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +153 -0
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +752 -2
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +42 -2
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/src/llm/prompts.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface FileSummaryInput {
|
|
|
17
17
|
export interface PromptContext {
|
|
18
18
|
commits?: PrCommit[];
|
|
19
19
|
language?: string;
|
|
20
|
+
prBody?: string;
|
|
21
|
+
discussion?: Array<{ author: string; body: string }>;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function langDirective(lang?: string): string {
|
|
@@ -24,6 +26,21 @@ function langDirective(lang?: string): string {
|
|
|
24
26
|
return `\nCRITICAL LANGUAGE RULE: ALL text values in your response MUST be written in ${lang}. This includes every summary, description, name, purpose, scope, and impact field. JSON keys stay in English, but ALL string values MUST be in ${lang}. Do NOT use English for any descriptive text.`;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function formatDiscussion(ctx?: PromptContext): string {
|
|
30
|
+
const parts: string[] = [];
|
|
31
|
+
if (ctx?.prBody?.trim()) {
|
|
32
|
+
parts.push(`PR Description:\n${ctx.prBody.trim()}`);
|
|
33
|
+
}
|
|
34
|
+
if (ctx?.discussion && ctx.discussion.length > 0) {
|
|
35
|
+
const comments = ctx.discussion
|
|
36
|
+
.map((c) => `@${c.author}: ${c.body.length > 500 ? `${c.body.slice(0, 500)}…` : c.body}`)
|
|
37
|
+
.join("\n\n");
|
|
38
|
+
parts.push(`Discussion (${ctx.discussion.length} comments):\n${comments}`);
|
|
39
|
+
}
|
|
40
|
+
if (parts.length === 0) return "";
|
|
41
|
+
return `\n\n--- PR DISCUSSION ---\n${parts.join("\n\n")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
function formatCommitHistory(commits: PrCommit[]): string {
|
|
28
45
|
if (commits.length === 0) return "";
|
|
29
46
|
const lines = commits.map((c) => {
|
|
@@ -44,13 +61,15 @@ export function buildFileSummaryPrompt(chunks: DiffChunk[], ctx?: PromptContext)
|
|
|
44
61
|
|
|
45
62
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
46
63
|
|
|
64
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
65
|
+
|
|
47
66
|
return {
|
|
48
67
|
system: `You are an expert code reviewer. Analyze the given diff and provide a 1-line summary for each changed file.
|
|
49
|
-
Use the commit history to understand the intent behind each change — why the change was made, not just what changed.
|
|
68
|
+
Use the commit history and PR discussion to understand the intent behind each change — why the change was made, not just what changed.
|
|
50
69
|
Respond ONLY with a JSON array. Each element: {"path": "file/path", "summary": "one line description of what changed"}.
|
|
51
70
|
The "path" value must be the exact file path. The "summary" value is a human-readable description.
|
|
52
71
|
No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
53
|
-
user: `${fileList}${commitCtx}`,
|
|
72
|
+
user: `${fileList}${commitCtx}${discussionCtx}`,
|
|
54
73
|
};
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -61,15 +80,17 @@ export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: Pro
|
|
|
61
80
|
|
|
62
81
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
63
82
|
|
|
83
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
84
|
+
|
|
64
85
|
return {
|
|
65
86
|
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
|
|
66
87
|
Each group should have a descriptive name, a type (one of: feature, refactor, bugfix, chore, docs, test, config), a description, and a list of file paths.
|
|
67
88
|
A file MAY appear in multiple groups if it serves multiple purposes (e.g., index.ts re-exporting for both a feature and a refactor).
|
|
68
|
-
Use the commit history to understand which changes belong together logically.
|
|
89
|
+
Use the commit history and PR discussion to understand which changes belong together logically.
|
|
69
90
|
Respond ONLY with a JSON array. Each element: {"name": "group name", "type": "feature|refactor|bugfix|chore|docs|test|config", "description": "what this group of changes does", "files": ["path1", "path2"]}.
|
|
70
91
|
The "name" and "description" values are human-readable text. The "type" value must be one of the English keywords listed above. File paths stay as-is.
|
|
71
92
|
Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
72
|
-
user: `Changed files:\n${fileList}${commitCtx}`,
|
|
93
|
+
user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
73
94
|
};
|
|
74
95
|
}
|
|
75
96
|
|
|
@@ -86,13 +107,15 @@ export function buildOverallSummaryPrompt(
|
|
|
86
107
|
const fileList = fileSummaries.map((f) => `- ${f.path}: ${f.summary}`).join("\n");
|
|
87
108
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
88
109
|
|
|
110
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
111
|
+
|
|
89
112
|
return {
|
|
90
113
|
system: `You are an expert code reviewer. Provide an overall summary of this Pull Request.
|
|
91
|
-
Use the commit history to understand the development progression and intent.
|
|
114
|
+
Use the commit history and PR discussion to understand the development progression and intent. The PR description and reviewer comments provide essential context about why changes were made.
|
|
92
115
|
Respond ONLY with a JSON object: {"purpose": "why this PR exists (1-2 sentences)", "scope": "what areas of code are affected", "impact": "what is the impact of these changes", "risk_level": "low|medium|high"}.
|
|
93
116
|
The "purpose", "scope", and "impact" values are human-readable text. The "risk_level" must be one of: low, medium, high (in English).
|
|
94
117
|
No markdown, no explanation, just the JSON object.${langDirective(ctx?.language)}`,
|
|
95
|
-
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}`,
|
|
118
|
+
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
96
119
|
};
|
|
97
120
|
}
|
|
98
121
|
|
|
@@ -109,17 +132,19 @@ export function buildNarrativePrompt(
|
|
|
109
132
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
110
133
|
const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
|
|
111
134
|
|
|
135
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
136
|
+
|
|
112
137
|
return {
|
|
113
138
|
system: `You are an expert code reviewer writing a review walkthrough for other developers.
|
|
114
139
|
Write a clear, concise narrative that tells the "story" of this PR — what changes were made and in what logical order.
|
|
115
|
-
Use the commit history to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step.
|
|
140
|
+
Use the commit history and PR discussion to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step. The PR description often explains the author's motivation and approach.
|
|
116
141
|
Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
|
|
117
142
|
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and [[group:...]]/[[file:...]] tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}
|
|
118
143
|
|
|
119
144
|
IMPORTANT: When referencing a change group, wrap it as [[group:Group Name]]. When referencing a specific file, wrap it as [[file:path/to/file.ts]].
|
|
120
145
|
Use the EXACT group names and file paths provided. Every group MUST be referenced at least once. Reference key files where relevant.
|
|
121
146
|
Example: "The [[group:Auth Flow]] group introduces session management via [[file:src/auth/session.ts]] and [[file:src/auth/token.ts]]."`,
|
|
122
|
-
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}`,
|
|
147
|
+
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}`,
|
|
123
148
|
};
|
|
124
149
|
}
|
|
125
150
|
|
package/src/tui/Shell.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import type { SessionRecord } from "../history/types.ts";
|
|
|
7
7
|
import type { AgentToolName } from "../workspace/types.ts";
|
|
8
8
|
import { parsePrInput } from "../github/parse-pr.ts";
|
|
9
9
|
import { analyzePr } from "../analyzer/pipeline.ts";
|
|
10
|
-
import { saveSession, listSessions, loadSession } from "../history/store.ts";
|
|
10
|
+
import { saveSession, savePatchesSidecar, listSessions, loadSession } from "../history/store.ts";
|
|
11
11
|
import { detectAgents } from "../workspace/agent.ts";
|
|
12
12
|
import { App } from "./App.tsx";
|
|
13
13
|
import { InputBar } from "./InputBar.tsx";
|
|
@@ -94,10 +94,12 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
|
|
|
94
94
|
setState({ phase: "loading", steps: [], startTime });
|
|
95
95
|
setElapsed(0);
|
|
96
96
|
|
|
97
|
+
let capturedPatches: Record<string, string> = {};
|
|
97
98
|
const result = await analyzePr({
|
|
98
99
|
pr,
|
|
99
100
|
token,
|
|
100
101
|
config: liveConfig,
|
|
102
|
+
onFilePatches: (patches) => { capturedPatches = patches; },
|
|
101
103
|
onProgress: (event: ProgressEvent) => {
|
|
102
104
|
const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
|
|
103
105
|
const prev = eventsRef.current;
|
|
@@ -117,7 +119,10 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
|
|
|
117
119
|
},
|
|
118
120
|
});
|
|
119
121
|
|
|
120
|
-
await saveSession(result);
|
|
122
|
+
const record = await saveSession(result);
|
|
123
|
+
if (Object.keys(capturedPatches).length > 0) {
|
|
124
|
+
await savePatchesSidecar(record.id, capturedPatches).catch(() => {});
|
|
125
|
+
}
|
|
121
126
|
const updated = await listSessions(10);
|
|
122
127
|
setSessions(updated);
|
|
123
128
|
|
package/src/types/github.ts
CHANGED
|
@@ -12,9 +12,20 @@ export interface PrCommit {
|
|
|
12
12
|
files: string[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface PrComment {
|
|
16
|
+
id: number;
|
|
17
|
+
author: string;
|
|
18
|
+
author_avatar?: string;
|
|
19
|
+
body: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
updated_at: string;
|
|
22
|
+
html_url: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export interface GithubPrData {
|
|
16
26
|
number: number;
|
|
17
27
|
title: string;
|
|
28
|
+
body: string;
|
|
18
29
|
url: string;
|
|
19
30
|
base_branch: string;
|
|
20
31
|
head_branch: string;
|
package/src/types/output.ts
CHANGED
|
@@ -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;
|
|
@@ -50,10 +51,60 @@ export interface FileChange {
|
|
|
50
51
|
groups: string[];
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
export interface CartoonImage {
|
|
55
|
+
imageBase64: string;
|
|
56
|
+
mimeType: string;
|
|
57
|
+
generatedAt: string;
|
|
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
|
+
|
|
53
103
|
export interface NewprOutput {
|
|
54
104
|
meta: PrMeta;
|
|
55
105
|
summary: PrSummary;
|
|
56
106
|
groups: FileGroup[];
|
|
57
107
|
files: FileChange[];
|
|
58
108
|
narrative: string;
|
|
109
|
+
cartoon?: CartoonImage;
|
|
59
110
|
}
|
package/src/web/client/App.tsx
CHANGED
|
@@ -3,12 +3,15 @@ import { useAnalysis } from "./hooks/useAnalysis.ts";
|
|
|
3
3
|
import { useTheme } from "./hooks/useTheme.ts";
|
|
4
4
|
import { useSessions } from "./hooks/useSessions.ts";
|
|
5
5
|
import { useGithubUser } from "./hooks/useGithubUser.ts";
|
|
6
|
+
import { useFeatures } from "./hooks/useFeatures.ts";
|
|
6
7
|
import { AppShell } from "./components/AppShell.tsx";
|
|
7
8
|
import { InputScreen } from "./components/InputScreen.tsx";
|
|
8
9
|
import { LoadingTimeline } from "./components/LoadingTimeline.tsx";
|
|
9
10
|
import { ResultsScreen } from "./components/ResultsScreen.tsx";
|
|
10
11
|
import { ErrorScreen } from "./components/ErrorScreen.tsx";
|
|
11
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";
|
|
12
15
|
|
|
13
16
|
function getUrlParam(key: string): string | null {
|
|
14
17
|
return new URLSearchParams(window.location.search).get(key);
|
|
@@ -31,6 +34,7 @@ export function App() {
|
|
|
31
34
|
const themeCtx = useTheme();
|
|
32
35
|
const { sessions, refresh: refreshSessions } = useSessions();
|
|
33
36
|
const githubUser = useGithubUser();
|
|
37
|
+
const features = useFeatures();
|
|
34
38
|
const initialLoadDone = useRef(false);
|
|
35
39
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
36
40
|
|
|
@@ -78,11 +82,28 @@ export function App() {
|
|
|
78
82
|
analysis.reset();
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
const diffSessionId = analysis.historyId ?? analysis.sessionId;
|
|
86
|
+
const prUrl = analysis.result?.meta.pr_url;
|
|
81
87
|
const detailPanel = detailTarget ? (
|
|
82
|
-
<DetailPane target={detailTarget} onClose={() => setActiveId(null)} />
|
|
88
|
+
<DetailPane target={detailTarget} sessionId={diffSessionId} prUrl={prUrl} onClose={() => setActiveId(null)} />
|
|
83
89
|
) : null;
|
|
84
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
|
+
|
|
85
105
|
return (
|
|
106
|
+
<ChatProvider state={chatState} anchorItems={anchorItems}>
|
|
86
107
|
<AppShell
|
|
87
108
|
theme={themeCtx.theme}
|
|
88
109
|
onThemeChange={themeCtx.setTheme}
|
|
@@ -91,9 +112,15 @@ export function App() {
|
|
|
91
112
|
onSessionSelect={handleSessionSelect}
|
|
92
113
|
onNewAnalysis={handleNewAnalysis}
|
|
93
114
|
detailPanel={detailPanel}
|
|
115
|
+
bottomBar={analysis.phase === "done" ? <ChatInput /> : undefined}
|
|
116
|
+
activeSessionId={diffSessionId}
|
|
94
117
|
>
|
|
95
118
|
{analysis.phase === "idle" && (
|
|
96
|
-
<InputScreen
|
|
119
|
+
<InputScreen
|
|
120
|
+
onSubmit={(pr) => analysis.start(pr)}
|
|
121
|
+
sessions={sessions}
|
|
122
|
+
onSessionSelect={handleSessionSelect}
|
|
123
|
+
/>
|
|
97
124
|
)}
|
|
98
125
|
{analysis.phase === "loading" && (
|
|
99
126
|
<LoadingTimeline
|
|
@@ -107,6 +134,8 @@ export function App() {
|
|
|
107
134
|
onBack={handleNewAnalysis}
|
|
108
135
|
activeId={activeId}
|
|
109
136
|
onAnchorClick={handleAnchorClick}
|
|
137
|
+
cartoonEnabled={features.cartoon}
|
|
138
|
+
sessionId={diffSessionId}
|
|
110
139
|
/>
|
|
111
140
|
)}
|
|
112
141
|
{analysis.phase === "error" && (
|
|
@@ -117,5 +146,6 @@ export function App() {
|
|
|
117
146
|
/>
|
|
118
147
|
)}
|
|
119
148
|
</AppShell>
|
|
149
|
+
</ChatProvider>
|
|
120
150
|
);
|
|
121
151
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
2
|
-
import { Sun, Moon, Monitor, Plus,
|
|
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 =
|
|
17
|
-
const RIGHT_MAX =
|
|
18
|
-
const RIGHT_DEFAULT =
|
|
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) =>
|
|
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-
|
|
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-
|
|
114
|
+
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
|
79
115
|
>
|
|
80
|
-
<span className="text-
|
|
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-
|
|
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-
|
|
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="
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
113
|
-
<span className="truncate">{s.repo.split("/").pop()}</span>
|
|
114
|
-
<span>#{s.pr_number}</span>
|
|
115
|
-
<span className="text-muted-foreground/
|
|
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
|
-
|
|
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-
|
|
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
|
|
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-
|
|
178
|
+
className="h-5 w-5 rounded-full"
|
|
139
179
|
/>
|
|
140
|
-
<
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
173
|
-
<main className="flex-1 overflow-y-auto">
|
|
174
|
-
<div className="mx-auto max-w-
|
|
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 && (
|