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.
- package/package.json +2 -2
- package/src/analyzer/pipeline.ts +1 -0
- package/src/github/fetch-pr.ts +1 -0
- package/src/history/store.ts +25 -1
- package/src/llm/slides.ts +381 -0
- package/src/types/github.ts +1 -0
- package/src/types/output.ts +26 -0
- package/src/web/client/App.tsx +5 -1
- package/src/web/client/components/ChatSection.tsx +74 -15
- package/src/web/client/components/DiffViewer.tsx +187 -3
- package/src/web/client/components/ResultsScreen.tsx +32 -2
- package/src/web/client/hooks/useBackgroundAnalyses.ts +17 -12
- package/src/web/client/hooks/useChatStore.ts +34 -31
- package/src/web/client/hooks/useOutdatedCheck.ts +41 -0
- package/src/web/client/lib/notify.ts +21 -0
- package/src/web/client/panels/SlidesPanel.tsx +316 -0
- package/src/web/server/routes.ts +185 -2
- package/src/web/server.ts +15 -0
- package/src/web/styles/built.css +1 -1
|
@@ -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 &&
|
|
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.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
59
|
-
s.loaded = true;
|
|
65
|
+
this.update(sessionId, { messages: data, loaded: true });
|
|
60
66
|
} catch {
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|