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.
Files changed (37) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/cli/args.ts +6 -1
  4. package/src/cli/index.ts +2 -2
  5. package/src/github/fetch-pr.ts +43 -1
  6. package/src/history/store.ts +106 -1
  7. package/src/llm/cartoon.ts +128 -0
  8. package/src/llm/client.ts +197 -0
  9. package/src/llm/prompts.ts +33 -8
  10. package/src/tui/Shell.tsx +7 -2
  11. package/src/types/github.ts +11 -0
  12. package/src/types/output.ts +51 -0
  13. package/src/web/client/App.tsx +32 -2
  14. package/src/web/client/components/AppShell.tsx +94 -47
  15. package/src/web/client/components/ChatSection.tsx +427 -0
  16. package/src/web/client/components/DetailPane.tsx +163 -75
  17. package/src/web/client/components/DiffViewer.tsx +679 -0
  18. package/src/web/client/components/InputScreen.tsx +110 -26
  19. package/src/web/client/components/Markdown.tsx +169 -43
  20. package/src/web/client/components/ResultsScreen.tsx +135 -110
  21. package/src/web/client/components/TipTapEditor.tsx +405 -0
  22. package/src/web/client/hooks/useAnalysis.ts +8 -1
  23. package/src/web/client/hooks/useFeatures.ts +18 -0
  24. package/src/web/client/lib/shiki.ts +63 -0
  25. package/src/web/client/panels/CartoonPanel.tsx +153 -0
  26. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  27. package/src/web/client/panels/FilesPanel.tsx +435 -54
  28. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  29. package/src/web/client/panels/StoryPanel.tsx +42 -22
  30. package/src/web/components/ui/tabs.tsx +3 -3
  31. package/src/web/server/routes.ts +752 -2
  32. package/src/web/server/session-manager.ts +11 -2
  33. package/src/web/server.ts +42 -2
  34. package/src/web/styles/built.css +1 -1
  35. package/src/web/styles/globals.css +117 -1
  36. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  37. package/src/web/client/panels/SummaryPanel.tsx +0 -20
@@ -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
 
@@ -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;
@@ -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
  }
@@ -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 onSubmit={(pr) => analysis.start(pr)} />
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, 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 && (