chore: remove changeset negation rules from .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-30 08:36:32 -07:00
parent b562f605e9
commit f7df0d4e34
13 changed files with 570 additions and 240 deletions

View File

@@ -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();
});
});

View File

@@ -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("");

View File

@@ -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. */

View File

@@ -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([]);