Unverified 提交 66d2c8ce authored 作者: Nour Zakhma's avatar Nour Zakhma 提交者: GitHub

fix: keep retry button visible when error popup appears (#3231)

closes #1772 When an AI request fails during build, the error popup would appear and cover the retry button at the bottom of the chat, forcing users to manually scroll down to access it. This fix adds an auto-scroll effect that scrolls up ~150px when an error occurs, ensuring the retry button remains visible and accessible above the error popup. This improves the UX for error recovery scenarios. before: <img width="550" height="365" alt="Capture d&#39;écran 2026-04-18 031044" src="https://github.com/user-attachments/assets/61b4108b-0732-469e-abae-bd6ba86ee7ab" /> after <img width="583" height="439" alt="Capture d&#39;écran 2026-04-18 030508" src="https://github.com/user-attachments/assets/917ac306-a7eb-41ae-959c-aadf9d87b2c2" /> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3231" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
上级 070c6307
......@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useAtomValue, useSetAtom } from "jotai";
import {
chatErrorByIdAtom,
chatMessagesByIdAtom,
chatStreamCountByIdAtom,
isStreamingByIdAtom,
......@@ -12,7 +13,6 @@ import { ChatHeader } from "./chat/ChatHeader";
import { MessagesList } from "./chat/MessagesList";
import { ChatInput } from "./chat/ChatInput";
import { VersionPane } from "./chat/VersionPane";
import { ChatError } from "./chat/ChatError";
import { FreeAgentQuotaBanner } from "./chat/FreeAgentQuotaBanner";
import { NotificationBanner } from "./chat/NotificationBanner";
import { Button } from "@/components/ui/button";
......@@ -40,9 +40,9 @@ export function ChatPanel({
}: ChatPanelProps) {
const { t } = useTranslation("chat");
const messagesById = useAtomValue(chatMessagesByIdAtom);
const chatErrorById = useAtomValue(chatErrorByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const { settings } = useSettings();
......@@ -86,6 +86,7 @@ export function ChatPanel({
// Scroll to bottom when a new stream starts (user sent a message)
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
const streamError = chatId ? (chatErrorById.get(chatId) ?? null) : null;
// Track previous chatId to detect chat switches
const prevChatIdRef = useRef<number | undefined>(undefined);
......@@ -153,6 +154,49 @@ export function ChatPanel({
}
}, [isStreaming, scrollToBottom]);
// Keep footer actions (including Retry) visible when stream errors render below.
useEffect(() => {
if (!streamError) return;
const container = messagesContainerRef.current;
const distanceFromBottom = container
? container.scrollHeight - (container.scrollTop + container.clientHeight)
: 0;
const isNearBottom = distanceFromBottom <= 220;
if (!isAtBottomRef.current && !isNearBottom) return;
let cancelled = false;
let firstRafId: number | undefined;
let secondRafId: number | undefined;
let timeoutId: number | undefined;
firstRafId = requestAnimationFrame(() => {
if (cancelled) return;
secondRafId = requestAnimationFrame(() => {
if (cancelled) return;
scrollToBottom("instant");
timeoutId = window.setTimeout(() => {
if (!cancelled) {
scrollToBottom("smooth");
}
}, 120);
});
});
return () => {
cancelled = true;
if (firstRafId !== undefined) {
window.cancelAnimationFrame(firstRafId);
}
if (secondRafId !== undefined) {
window.cancelAnimationFrame(secondRafId);
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [streamError, scrollToBottom]);
// Test mode only: Track scroll position to update isAtBottom state.
// In production, Virtuoso's atBottomStateChange handles this.
useEffect(() => {
......@@ -223,8 +267,6 @@ export function ChatPanel({
</div>
)}
</div>
<ChatError error={error} onDismiss={() => setError(null)} />
{showFreeAgentQuotaBanner && (
<FreeAgentQuotaBanner
onSwitchToBuildMode={() =>
......
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
import { useTranslation } from "react-i18next";
interface ChatErrorProps {
error: string | null;
onDismiss: () => void;
}
export function ChatError({ error, onDismiss }: ChatErrorProps) {
const { t } = useTranslation("chat");
if (!error) {
return null;
}
return (
<div
data-testid="chat-error"
className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm"
>
<AlertTriangle
className="h-5 w-5 mr-2 flex-shrink-0"
aria-hidden="true"
/>
<span className="flex-1">{error}</span>
<button
onClick={onDismiss}
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label={t("dismissError")}
>
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
</button>
</div>
);
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论