Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ export const ChatImpl = memo(
const [failedMessageIds, setFailedMessageIds] = useState<Set<string>>(new Set());
const failedMessageIdRef = useRef<string | null>(null);
const lastAiMessageIdRef = useRef<string | null>(null);
const messagesRef = useRef<UIMessage[]>([]);
const [installNpm, setInstallNpm] = useState<boolean>(false);
const [customProgressAnnotations, setCustomProgressAnnotations] = useState<ProgressAnnotation[]>([]);

Expand Down Expand Up @@ -775,11 +776,31 @@ export const ChatImpl = memo(
return;
}

const id = lastAiMessageIdRef.current;
const currentMessages = messagesRef.current;
const lastMessage = currentMessages[currentMessages.length - 1];

if (id) {
setFailedMessageIds((prev) => new Set(prev).add(id));
failedMessageIdRef.current = id;
if (lastMessage?.role === 'user') {
/*
* Error occurred in submitted state — no assistant message exists yet for this turn.
* Create a placeholder so the user can see the failure and retry.
*/

const errorId = `assistant-error-${Date.now()}`;

setMessages((prev) => [
...prev,
{ id: errorId, role: 'assistant', parts: [{ type: 'text', text: '' }] } as UIMessage,
]);
setFailedMessageIds((prev) => new Set(prev).add(errorId));
failedMessageIdRef.current = errorId;
} else {
// Error occurred in streaming state — assistant message already exists, mark it as failed.
const id = lastAiMessageIdRef.current;

if (id) {
setFailedMessageIds((prev) => new Set(prev).add(id));
failedMessageIdRef.current = id;
}
}

const currentModel = chatStateRef.current.model;
Expand Down Expand Up @@ -894,6 +915,39 @@ export const ChatImpl = memo(
// Derived state for loading status
const isLoading = status === 'streaming' || status === 'submitted';

// Timeout: if no response arrives within 60s after submission, treat as failed
const RESPONSE_TIMEOUT_MS = 60_000;

useEffect(() => {
if (status !== 'submitted') {
return undefined;
}

const timer = setTimeout(() => {
stopRef.current();
clearProgressState();
setFakeLoading(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setFakeLoading(false) 와 같은 UI 정리는 필요없을지 확인 부탁드립니다.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 코멘트 반영하였습니다. 감사합니다.

/*
* In submitted state, useChat has not yet added an assistant message for the current
* request — lastAssistant would be the previous turn's message, not the current one.
* Always create a new placeholder to represent the timed-out response.
*/
const timeoutId = `assistant-timeout-${Date.now()}`;

setMessages((prev) => [
...prev,
{ id: timeoutId, role: 'assistant', parts: [{ type: 'text', text: '' }] } as UIMessage,
]);
setFailedMessageIds((prev) => new Set(prev).add(timeoutId));
failedMessageIdRef.current = timeoutId;

logger.warn('Response timeout: no data received within ' + RESPONSE_TIMEOUT_MS + 'ms');
}, RESPONSE_TIMEOUT_MS);

return () => clearTimeout(timer);
}, [status]);

useEffect(() => {
const prompt = searchParams.get('prompt');
const autorun = searchParams.get('run');
Expand Down Expand Up @@ -1034,6 +1088,8 @@ export const ChatImpl = memo(
}, [messages, isLoading, parseMessages]);

useEffect(() => {
messagesRef.current = messages;

const lastAiMsg = [...messages].reverse().find((m) => m.role === 'assistant');
lastAiMessageIdRef.current = lastAiMsg?.id ?? null;
}, [messages]);
Expand Down Expand Up @@ -2267,9 +2323,11 @@ export const ChatImpl = memo(
}

if (!commitHash || !isCommitHash(commitHash)) {
processError('No commit hash found', startTime, {
context: 'handleRetry - commit hash validation',
});
/*
* No valid commit to revert to (e.g. failed before any code was generated).
* Restore the user's input so they can retry without reverting.
*/
setInput(stripMetadata(extractTextContent(message)));

return;
}
Expand Down
78 changes: 42 additions & 36 deletions app/components/chat/Messages.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
<span className="text-heading-xs text-subtle">Response Stopped</span>
</div>
</div>
) : !isUserMessage && messageText.trim() === '' && failedMessageIds?.has(messageId ?? '') ? (
<div className="flex flex-col justify-start items-start gap-0 self-stretch rounded-[24px_24px_24px_0] border border-tertiary bg-primary backdrop-blur-[4px] mt-3">
<div className="relative flex items-center justify-between p-[14px] w-full bg-primary rounded-[23px] rounded-bl-none">
<span className="text-heading-xs text-danger-bold">Response Failed</span>
</div>
</div>
) : (
<div
data-message-index={index}
Expand Down Expand Up @@ -495,38 +501,39 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
</Tooltip.Root>
);
})()}
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger asChild>
<CustomIconButton
variant="secondary-transparent"
size="sm"
icon={<CopyLineIcon size={20} />}
onClick={() => {
// Get rendered text from message content only (excludes UI buttons like Show All/Hide)
const messageElement = document.querySelector(
`[data-message-index="${index}"]`,
) as HTMLElement | null;
const contentElement = messageElement?.querySelector(
'[data-message-content]',
) as HTMLElement | null;
const textToCopy = contentElement?.innerText || messageText;
navigator.clipboard.writeText(textToCopy);
toast.success('Copied to clipboard');
}}
disabled={false}
data-track="editor-response-copy"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="inline-flex items-start rounded-radius-8 bg-[var(--color-bg-inverse,#F3F5F8)] text-[var(--color-text-inverse,#111315)] p-[9.6px] shadow-md z-[9999] text-body-md-medium"
side="bottom"
>
Copy
<Tooltip.Arrow className="fill-[var(--color-bg-inverse,#F3F5F8)]" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
{messageText.trim() !== '' && (
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger asChild>
<CustomIconButton
variant="secondary-transparent"
size="sm"
icon={<CopyLineIcon size={20} />}
onClick={() => {
const messageElement = document.querySelector(
`[data-message-index="${index}"]`,
) as HTMLElement | null;
const contentElement = messageElement?.querySelector(
'[data-message-content]',
) as HTMLElement | null;
const textToCopy = contentElement?.innerText || messageText;
navigator.clipboard.writeText(textToCopy);
toast.success('Copied to clipboard');
}}
disabled={false}
data-track="editor-response-copy"
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="inline-flex items-start rounded-radius-8 bg-[var(--color-bg-inverse,#F3F5F8)] text-[var(--color-text-inverse,#111315)] p-[9.6px] shadow-md z-[9999] text-body-md-medium"
side="bottom"
>
Copy
<Tooltip.Arrow className="fill-[var(--color-bg-inverse,#F3F5F8)]" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)}
{/* Show Restore button for assistant messages with commit hash (except last message, only if message has content and not aborted) */}
{messageText.trim() !== '' &&
!isMessageAborted &&
Expand Down Expand Up @@ -568,10 +575,9 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
</Tooltip.Portal>
</Tooltip.Root>
)}
{/* Show Retry button only for the last message (only if message has content and not aborted) */}
{messageText.trim() !== '' &&
{/* Show Retry button only for the last message (only if message has content or failed, and not aborted) */}
{(messageText.trim() !== '' || failedMessageIds?.has(messageId ?? '')) &&
!isMessageAborted &&
!failedMessageIds?.has(messageId ?? '') &&
index > 0 &&
messages[index - 1]?.role === 'user' &&
isLast && (
Expand Down Expand Up @@ -669,7 +675,7 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
</Tooltip.Root>
)}
{failedMessageIds?.has(messageId ?? '') && (
<span className="text-body-sm text-tertiary">Please retry your prompt</span>
<span className="text-body-sm text-tertiary py-[9px]">Please retry your prompt</span>
)}
</div>
</div>
Expand Down