perf: P0 performance optimizations — memo, base64 cleanup, startup speed, bump v0.17.5
Six independent fixes targeting startup lag, streaming jank, and excessive payloads: Step 0 — MessageItem React.memo + useMemo (MessageItem.tsx) - Wrap MessageItem with React.memo so historical messages skip re-render when `message` ref is stable during streaming - Add useMemo for parseToolBlocks + pairTools (deps: message.content), parseMessageFiles (deps: text), token_usage JSON.parse (deps: message.token_usage) - Extract assistant content rendering into separate memo'd AssistantContent component to cache parseBatchPlan / parseImageGen chain (deps: displayText, messageId) - Root cause: 50 msgs × 10 SSE events/s = 500 full parses/s causing UI jank Step 1 — Strip base64 from new messages (ChatView.tsx, route.ts) - ChatView: remove `data: f.data` from fileMeta in display content HTML comment - route.ts: skip base64 data in fileAttachments when filePath exists (file already on disk) - Root cause: base64 image data inflated message content to MB-level Step 2 — Sanitize old messages in API (messages/route.ts) - Add stripFileData() to strip base64 `data` fields from <!--files:...--> comments before returning messages from GET /api/chat/sessions/:id/messages - Handles legacy data already stored with base64 Step 3 — Reduce initial fetch limit 100 → 30 (page.tsx, messages/route.ts) - Default limit changed from 100 to 30 on both frontend and backend - "Load more" functionality unchanged Step 4 — ChatListPanel dedup requests (ChatListPanel.tsx) - Remove duplicate pathname-dependent useEffect (was causing 2 fetches per navigation) - Add AbortController to cancel in-flight requests on new fetch - Add 300ms debounce for event-driven fetches (session-created/session-updated) Step 5 — Electron startup: show window before server ready (electron/main.ts) - createWindow() now accepts optional URL, defaults to inline loading HTML - Loading page: dark theme, centered spinner, "Starting CodePilot..." text - Startup: startServer() → createWindow(loading) → waitForServer() → loadURL(real) - Also updated activate handler for same early-window pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
c14fdb07ae
commit
132fdeb255
9 files changed
+232
-133
No files matched your search
+53
-6
@@ -325,7 +325,40 @@ function getIconPath(): string {
|
||||
return path.join(process.resourcesPath, 'icon.icns');
|
||||
}
|
||||
|
||||
function createWindow(port: number) {
|
||||
/** Inline loading HTML shown while the server starts up */
|
||||
const LOADING_HTML = `data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0a0a0a; color: #a0a0a0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.container { text-align: center; }
|
||||
.spinner {
|
||||
width: 28px; height: 28px; margin: 0 auto 14px;
|
||||
border: 2.5px solid rgba(255,255,255,0.1);
|
||||
border-top-color: rgba(255,255,255,0.5);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { font-size: 13px; opacity: 0.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<p>Starting CodePilot...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)}`;
|
||||
|
||||
function createWindow(url?: string) {
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: 1280,
|
||||
height: 860,
|
||||
@@ -352,7 +385,7 @@ function createWindow(port: number) {
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
mainWindow.loadURL(`http://127.0.0.1:${port}`);
|
||||
mainWindow.loadURL(url || LOADING_HTML);
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
@@ -730,17 +763,25 @@ app.whenReady().then(async () => {
|
||||
if (isDev) {
|
||||
port = 3000;
|
||||
console.log(`Dev mode: connecting to http://127.0.0.1:${port}`);
|
||||
serverPort = port;
|
||||
createWindow(`http://127.0.0.1:${port}`);
|
||||
} else {
|
||||
port = await getPort();
|
||||
console.log(`Starting server on port ${port}...`);
|
||||
serverProcess = startServer(port);
|
||||
serverPort = port;
|
||||
|
||||
// Show window immediately with loading screen
|
||||
createWindow();
|
||||
|
||||
// Wait for server in background, then navigate to real URL
|
||||
await waitForServer(port);
|
||||
console.log('Server is ready');
|
||||
if (mainWindow) {
|
||||
mainWindow.loadURL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
createWindow(port);
|
||||
|
||||
// Initialize auto-updater in packaged mode only
|
||||
if (!isDev && mainWindow) {
|
||||
initAutoUpdater(mainWindow);
|
||||
@@ -768,10 +809,16 @@ app.on('activate', async () => {
|
||||
if (!isDev && !serverProcess) {
|
||||
const port = await getPort();
|
||||
serverProcess = startServer(port);
|
||||
// Show loading window immediately
|
||||
createWindow();
|
||||
await waitForServer(port);
|
||||
serverPort = port;
|
||||
if (mainWindow) {
|
||||
mainWindow.loadURL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
} else {
|
||||
createWindow(`http://127.0.0.1:${serverPort || 3000}`);
|
||||
}
|
||||
createWindow(serverPort || 3000);
|
||||
|
||||
// Re-attach updater to the new window
|
||||
if (!isDev && mainWindow) {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.17.4",
|
||||
"version": "0.17.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codepilot",
|
||||
"version": "0.17.4",
|
||||
"version": "0.17.5",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.47",
|
||||
"@ai-sdk/google": "^3.0.31",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.17.4",
|
||||
"version": "0.17.5",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function POST(request: NextRequest) {
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
data: f.data,
|
||||
data: meta?.filePath ? '' : f.data, // Skip base64 data if file is already saved to disk
|
||||
filePath: meta?.filePath,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -2,6 +2,22 @@ import { NextRequest } from 'next/server';
|
||||
import { getMessages, getSession } from '@/lib/db';
|
||||
import type { MessagesResponse } from '@/types';
|
||||
|
||||
/** Strip base64 `data` fields from <!--files:...--> HTML comments in message content */
|
||||
function stripFileData(content: string): string {
|
||||
const match = content.match(/^<!--files:(.*?)-->/);
|
||||
if (!match) return content;
|
||||
try {
|
||||
const files = JSON.parse(match[1]);
|
||||
const cleaned = files.map((f: Record<string, unknown>) => {
|
||||
const { data, ...rest } = f;
|
||||
return rest;
|
||||
});
|
||||
return `<!--files:${JSON.stringify(cleaned)}-->${content.slice(match[0].length)}`;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -17,11 +33,16 @@ export async function GET(
|
||||
const limitParam = searchParams.get('limit');
|
||||
const beforeParam = searchParams.get('before');
|
||||
|
||||
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 100, 1), 500) : 100;
|
||||
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 30, 1), 500) : 30;
|
||||
const beforeRowId = beforeParam ? parseInt(beforeParam, 10) || undefined : undefined;
|
||||
|
||||
const { messages, hasMore } = getMessages(id, { limit, beforeRowId });
|
||||
const response: MessagesResponse = { messages, hasMore };
|
||||
// Sanitize: strip base64 data from file attachments in old messages
|
||||
const sanitizedMessages = messages.map(m => ({
|
||||
...m,
|
||||
content: stripFileData(m.content),
|
||||
}));
|
||||
const response: MessagesResponse = { messages: sanitizedMessages, hasMore };
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch messages';
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
|
||||
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const res = await fetch(`/api/chat/sessions/${id}/messages?limit=100`);
|
||||
const res = await fetch(`/api/chat/sessions/${id}/messages?limit=30`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
|
||||
@@ -212,7 +212,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
// Build display content: embed file metadata as HTML comment for MessageItem to parse
|
||||
let displayContent = displayUserContent;
|
||||
if (files && files.length > 0) {
|
||||
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data }));
|
||||
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size }));
|
||||
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${displayUserContent}`;
|
||||
}
|
||||
|
||||
|
||||
+123
-108
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react';
|
||||
import type { Message, TokenUsage, FileAttachment } from '@/types';
|
||||
import {
|
||||
Message as AIMessage,
|
||||
@@ -316,7 +316,7 @@ function TokenUsageDisplay({ usage }: { usage: TokenUsage }) {
|
||||
|
||||
const COLLAPSE_HEIGHT = 300;
|
||||
|
||||
export function MessageItem({ message }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({ message }: MessageItemProps) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
// Collapse/expand state for long user messages (hooks must be called unconditionally)
|
||||
@@ -324,35 +324,42 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Memoize expensive parsing: parseToolBlocks + pairTools
|
||||
const { text, pairedTools } = useMemo(() => {
|
||||
const { text, tools } = parseToolBlocks(message.content);
|
||||
const pairedTools = pairTools(tools);
|
||||
return { text, pairedTools };
|
||||
}, [message.content]);
|
||||
|
||||
// Memoize file attachment parsing
|
||||
const { files, displayText } = useMemo(() => {
|
||||
if (isUser) {
|
||||
const { files, text: textWithoutFiles } = parseMessageFiles(text);
|
||||
return { files, displayText: textWithoutFiles };
|
||||
}
|
||||
return { files: [] as FileAttachment[], displayText: text };
|
||||
}, [text, isUser]);
|
||||
|
||||
// Hide image-gen system notices — they exist in DB for Claude's context but shouldn't render
|
||||
if (isUser && message.content.startsWith('[__IMAGE_GEN_NOTICE__')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { text, tools } = parseToolBlocks(message.content);
|
||||
const pairedTools = pairTools(tools);
|
||||
|
||||
// Parse file attachments from user messages
|
||||
const { files, text: textWithoutFiles } = isUser
|
||||
? parseMessageFiles(text)
|
||||
: { files: [], text };
|
||||
|
||||
const displayText = isUser ? textWithoutFiles : text;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUser && contentRef.current) {
|
||||
setIsOverflowing(contentRef.current.scrollHeight > COLLAPSE_HEIGHT);
|
||||
}
|
||||
}, [isUser, displayText]);
|
||||
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
if (message.token_usage) {
|
||||
// Memoize token usage JSON parsing
|
||||
const tokenUsage = useMemo<TokenUsage | null>(() => {
|
||||
if (!message.token_usage) return null;
|
||||
try {
|
||||
tokenUsage = JSON.parse(message.token_usage);
|
||||
return JSON.parse(message.token_usage);
|
||||
} catch {
|
||||
// skip
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, [message.token_usage]);
|
||||
|
||||
const timestamp = parseDBDate(message.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
@@ -418,96 +425,7 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (() => {
|
||||
// Try batch-plan first (Image Agent batch mode)
|
||||
const batchPlanResult = parseBatchPlan(displayText);
|
||||
if (batchPlanResult) {
|
||||
return (
|
||||
<>
|
||||
{batchPlanResult.beforeText && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
|
||||
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={message.id} />
|
||||
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Try image-gen-result first (new direct-call format)
|
||||
const genResult = parseImageGenResult(displayText);
|
||||
if (genResult) {
|
||||
const { result } = genResult;
|
||||
if (result.status === 'generating') {
|
||||
return (
|
||||
<>
|
||||
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">Generating image...</span>
|
||||
</div>
|
||||
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
return (
|
||||
<>
|
||||
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
|
||||
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{result.error || 'Image generation failed'}</p>
|
||||
</div>
|
||||
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (result.status === 'completed' && result.images && result.images.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
|
||||
<ImageGenCard
|
||||
images={result.images.map(img => ({
|
||||
data: img.data || '',
|
||||
mimeType: img.mimeType,
|
||||
localPath: img.localPath,
|
||||
}))}
|
||||
prompt={result.prompt}
|
||||
aspectRatio={result.aspectRatio}
|
||||
imageSize={result.resolution}
|
||||
model={result.model}
|
||||
/>
|
||||
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: image-gen-request (model-dependent format, for old messages)
|
||||
const parsed = parseImageGenRequest(displayText);
|
||||
if (parsed) {
|
||||
const refs = buildReferenceImages(
|
||||
message.id,
|
||||
parsed.request.useLastGenerated || false,
|
||||
parsed.request.referenceImages,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parsed.beforeText && <MessageResponse>{parsed.beforeText}</MessageResponse>}
|
||||
<ImageGenConfirmation
|
||||
messageId={message.id}
|
||||
initialPrompt={parsed.request.prompt}
|
||||
initialAspectRatio={parsed.request.aspectRatio}
|
||||
initialResolution={parsed.request.resolution}
|
||||
referenceImages={refs.length > 0 ? refs : undefined}
|
||||
/>
|
||||
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const stripped = displayText
|
||||
.replace(/```image-gen-request[\s\S]*?```/g, '')
|
||||
.replace(/```image-gen-result[\s\S]*?```/g, '')
|
||||
.replace(/```batch-plan[\s\S]*?```/g, '')
|
||||
.trim();
|
||||
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
|
||||
})()
|
||||
) : <AssistantContent displayText={displayText} messageId={message.id} />
|
||||
)}
|
||||
</MessageContent>
|
||||
|
||||
@@ -519,4 +437,101 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
</div>
|
||||
</AIMessage>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Memoized assistant message content — avoids re-running parseBatchPlan / parseImageGenResult /
|
||||
* parseImageGenRequest on every render when only unrelated props change.
|
||||
*/
|
||||
const AssistantContent = memo(function AssistantContent({ displayText, messageId }: { displayText: string; messageId: string }) {
|
||||
return useMemo(() => {
|
||||
// Try batch-plan first (Image Agent batch mode)
|
||||
const batchPlanResult = parseBatchPlan(displayText);
|
||||
if (batchPlanResult) {
|
||||
return (
|
||||
<>
|
||||
{batchPlanResult.beforeText && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
|
||||
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={messageId} />
|
||||
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Try image-gen-result first (new direct-call format)
|
||||
const genResult = parseImageGenResult(displayText);
|
||||
if (genResult) {
|
||||
const { result } = genResult;
|
||||
if (result.status === 'generating') {
|
||||
return (
|
||||
<>
|
||||
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">Generating image...</span>
|
||||
</div>
|
||||
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
return (
|
||||
<>
|
||||
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
|
||||
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{result.error || 'Image generation failed'}</p>
|
||||
</div>
|
||||
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (result.status === 'completed' && result.images && result.images.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{genResult.beforeText && <MessageResponse>{genResult.beforeText}</MessageResponse>}
|
||||
<ImageGenCard
|
||||
images={result.images.map(img => ({
|
||||
data: img.data || '',
|
||||
mimeType: img.mimeType,
|
||||
localPath: img.localPath,
|
||||
}))}
|
||||
prompt={result.prompt}
|
||||
aspectRatio={result.aspectRatio}
|
||||
imageSize={result.resolution}
|
||||
model={result.model}
|
||||
/>
|
||||
{genResult.afterText && <MessageResponse>{genResult.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: image-gen-request (model-dependent format, for old messages)
|
||||
const parsed = parseImageGenRequest(displayText);
|
||||
if (parsed) {
|
||||
const refs = buildReferenceImages(
|
||||
messageId,
|
||||
parsed.request.useLastGenerated || false,
|
||||
parsed.request.referenceImages,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parsed.beforeText && <MessageResponse>{parsed.beforeText}</MessageResponse>}
|
||||
<ImageGenConfirmation
|
||||
messageId={messageId}
|
||||
initialPrompt={parsed.request.prompt}
|
||||
initialAspectRatio={parsed.request.aspectRatio}
|
||||
initialResolution={parsed.request.resolution}
|
||||
referenceImages={refs.length > 0 ? refs : undefined}
|
||||
/>
|
||||
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const stripped = displayText
|
||||
.replace(/```image-gen-request[\s\S]*?```/g, '')
|
||||
.replace(/```image-gen-result[\s\S]*?```/g, '')
|
||||
.replace(/```batch-plan[\s\S]*?```/g, '')
|
||||
.trim();
|
||||
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
|
||||
}, [displayText, messageId]);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Delete02Icon,
|
||||
@@ -213,37 +213,53 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// AbortController ref for cancelling in-flight requests
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
// Cancel any in-flight request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await fetch("/api/chat/sessions");
|
||||
const res = await fetch("/api/chat/sessions", { signal: controller.signal });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSessions(data.sessions || []);
|
||||
}
|
||||
} catch {
|
||||
// API may not be available yet
|
||||
} catch (e) {
|
||||
// Ignore abort errors; log others
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
const debouncedFetchSessions = useCallback(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchSessions();
|
||||
}, 300);
|
||||
}, [fetchSessions]);
|
||||
|
||||
// Refresh session list when navigating
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, [pathname, fetchSessions]);
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [fetchSessions]);
|
||||
|
||||
// Refresh session list when a session is created or updated
|
||||
// Refresh session list when a session is created or updated (debounced)
|
||||
useEffect(() => {
|
||||
const handler = () => fetchSessions();
|
||||
const handler = () => debouncedFetchSessions();
|
||||
window.addEventListener("session-created", handler);
|
||||
window.addEventListener("session-updated", handler);
|
||||
return () => {
|
||||
window.removeEventListener("session-created", handler);
|
||||
window.removeEventListener("session-updated", handler);
|
||||
};
|
||||
}, [fetchSessions]);
|
||||
}, [debouncedFetchSessions]);
|
||||
|
||||
const handleDeleteSession = async (
|
||||
e: React.MouseEvent,
|
||||
|
||||
Reference in new issue
Block a user