newpr 0.4.0 → 0.5.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.
@@ -1,9 +1,10 @@
1
1
  import { useState, useEffect, useMemo, useRef, useCallback, type ReactNode } from "react";
2
2
  import { type Highlighter, type ThemedToken } from "shiki";
3
- import { MessageSquare, Trash2, ExternalLink, CornerDownLeft, Pencil, Check, X } from "lucide-react";
3
+ import { MessageSquare, Trash2, ExternalLink, CornerDownLeft, Pencil, Check, X, Sparkles, Loader2 } from "lucide-react";
4
4
  import { ensureHighlighter, getHighlighterSync, detectShikiLang, type ShikiLang } from "../lib/shiki.ts";
5
5
  import type { DiffComment } from "../../../types/output.ts";
6
6
  import { TipTapEditor } from "./TipTapEditor.tsx";
7
+ import { Markdown } from "./Markdown.tsx";
7
8
 
8
9
  interface DiffLine {
9
10
  type: "header" | "hunk" | "added" | "removed" | "context" | "binary";
@@ -375,6 +376,135 @@ function CommentForm({
375
376
  );
376
377
  }
377
378
 
379
+ function AskAiPanel({
380
+ sessionId,
381
+ filePath,
382
+ startLine,
383
+ endLine,
384
+ codeSnippet,
385
+ onClose,
386
+ }: {
387
+ sessionId: string;
388
+ filePath: string;
389
+ startLine: number;
390
+ endLine: number;
391
+ codeSnippet: string;
392
+ onClose: () => void;
393
+ }) {
394
+ const [question, setQuestion] = useState("");
395
+ const [response, setResponse] = useState("");
396
+ const [loading, setLoading] = useState(false);
397
+ const [autoStarted, setAutoStarted] = useState(false);
398
+
399
+ const ask = useCallback(async (customQuestion?: string) => {
400
+ setLoading(true);
401
+ setResponse("");
402
+ const q = customQuestion ?? question.trim();
403
+ const prompt = q
404
+ ? `Regarding this code in ${filePath} (lines ${startLine}-${endLine}):\n\`\`\`\n${codeSnippet}\n\`\`\`\n\nQuestion: ${q}`
405
+ : `Analyze this code in ${filePath} (lines ${startLine}-${endLine}). Explain what it does, identify any issues (bugs, performance, security, style), and suggest improvements:\n\`\`\`\n${codeSnippet}\n\`\`\``;
406
+
407
+ try {
408
+ const res = await fetch(`/api/sessions/${sessionId}/ask-inline`, {
409
+ method: "POST",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify({ message: prompt }),
412
+ });
413
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
414
+
415
+ const reader = res.body!.getReader();
416
+ const decoder = new TextDecoder();
417
+ let buffer = "";
418
+ let text = "";
419
+ let pendingEvent = "";
420
+
421
+ while (true) {
422
+ const { done, value } = await reader.read();
423
+ if (done) break;
424
+ buffer += decoder.decode(value, { stream: true });
425
+ const lines = buffer.split("\n");
426
+ buffer = lines.pop() ?? "";
427
+ for (const line of lines) {
428
+ const trimmed = line.trim();
429
+ if (!trimmed) { pendingEvent = ""; continue; }
430
+ if (trimmed.startsWith("event: ")) { pendingEvent = trimmed.slice(7); continue; }
431
+ if (!trimmed.startsWith("data: ")) continue;
432
+ try {
433
+ const data = JSON.parse(trimmed.slice(6));
434
+ if (pendingEvent === "text") {
435
+ text += data.content ?? "";
436
+ setResponse(text);
437
+ }
438
+ } catch {}
439
+ pendingEvent = "";
440
+ }
441
+ }
442
+ } catch (err) {
443
+ setResponse(`Error: ${err instanceof Error ? err.message : String(err)}`);
444
+ } finally {
445
+ setLoading(false);
446
+ }
447
+ }, [sessionId, filePath, startLine, endLine, codeSnippet, question]);
448
+
449
+ useEffect(() => {
450
+ if (!autoStarted) {
451
+ setAutoStarted(true);
452
+ ask();
453
+ }
454
+ }, [autoStarted, ask]);
455
+
456
+ return (
457
+ <div className="px-3 py-2.5 font-sans">
458
+ <div className="space-y-2.5">
459
+ <div className="flex items-center justify-between">
460
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60">
461
+ <Sparkles className="h-3 w-3" />
462
+ <span>AI Analysis</span>
463
+ <span className="text-muted-foreground/30">L{startLine}{endLine !== startLine ? `-L${endLine}` : ""}</span>
464
+ </div>
465
+ <button type="button" onClick={onClose} className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-muted-foreground/60 transition-colors">
466
+ <X className="h-3 w-3" />
467
+ </button>
468
+ </div>
469
+
470
+ {loading && !response && (
471
+ <div className="flex items-center gap-1.5 py-2">
472
+ <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/40" />
473
+ <span className="text-[11px] text-muted-foreground/40">Analyzing...</span>
474
+ </div>
475
+ )}
476
+
477
+ {response && (
478
+ <div className="text-xs leading-relaxed">
479
+ <Markdown>{response}</Markdown>
480
+ </div>
481
+ )}
482
+
483
+ {!loading && (
484
+ <div className="flex items-center gap-1.5">
485
+ <input
486
+ type="text"
487
+ value={question}
488
+ onChange={(e) => setQuestion(e.target.value)}
489
+ onKeyDown={(e) => { if (e.key === "Enter" && question.trim()) ask(); }}
490
+ placeholder="Ask a follow-up..."
491
+ className="flex-1 h-7 rounded-md border bg-background px-2.5 text-[11px] placeholder:text-muted-foreground/30 focus:outline-none focus:border-foreground/20"
492
+ />
493
+ <button
494
+ type="button"
495
+ onClick={() => ask()}
496
+ disabled={loading}
497
+ className="h-7 px-2.5 rounded-md bg-foreground text-background text-[11px] font-medium disabled:opacity-30 hover:opacity-80 transition-opacity"
498
+ >
499
+ Ask
500
+ </button>
501
+ </div>
502
+ )}
503
+ </div>
504
+ </div>
505
+ );
506
+ }
507
+
378
508
  function InlineComments({
379
509
  comments,
380
510
  currentUser,
@@ -383,6 +513,11 @@ function InlineComments({
383
513
  formTarget,
384
514
  onSubmit,
385
515
  onCancel,
516
+ sessionId,
517
+ filePath,
518
+ startLine,
519
+ endLine,
520
+ codeSnippet,
386
521
  }: {
387
522
  comments: DiffComment[];
388
523
  currentUser: { login: string; avatar_url: string } | null;
@@ -391,16 +526,48 @@ function InlineComments({
391
526
  formTarget: boolean;
392
527
  onSubmit: (body: string) => Promise<void>;
393
528
  onCancel: () => void;
529
+ sessionId?: string | null;
530
+ filePath: string;
531
+ startLine?: number;
532
+ endLine?: number;
533
+ codeSnippet?: string;
394
534
  }) {
535
+ const [showAi, setShowAi] = useState(false);
395
536
  const hasComments = comments.length > 0;
396
- if (!hasComments && !formTarget) return null;
537
+ if (!hasComments && !formTarget && !showAi) return null;
397
538
 
398
539
  return (
399
540
  <div className="border-y border-border/30 bg-card/80 font-sans divide-y divide-border/20">
400
541
  {comments.map((c) => (
401
542
  <CommentCard key={c.id} comment={c} currentLogin={currentUser?.login ?? null} onEdit={onEdit} onDelete={onDelete} />
402
543
  ))}
403
- {formTarget && <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />}
544
+ {formTarget && (
545
+ <div>
546
+ <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />
547
+ {sessionId && startLine && endLine && codeSnippet && !showAi && (
548
+ <div className="px-3 pb-2">
549
+ <button
550
+ type="button"
551
+ onClick={() => setShowAi(true)}
552
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
553
+ >
554
+ <Sparkles className="h-3 w-3" />
555
+ Ask AI about this code
556
+ </button>
557
+ </div>
558
+ )}
559
+ </div>
560
+ )}
561
+ {showAi && sessionId && startLine && endLine && codeSnippet && (
562
+ <AskAiPanel
563
+ sessionId={sessionId}
564
+ filePath={filePath}
565
+ startLine={startLine}
566
+ endLine={endLine}
567
+ codeSnippet={codeSnippet}
568
+ onClose={() => setShowAi(false)}
569
+ />
570
+ )}
404
571
  </div>
405
572
  );
406
573
  }
@@ -595,6 +762,18 @@ export function DiffViewer({
595
762
  return () => document.removeEventListener("mouseup", handleUp);
596
763
  }, []);
597
764
 
765
+ const codeSnippetForRange = useMemo(() => {
766
+ if (!formRange) return "";
767
+ return lines
768
+ .filter((l) => {
769
+ const lk = lineKey(l);
770
+ if (!lk || lk.side !== formRange.side) return false;
771
+ return lk.num >= formRange.startLine && lk.num <= formRange.endLine;
772
+ })
773
+ .map((l) => l.content)
774
+ .join("\n");
775
+ }, [formRange, lines]);
776
+
598
777
  const commentCount = comments.length;
599
778
 
600
779
  return (
@@ -691,6 +870,11 @@ export function DiffViewer({
691
870
  formTarget={!!isFormAnchor}
692
871
  onSubmit={handleAddComment}
693
872
  onCancel={() => setFormRange(null)}
873
+ sessionId={sessionId}
874
+ filePath={filePath}
875
+ startLine={formRange?.startLine}
876
+ endLine={formRange?.endLine}
877
+ codeSnippet={codeSnippetForRange}
694
878
  />
695
879
  </div>
696
880
  )}
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect, useRef } from "react";
2
- import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown } from "lucide-react";
2
+ import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown, AlertTriangle, RefreshCw, Presentation } from "lucide-react";
3
3
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
4
4
  import type { NewprOutput } from "../../../types/output.ts";
5
5
  import { GroupsPanel } from "../panels/GroupsPanel.tsx";
@@ -7,9 +7,11 @@ import { FilesPanel } from "../panels/FilesPanel.tsx";
7
7
  import { StoryPanel } from "../panels/StoryPanel.tsx";
8
8
  import { DiscussionPanel } from "../panels/DiscussionPanel.tsx";
9
9
  import { CartoonPanel } from "../panels/CartoonPanel.tsx";
10
+ import { SlidesPanel } from "../panels/SlidesPanel.tsx";
10
11
  import { ReviewModal } from "./ReviewModal.tsx";
12
+ import { useOutdatedCheck } from "../hooks/useOutdatedCheck.ts";
11
13
 
12
- const VALID_TABS = ["story", "discussion", "groups", "files", "cartoon"] as const;
14
+ const VALID_TABS = ["story", "discussion", "groups", "files", "slides", "cartoon"] as const;
13
15
  type TabValue = typeof VALID_TABS[number];
14
16
 
15
17
  function getInitialTab(): TabValue {
@@ -46,6 +48,7 @@ export function ResultsScreen({
46
48
  cartoonEnabled,
47
49
  sessionId,
48
50
  onTabChange,
51
+ onReanalyze,
49
52
  }: {
50
53
  data: NewprOutput;
51
54
  onBack: () => void;
@@ -54,10 +57,12 @@ export function ResultsScreen({
54
57
  cartoonEnabled?: boolean;
55
58
  sessionId?: string | null;
56
59
  onTabChange?: (tab: string) => void;
60
+ onReanalyze?: (prUrl: string) => void;
57
61
  }) {
58
62
  const { meta, summary } = data;
59
63
  const [tab, setTab] = useState<TabValue>(getInitialTab);
60
64
  const [reviewOpen, setReviewOpen] = useState(false);
65
+ const outdated = useOutdatedCheck(sessionId);
61
66
 
62
67
  const stickyRef = useRef<HTMLDivElement>(null);
63
68
  const collapsibleRef = useRef<HTMLDivElement>(null);
@@ -173,6 +178,24 @@ export function ResultsScreen({
173
178
  </div>
174
179
  </div>
175
180
  </div>
181
+ {outdated?.outdated && (
182
+ <div className="flex items-center gap-2 mt-3 px-3 py-2 rounded-lg border border-yellow-500/20 bg-yellow-500/5">
183
+ <AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
184
+ <span className="text-[11px] text-yellow-700 dark:text-yellow-300 flex-1">
185
+ This PR has been updated since this analysis was created.
186
+ </span>
187
+ {onReanalyze && (
188
+ <button
189
+ type="button"
190
+ onClick={() => onReanalyze(meta.pr_url)}
191
+ className="flex items-center gap-1 text-[11px] font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 shrink-0 transition-colors"
192
+ >
193
+ <RefreshCw className="h-3 w-3" />
194
+ Re-analyze
195
+ </button>
196
+ )}
197
+ </div>
198
+ )}
176
199
  </div>
177
200
 
178
201
  <div ref={compactRef} className="overflow-hidden transition-[max-height,opacity] duration-200" style={{ maxHeight: 0, opacity: 0 }}>
@@ -211,6 +234,10 @@ export function ResultsScreen({
211
234
  <FolderTree className="h-3 w-3 shrink-0" />
212
235
  Files
213
236
  </TabsTrigger>
237
+ <TabsTrigger value="slides">
238
+ <Presentation className="h-3 w-3 shrink-0" />
239
+ Slides
240
+ </TabsTrigger>
214
241
  {cartoonEnabled && (
215
242
  <TabsTrigger value="cartoon">
216
243
  <Sparkles className="h-3 w-3 shrink-0" />
@@ -237,6 +264,9 @@ export function ResultsScreen({
237
264
  onFileSelect={(path: string) => onAnchorClick("file", path)}
238
265
  />
239
266
  </TabsContent>
267
+ <TabsContent value="slides">
268
+ <SlidesPanel data={data} sessionId={sessionId} />
269
+ </TabsContent>
240
270
  {cartoonEnabled && (
241
271
  <TabsContent value="cartoon">
242
272
  <CartoonPanel data={data} sessionId={sessionId} />
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
3
  import type { NewprOutput } from "../../../types/output.ts";
4
+ import { sendNotification } from "../lib/notify.ts";
4
5
 
5
6
  export type BgStatus = "running" | "done" | "error";
6
7
 
@@ -89,19 +90,22 @@ export function useBackgroundAnalyses() {
89
90
  try {
90
91
  const res = await fetch(`/api/analysis/${sessionId}`);
91
92
  const data = (await res.json()) as { result?: NewprOutput; historyId?: string };
92
- setAnalyses((prev) =>
93
- prev.map((a) =>
94
- a.sessionId === sessionId
95
- ? { ...a, status: "done", result: data.result, historyId: data.historyId }
96
- : a,
97
- ),
98
- );
93
+ setAnalyses((prev) => {
94
+ const a = prev.find((x) => x.sessionId === sessionId);
95
+ sendNotification("Analysis complete", a?.prTitle ?? prInput);
96
+ return prev.map((x) =>
97
+ x.sessionId === sessionId
98
+ ? { ...x, status: "done" as const, result: data.result, historyId: data.historyId }
99
+ : x,
100
+ );
101
+ });
99
102
  } catch {
100
- setAnalyses((prev) =>
101
- prev.map((a) =>
102
- a.sessionId === sessionId ? { ...a, status: "done" } : a,
103
- ),
104
- );
103
+ setAnalyses((prev) => {
104
+ sendNotification("Analysis complete", prInput);
105
+ return prev.map((a) =>
106
+ a.sessionId === sessionId ? { ...a, status: "done" as const } : a,
107
+ );
108
+ });
105
109
  }
106
110
  });
107
111
 
@@ -110,6 +114,7 @@ export function useBackgroundAnalyses() {
110
114
  eventSourcesRef.current.delete(sessionId);
111
115
  let msg = "Analysis failed";
112
116
  try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
117
+ sendNotification("Analysis failed", msg);
113
118
  setAnalyses((prev) =>
114
119
  prev.map((a) =>
115
120
  a.sessionId === sessionId ? { ...a, status: "error", error: msg } : a,
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useCallback, useSyncExternalStore } from "react";
2
+ import { sendNotification } from "../lib/notify.ts";
2
3
  import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
3
4
 
4
5
  interface ChatSessionState {
@@ -24,6 +25,12 @@ class ChatStore {
24
25
  return s;
25
26
  }
26
27
 
28
+ private update(sessionId: string, patch: Partial<ChatSessionState>) {
29
+ const s = this.getOrCreate(sessionId);
30
+ this.sessions.set(sessionId, { ...s, ...patch });
31
+ this.notify();
32
+ }
33
+
27
34
  private notify() {
28
35
  for (const l of this.listeners) l();
29
36
  }
@@ -55,12 +62,10 @@ class ChatStore {
55
62
  try {
56
63
  const res = await fetch(`/api/sessions/${sessionId}/chat`);
57
64
  const data = await res.json() as ChatMessage[];
58
- s.messages = data;
59
- s.loaded = true;
65
+ this.update(sessionId, { messages: data, loaded: true });
60
66
  } catch {
61
- s.loaded = true;
67
+ this.update(sessionId, { loaded: true });
62
68
  }
63
- this.notify();
64
69
  }
65
70
 
66
71
  async sendMessage(sessionId: string, text: string): Promise<void> {
@@ -68,10 +73,7 @@ class ChatStore {
68
73
  if (s.loading) return;
69
74
 
70
75
  const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
71
- s.messages = [...s.messages, userMsg];
72
- s.loading = true;
73
- s.streaming = { segments: [] };
74
- this.notify();
76
+ this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
75
77
 
76
78
  const controller = new AbortController();
77
79
  this.abortControllers.set(sessionId, controller);
@@ -122,23 +124,20 @@ class ChatStore {
122
124
  } else {
123
125
  orderedSegments.push({ type: "text", content: data.content ?? "" });
124
126
  }
125
- s.streaming = { segments: [...orderedSegments] };
126
- this.notify();
127
+ this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
127
128
  break;
128
129
  }
129
130
  case "tool_call": {
130
131
  const tc: ChatToolCall = { id: data.id, name: data.name, arguments: data.arguments ?? {} };
131
132
  allToolCalls.push(tc);
132
133
  orderedSegments.push({ type: "tool_call", toolCall: tc });
133
- s.streaming = { segments: [...orderedSegments], activeToolName: data.name };
134
- this.notify();
134
+ this.update(sessionId, { streaming: { segments: [...orderedSegments], activeToolName: data.name } });
135
135
  break;
136
136
  }
137
137
  case "tool_result": {
138
138
  const tc = allToolCalls.find((c) => c.id === data.id);
139
139
  if (tc) tc.result = data.result;
140
- s.streaming = { segments: [...orderedSegments] };
141
- this.notify();
140
+ this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
142
141
  break;
143
142
  }
144
143
  case "done": break;
@@ -151,26 +150,31 @@ class ChatStore {
151
150
  }
152
151
  }
153
152
 
154
- s.messages = [...s.messages, {
155
- role: "assistant",
156
- content: fullText,
157
- toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
158
- segments: orderedSegments.length > 0 ? orderedSegments : undefined,
159
- timestamp: new Date().toISOString(),
160
- }];
161
- } catch (err) {
162
- if ((err as Error).name !== "AbortError") {
163
- s.messages = [...s.messages, {
153
+ const cur = this.getOrCreate(sessionId);
154
+ this.update(sessionId, {
155
+ messages: [...cur.messages, {
164
156
  role: "assistant",
165
- content: `Error: ${err instanceof Error ? err.message : String(err)}`,
157
+ content: fullText,
158
+ toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
159
+ segments: orderedSegments.length > 0 ? orderedSegments : undefined,
166
160
  timestamp: new Date().toISOString(),
167
- }];
161
+ }],
162
+ });
163
+ sendNotification("Chat response ready", fullText.slice(0, 100));
164
+ } catch (err) {
165
+ if ((err as Error).name !== "AbortError") {
166
+ const cur = this.getOrCreate(sessionId);
167
+ this.update(sessionId, {
168
+ messages: [...cur.messages, {
169
+ role: "assistant",
170
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
171
+ timestamp: new Date().toISOString(),
172
+ }],
173
+ });
168
174
  }
169
175
  } finally {
170
- s.loading = false;
171
- s.streaming = null;
176
+ this.update(sessionId, { loading: false, streaming: null });
172
177
  this.abortControllers.delete(sessionId);
173
- this.notify();
174
178
  }
175
179
  }
176
180
 
@@ -180,8 +184,7 @@ class ChatStore {
180
184
  if (lastAssistantIdx === -1) return;
181
185
  const lastUserIdx = s.messages.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
182
186
  const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
183
- s.messages = s.messages.slice(0, removeFrom);
184
- this.notify();
187
+ this.update(sessionId, { messages: s.messages.slice(0, removeFrom) });
185
188
  await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
186
189
  }
187
190
  }
@@ -0,0 +1,41 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ export interface OutdatedInfo {
4
+ outdated: boolean;
5
+ currentTitle?: string;
6
+ currentState?: string;
7
+ analyzedAt?: string;
8
+ currentUpdatedAt?: string;
9
+ }
10
+
11
+ export function useOutdatedCheck(sessionId?: string | null): OutdatedInfo | null {
12
+ const [info, setInfo] = useState<OutdatedInfo | null>(null);
13
+
14
+ useEffect(() => {
15
+ setInfo(null);
16
+ if (!sessionId) return;
17
+ fetch(`/api/sessions/${sessionId}/outdated`)
18
+ .then((r) => r.json())
19
+ .then((data) => {
20
+ const d = data as {
21
+ outdated?: boolean;
22
+ current_title?: string;
23
+ current_state?: string;
24
+ analyzed_at?: string;
25
+ current_updated_at?: string;
26
+ };
27
+ if (d.outdated !== undefined) {
28
+ setInfo({
29
+ outdated: d.outdated,
30
+ currentTitle: d.current_title,
31
+ currentState: d.current_state,
32
+ analyzedAt: d.analyzed_at,
33
+ currentUpdatedAt: d.current_updated_at,
34
+ });
35
+ }
36
+ })
37
+ .catch(() => {});
38
+ }, [sessionId]);
39
+
40
+ return info;
41
+ }
@@ -0,0 +1,21 @@
1
+ let permissionRequested = false;
2
+
3
+ export function requestNotificationPermission(): void {
4
+ if (permissionRequested || typeof Notification === "undefined") return;
5
+ permissionRequested = true;
6
+ if (Notification.permission === "default") {
7
+ Notification.requestPermission();
8
+ }
9
+ }
10
+
11
+ export function sendNotification(title: string, body?: string): void {
12
+ if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
13
+ if (document.hasFocus()) return;
14
+ try {
15
+ new Notification(title, {
16
+ body,
17
+ icon: "/favicon.ico",
18
+ tag: "newpr",
19
+ });
20
+ } catch {}
21
+ }