chore: remove changeset negation rules from .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -213,4 +213,76 @@ describe("useMobileKeyboard", () => {
|
||||
expect(result.current.viewportHeight).toBe(520);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports moderate iOS fallback overlap below 80px", async () => {
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(0);
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 804,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 804,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(40);
|
||||
expect(result.current.viewportHeight).toBe(804);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses focused-input fallback for small viewport gaps", async () => {
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(0);
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 820,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 820,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.keyboardOverlap).toBe(24);
|
||||
expect(result.current.viewportHeight).toBe(820);
|
||||
});
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,6 +172,10 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
const cancelledByUserRef = useRef(false);
|
||||
const pendingMessageRef = useRef("");
|
||||
// Cancel any pending requestAnimationFrame flushes from the active stream.
|
||||
// Set when sendMessage starts, cleared on done/error. Called from stopStreaming
|
||||
// so a clear-then-rAF-fires sequence doesn't flash stale text back in.
|
||||
const cancelStreamingFlushesRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Refs for SSE event handlers to access current state
|
||||
const sessionsRef = useRef(sessions);
|
||||
@@ -343,8 +347,10 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
updatedAt: data.session.updatedAt,
|
||||
};
|
||||
|
||||
// Add to sessions list at the top
|
||||
setSessions((prev) => [newSession, ...prev]);
|
||||
setSessions((prev) => {
|
||||
if (prev.some((s) => s.id === newSession.id)) return prev;
|
||||
return [newSession, ...prev];
|
||||
});
|
||||
|
||||
selectSession(newSession.id, newSession);
|
||||
setMessages([]);
|
||||
@@ -400,6 +406,8 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
if (!activeSession) return;
|
||||
|
||||
cancelledByUserRef.current = true;
|
||||
cancelStreamingFlushesRef.current?.();
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
|
||||
@@ -463,14 +471,44 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
let capturedThinking = "";
|
||||
let capturedToolCalls: ToolCallInfo[] = [];
|
||||
|
||||
// Coalesce per-token state updates to one render per animation frame.
|
||||
// ReactMarkdown re-parses the entire growing string on every render and
|
||||
// every prior message also re-renders, so unthrottled updates pin the
|
||||
// main thread for long replies.
|
||||
let textRaf: number | null = null;
|
||||
let thinkingRaf: number | null = null;
|
||||
const flushText = () => {
|
||||
textRaf = null;
|
||||
setStreamingText(capturedText);
|
||||
};
|
||||
const flushThinking = () => {
|
||||
thinkingRaf = null;
|
||||
setStreamingThinking(capturedThinking);
|
||||
};
|
||||
const cancelStreamingFlushes = () => {
|
||||
if (textRaf !== null) {
|
||||
cancelAnimationFrame(textRaf);
|
||||
textRaf = null;
|
||||
}
|
||||
if (thinkingRaf !== null) {
|
||||
cancelAnimationFrame(thinkingRaf);
|
||||
thinkingRaf = null;
|
||||
}
|
||||
};
|
||||
cancelStreamingFlushesRef.current = cancelStreamingFlushes;
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
capturedThinking += data;
|
||||
setStreamingThinking(capturedThinking);
|
||||
if (thinkingRaf === null) {
|
||||
thinkingRaf = requestAnimationFrame(flushThinking);
|
||||
}
|
||||
},
|
||||
onText: (data: string) => {
|
||||
capturedText += data;
|
||||
setStreamingText(capturedText);
|
||||
if (textRaf === null) {
|
||||
textRaf = requestAnimationFrame(flushText);
|
||||
}
|
||||
},
|
||||
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
|
||||
capturedToolCalls = [
|
||||
@@ -513,6 +551,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onDone: (data: { messageId: string }) => {
|
||||
cancelStreamingFlushes();
|
||||
const assistantMessage: ChatMessageInfo = {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
sessionId: activeSession.id,
|
||||
@@ -550,6 +589,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
}
|
||||
},
|
||||
onError: (data: string) => {
|
||||
cancelStreamingFlushes();
|
||||
setMessages((prev) => prev.filter((m) => m.id !== tempId));
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const IOS_FALLBACK_MIN_GAP_PX = 30;
|
||||
const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16;
|
||||
|
||||
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -31,6 +34,16 @@ function getInitialViewportHeight(): number {
|
||||
* - Fallback: initial viewport height - vv.height - vv.offsetTop
|
||||
* Works on iOS Safari where window.innerHeight shrinks with the keyboard.
|
||||
*/
|
||||
function isKeyboardFocusableElement(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
if (el instanceof HTMLTextAreaElement) return true;
|
||||
if (el instanceof HTMLInputElement) {
|
||||
const nonTextTypes = new Set(["checkbox", "radio", "button", "submit", "reset", "file", "range", "color", "hidden"]);
|
||||
return !nonTextTypes.has(el.type);
|
||||
}
|
||||
return el instanceof HTMLElement && el.isContentEditable;
|
||||
}
|
||||
|
||||
function getKeyboardOverlap(): number {
|
||||
if (typeof window === "undefined" || !window.visualViewport) return 0;
|
||||
const vv = window.visualViewport;
|
||||
@@ -38,8 +51,17 @@ function getKeyboardOverlap(): number {
|
||||
if (chromeOverlap > 0) return chromeOverlap;
|
||||
|
||||
const initialHeight = getInitialViewportHeight();
|
||||
const gap = initialHeight - vv.offsetTop - vv.height;
|
||||
return gap >= 30 && gap > 80 ? gap : 0;
|
||||
const gap = Math.max(0, initialHeight - vv.offsetTop - vv.height);
|
||||
|
||||
if (gap >= IOS_FALLBACK_MIN_GAP_PX) {
|
||||
return gap;
|
||||
}
|
||||
|
||||
if (gap >= IOS_FALLBACK_MIN_FOCUSED_GAP_PX && isKeyboardFocusableElement(document.activeElement)) {
|
||||
return gap;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Reset cached initial viewport height. Exported for tests only. */
|
||||
|
||||
@@ -166,6 +166,7 @@ export function useQuickChat(
|
||||
// Stream connection ref for cleanup
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
const cancelledByUserRef = useRef(false);
|
||||
const cancelStreamingFlushesRef = useRef<(() => void) | null>(null);
|
||||
const pendingMessageRef = useRef("");
|
||||
const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
|
||||
|
||||
@@ -356,6 +357,8 @@ export function useQuickChat(
|
||||
if (!activeSession) return;
|
||||
|
||||
cancelledByUserRef.current = true;
|
||||
cancelStreamingFlushesRef.current?.();
|
||||
cancelStreamingFlushesRef.current = null;
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
|
||||
@@ -429,14 +432,42 @@ export function useQuickChat(
|
||||
let capturedThinking = "";
|
||||
let capturedToolCalls: ToolCallInfo[] = [];
|
||||
|
||||
// Coalesce per-token state updates to one render per animation frame —
|
||||
// unthrottled setStreamingText pegs the main thread on long replies.
|
||||
let textRaf: number | null = null;
|
||||
let thinkingRaf: number | null = null;
|
||||
const flushText = () => {
|
||||
textRaf = null;
|
||||
setStreamingText(capturedText);
|
||||
};
|
||||
const flushThinking = () => {
|
||||
thinkingRaf = null;
|
||||
setStreamingThinking(capturedThinking);
|
||||
};
|
||||
const cancelStreamingFlushes = () => {
|
||||
if (textRaf !== null) {
|
||||
cancelAnimationFrame(textRaf);
|
||||
textRaf = null;
|
||||
}
|
||||
if (thinkingRaf !== null) {
|
||||
cancelAnimationFrame(thinkingRaf);
|
||||
thinkingRaf = null;
|
||||
}
|
||||
};
|
||||
cancelStreamingFlushesRef.current = cancelStreamingFlushes;
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
capturedThinking += data;
|
||||
setStreamingThinking(capturedThinking);
|
||||
if (thinkingRaf === null) {
|
||||
thinkingRaf = requestAnimationFrame(flushThinking);
|
||||
}
|
||||
},
|
||||
onText: (data: string) => {
|
||||
capturedText += data;
|
||||
setStreamingText(capturedText);
|
||||
if (textRaf === null) {
|
||||
textRaf = requestAnimationFrame(flushText);
|
||||
}
|
||||
},
|
||||
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
|
||||
capturedToolCalls = [
|
||||
@@ -479,6 +510,7 @@ export function useQuickChat(
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onDone: (data: { messageId: string }) => {
|
||||
cancelStreamingFlushes();
|
||||
const assistantMessage: ChatMessageInfo = {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
sessionId: activeSession.id,
|
||||
@@ -508,6 +540,7 @@ export function useQuickChat(
|
||||
}
|
||||
},
|
||||
onError: (data: string) => {
|
||||
cancelStreamingFlushes();
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
|
||||
Reference in New Issue
Block a user