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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Improve mobile keyboard overlap detection so chat layout resizes reliably (including smaller iOS viewport shifts) without pushing fixed app chrome.

5
.gitignore vendored
View File

@@ -10,11 +10,6 @@ node_modules/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo
# Changesets are committed with published package changes.
!.changeset/
!.changeset/*.md
!.changeset/*.json
# Coverage reports # Coverage reports
coverage/ coverage/

View File

@@ -1,6 +1,6 @@
// ChatView.css is imported eagerly from App.tsx to avoid a flash of // ChatView.css is imported eagerly from App.tsx to avoid a flash of
// unstyled content when the lazy chunk loads. Do not re-import here. // unstyled content when the lazy chunk loads. Do not re-import here.
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
@@ -20,7 +20,7 @@ import {
File, File,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { useChat, type ToolCallInfo } from "../hooks/useChat"; import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
import { useViewportMode } from "./Header"; import { useViewportMode } from "./Header";
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api"; import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
import type { Agent } from "@fusion/core"; import type { Agent } from "@fusion/core";
@@ -534,6 +534,158 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
interface ChatMessageItemProps {
message: ChatMessageInfo;
forcePlain: boolean;
agentName: string;
showAssistantModelTag: boolean;
activeModelTag: string | null;
activeSessionId: string | null;
mentionAgentsByName: Map<string, Agent>;
onToggleRender: (id: string) => void;
}
// Renders a single chat message bubble. Memoized so the streaming bubble's
// per-frame state churn does not re-render every prior message (each one
// would re-run ReactMarkdown over its full content otherwise).
const ChatMessageItem = memo(function ChatMessageItem({
message,
forcePlain,
agentName,
showAssistantModelTag,
activeModelTag,
activeSessionId,
mentionAgentsByName,
onToggleRender,
}: ChatMessageItemProps) {
const isAssistantMessage = message.role === "assistant";
const renderedUserContent = useMemo<ReactNode>(() => {
if (isAssistantMessage) return null;
const content = message.content;
const mentionRegex = /@([\w-]+)/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
let match = mentionRegex.exec(content);
while (match) {
const [fullMatch, rawName = ""] = match;
const start = match.index;
if (start > lastIndex) parts.push(content.slice(lastIndex, start));
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
const mentionedAgent = mentionAgentsByName.get(normalizedName);
if (mentionedAgent) {
parts.push(
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
@{mentionedAgent.name.replace(/\s+/g, "_")}
</span>,
);
} else {
parts.push(fullMatch);
}
lastIndex = start + fullMatch.length;
match = mentionRegex.exec(content);
}
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
return parts.length === 0 ? content : parts;
}, [isAssistantMessage, message.content, mentionAgentsByName]);
const renderedAttachments = useMemo<ReactNode>(() => {
const attachments = message.attachments;
if (!attachments || attachments.length === 0 || !activeSessionId) return null;
const attachmentUrlBase = `/api/chat/sessions/${encodeURIComponent(activeSessionId)}/attachments/`;
return (
<div className="chat-message-attachments">
{attachments.map((attachment) => {
const isImage = attachment.mimeType.startsWith("image/");
const key = attachment.id || attachment.filename;
const href = `${attachmentUrlBase}${encodeURIComponent(attachment.filename)}`;
if (isImage) {
return (
<a
key={key}
className="chat-message-attachment-link"
data-testid="chat-message-attachment"
href={href}
target="_blank"
rel="noopener noreferrer"
>
<img
className="chat-message-attachment"
src={href}
alt={attachment.originalName}
/>
</a>
);
}
return (
<a
key={key}
className="chat-message-attachment-file"
data-testid="chat-message-attachment"
href={href}
target="_blank"
rel="noopener noreferrer"
>
<File size={14} />
<span>{attachment.originalName}</span>
</a>
);
})}
</div>
);
}, [message.attachments, activeSessionId]);
const assistantBody = useMemo<ReactNode>(() => {
if (!isAssistantMessage) return null;
if (forcePlain) {
return <div className="chat-message-content chat-message-content--plain">{message.content}</div>;
}
return (
<div className="chat-message-content chat-message-content--markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={chatMarkdownComponents}>
{message.content}
</ReactMarkdown>
</div>
);
}, [isAssistantMessage, forcePlain, message.content]);
return (
<div
className={`chat-message chat-message--${message.role}`}
data-testid={`chat-message-${message.id}`}
>
{isAssistantMessage && (
<div className="chat-message-avatar">
<Bot size={14} />
<span>{agentName}</span>
{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<button
type="button"
className={`chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => onToggleRender(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
{isAssistantMessage
? assistantBody
: <div className="chat-message-content">{renderedUserContent}</div>}
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
</details>
)}
{renderedAttachments}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
</div>
);
});
export function ChatView({ projectId, addToast }: ChatViewProps) { export function ChatView({ projectId, addToast }: ChatViewProps) {
const { const {
activeSession, activeSession,
@@ -888,109 +1040,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
[mentionStartPos, messageInput], [mentionStartPos, messageInput],
); );
const renderMessageContent = useCallback(
(content: string) => {
const mentionRegex = /@([\w-]+)/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
let match = mentionRegex.exec(content);
while (match) {
const [fullMatch, rawName = ""] = match;
const start = match.index;
if (start > lastIndex) {
parts.push(content.slice(lastIndex, start));
}
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
const mentionedAgent = mentionAgentsByName.get(normalizedName);
if (mentionedAgent) {
parts.push(
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
@{mentionedAgent.name.replace(/\s+/g, "_")}
</span>,
);
} else {
parts.push(fullMatch);
}
lastIndex = start + fullMatch.length;
match = mentionRegex.exec(content);
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
if (parts.length === 0) {
return content;
}
return parts;
},
[mentionAgentsByName],
);
const getAttachmentUrl = useCallback(
(filename: string) =>
activeSession ? `/api/chat/sessions/${encodeURIComponent(activeSession.id)}/attachments/${encodeURIComponent(filename)}` : "",
[activeSession],
);
const renderMessageAttachments = useCallback(
(
attachments: Array<{
id: string;
filename: string;
originalName: string;
mimeType: string;
}> | undefined,
) => {
if (!attachments || attachments.length === 0) return null;
return (
<div className="chat-message-attachments">
{attachments.map((attachment) => {
const isImage = attachment.mimeType.startsWith("image/");
const key = attachment.id || attachment.filename;
const href = getAttachmentUrl(attachment.filename);
if (isImage) {
return (
<a
key={key}
className="chat-message-attachment-link"
data-testid="chat-message-attachment"
href={href}
target="_blank"
rel="noopener noreferrer"
>
<img
className="chat-message-attachment"
src={href}
alt={attachment.originalName}
/>
</a>
);
}
return (
<a
key={key}
className="chat-message-attachment-file"
data-testid="chat-message-attachment"
href={href}
target="_blank"
rel="noopener noreferrer"
>
<File size={14} />
<span>{attachment.originalName}</span>
</a>
);
})}
</div>
);
},
[getAttachmentUrl],
);
// Handle input key down // Handle input key down
const handleInputKeyDown = useCallback( const handleInputKeyDown = useCallback(
@@ -1464,47 +1513,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div> </div>
) : ( ) : (
<> <>
{messages.map((message) => { {messages.map((message) => (
const isAssistantMessage = message.role === "assistant"; <ChatMessageItem
const forcePlain = plainTextMessageIds.has(message.id); key={message.id}
message={message}
return ( forcePlain={plainTextMessageIds.has(message.id)}
<div agentName={agentName}
key={message.id} showAssistantModelTag={showAssistantModelTag}
className={`chat-message chat-message--${message.role}`} activeModelTag={activeModelTag}
data-testid={`chat-message-${message.id}`} activeSessionId={activeSession?.id ?? null}
> mentionAgentsByName={mentionAgentsByName}
{isAssistantMessage && ( onToggleRender={toggleMessageRenderMode}
<div className="chat-message-avatar"> />
<Bot size={14} /> ))}
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<button
type="button"
className={`chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
{isAssistantMessage
? renderAssistantContent(message.content, forcePlain)
: <div className="chat-message-content">{renderMessageContent(message.content)}</div>}
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
</details>
)}
{renderMessageAttachments(message.attachments)}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
</div>
);
})}
{isStreaming && ( {isStreaming && (
<div className="chat-message chat-message--assistant chat-message--streaming"> <div className="chat-message chat-message--assistant chat-message--streaming">
<div className="chat-message-avatar"> <div className="chat-message-avatar">

View File

@@ -308,5 +308,22 @@
bottom: calc(60px + var(--executor-footer-height-mobile, var(--executor-footer-height, 0px)) + var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px)); bottom: calc(60px + var(--executor-footer-height-mobile, var(--executor-footer-height, 0px)) + var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px));
height: min(520px, calc(100dvh - 120px)); height: min(520px, calc(100dvh - 120px));
} }
/* Keep regular chat New Chat + Delete confirmation dialogs compact on mobile.
styles.css sets .chat-new-dialog-backdrop align-items: stretch globally on
mobile; override here so this modal does not become full-height. */
.chat-new-dialog-backdrop {
align-items: center;
padding: var(--space-md);
padding-top: max(var(--space-md), env(safe-area-inset-top, 0px));
padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px));
}
.chat-new-dialog {
width: 100%;
max-width: min(100%, calc(var(--space-xl) * 16));
max-height: calc(100dvh - (var(--space-md) * 2) - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
overflow-y: auto;
}
} }

View File

@@ -1,5 +1,6 @@
import "./QuickChatFAB.css"; import "./QuickChatFAB.css";
import { import {
memo,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -687,6 +688,92 @@ function usePanelResize(projectId: string | undefined, fabRight: number, fabBott
}; };
} }
interface QuickChatMessageItemProps {
message: ChatMessageInfo;
forcePlain: boolean;
mentionAgentsByName: Map<string, Agent>;
onToggleRender: (id: string) => void;
}
// Memoized so streaming state churn doesn't re-render every prior message
// (each one would re-run ReactMarkdown over its full content otherwise).
const QuickChatMessageItem = memo(function QuickChatMessageItem({
message,
forcePlain,
mentionAgentsByName,
onToggleRender,
}: QuickChatMessageItemProps) {
const isSent = message.role === "user";
const renderedUserContent = useMemo<ReactNode>(() => {
if (!isSent) return null;
const content = message.content;
const mentionRegex = /@([\w-]+)/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
let match = mentionRegex.exec(content);
while (match) {
const [fullMatch, rawName = ""] = match;
const start = match.index;
if (start > lastIndex) parts.push(content.slice(lastIndex, start));
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
const mentionedAgent = mentionAgentsByName.get(normalizedName);
if (mentionedAgent) {
parts.push(
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
@{mentionedAgent.name.replace(/\s+/g, "_")}
</span>,
);
} else {
parts.push(fullMatch);
}
lastIndex = start + fullMatch.length;
match = mentionRegex.exec(content);
}
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
return parts.length === 0 ? content : parts;
}, [isSent, message.content, mentionAgentsByName]);
const assistantBody = useMemo<ReactNode>(() => {
if (isSent) return null;
if (forcePlain) {
return <div className="quick-chat-message-content quick-chat-message-content--plain">{message.content}</div>;
}
return (
<div className="quick-chat-message-content quick-chat-message-content--markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={quickChatMarkdownComponents}>
{message.content}
</ReactMarkdown>
</div>
);
}, [isSent, forcePlain, message.content]);
return (
<div
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`}
data-testid={`quick-chat-message-${message.id}`}
>
{isSent
? <p>{renderedUserContent}</p>
: (
<>
{assistantBody}
<button
type="button"
className={`quick-chat-message-render-toggle${forcePlain ? " quick-chat-message-render-toggle--plain" : ""}`}
data-testid="quick-chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => onToggleRender(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</>
)}
{renderToolCalls(message.toolCalls, true)}
</div>
);
});
export function QuickChatFAB({ export function QuickChatFAB({
projectId, projectId,
addToast, addToast,
@@ -1327,49 +1414,6 @@ export function QuickChatFAB({
[handleInputSelectionChange], [handleInputSelectionChange],
); );
const renderMessageContent = useCallback(
(content: string) => {
const mentionRegex = /@([\w-]+)/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
let match = mentionRegex.exec(content);
while (match) {
const [fullMatch, rawName = ""] = match;
const start = match.index;
if (start > lastIndex) {
parts.push(content.slice(lastIndex, start));
}
const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
const mentionedAgent = mentionAgentsByName.get(normalizedName);
if (mentionedAgent) {
parts.push(
<span key={`${mentionedAgent.id}-${start}`} className="chat-mention-chip">
@{mentionedAgent.name.replace(/\s+/g, "_")}
</span>,
);
} else {
parts.push(fullMatch);
}
lastIndex = start + fullMatch.length;
match = mentionRegex.exec(content);
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
if (parts.length === 0) {
return content;
}
return parts;
},
[mentionAgentsByName],
);
const toggleMessageRenderMode = useCallback((messageId: string) => { const toggleMessageRenderMode = useCallback((messageId: string) => {
setPlainTextMessageIds((current) => { setPlainTextMessageIds((current) => {
const next = new Set(current); const next = new Set(current);
@@ -1694,35 +1738,15 @@ export function QuickChatFAB({
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div> <div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
) : ( ) : (
<> <>
{messages.map((message: ChatMessageInfo) => { {messages.map((message: ChatMessageInfo) => (
const isSent = message.role === "user"; <QuickChatMessageItem
const forcePlain = !isSent && plainTextMessageIds.has(message.id); key={message.id}
return ( message={message}
<div forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
key={message.id} mentionAgentsByName={mentionAgentsByName}
className={`quick-chat-panel-message ${isSent ? "quick-chat-panel-message--sent" : "quick-chat-panel-message--received"}`} onToggleRender={toggleMessageRenderMode}
data-testid={`quick-chat-message-${message.id}`} />
> ))}
{isSent
? <p>{renderMessageContent(message.content)}</p>
: (
<>
{renderAssistantMessageContent(message.content, forcePlain)}
<button
type="button"
className={`quick-chat-message-render-toggle${forcePlain ? " quick-chat-message-render-toggle--plain" : ""}`}
data-testid="quick-chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</>
)}
{renderToolCalls(message.toolCalls, true)}
</div>
);
})}
{/* Streaming message bubble */} {/* Streaming message bubble */}
{isStreaming && ( {isStreaming && (
<div <div

View File

@@ -213,4 +213,76 @@ describe("useMobileKeyboard", () => {
expect(result.current.viewportHeight).toBe(520); 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 streamRef = useRef<{ close: () => void } | null>(null);
const cancelledByUserRef = useRef(false); const cancelledByUserRef = useRef(false);
const pendingMessageRef = useRef(""); 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 // Refs for SSE event handlers to access current state
const sessionsRef = useRef(sessions); const sessionsRef = useRef(sessions);
@@ -343,8 +347,10 @@ export function useChat(projectId?: string): UseChatReturn {
updatedAt: data.session.updatedAt, updatedAt: data.session.updatedAt,
}; };
// Add to sessions list at the top setSessions((prev) => {
setSessions((prev) => [newSession, ...prev]); if (prev.some((s) => s.id === newSession.id)) return prev;
return [newSession, ...prev];
});
selectSession(newSession.id, newSession); selectSession(newSession.id, newSession);
setMessages([]); setMessages([]);
@@ -400,6 +406,8 @@ export function useChat(projectId?: string): UseChatReturn {
if (!activeSession) return; if (!activeSession) return;
cancelledByUserRef.current = true; cancelledByUserRef.current = true;
cancelStreamingFlushesRef.current?.();
cancelStreamingFlushesRef.current = null;
streamRef.current?.close(); streamRef.current?.close();
streamRef.current = null; streamRef.current = null;
@@ -463,14 +471,44 @@ export function useChat(projectId?: string): UseChatReturn {
let capturedThinking = ""; let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = []; 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 = { const textHandlers = {
onThinking: (data: string) => { onThinking: (data: string) => {
capturedThinking += data; capturedThinking += data;
setStreamingThinking(capturedThinking); if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
}, },
onText: (data: string) => { onText: (data: string) => {
capturedText += data; capturedText += data;
setStreamingText(capturedText); if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
}, },
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => { onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
capturedToolCalls = [ capturedToolCalls = [
@@ -513,6 +551,7 @@ export function useChat(projectId?: string): UseChatReturn {
setStreamingToolCalls(capturedToolCalls); setStreamingToolCalls(capturedToolCalls);
}, },
onDone: (data: { messageId: string }) => { onDone: (data: { messageId: string }) => {
cancelStreamingFlushes();
const assistantMessage: ChatMessageInfo = { const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`, id: data.messageId || `msg-${Date.now()}`,
sessionId: activeSession.id, sessionId: activeSession.id,
@@ -550,6 +589,7 @@ export function useChat(projectId?: string): UseChatReturn {
} }
}, },
onError: (data: string) => { onError: (data: string) => {
cancelStreamingFlushes();
setMessages((prev) => prev.filter((m) => m.id !== tempId)); setMessages((prev) => prev.filter((m) => m.id !== tempId));
setStreamingText(""); setStreamingText("");
setStreamingThinking(""); setStreamingThinking("");

View File

@@ -1,5 +1,8 @@
import { useEffect, useState } from "react"; 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). */ /** Whether the current device is likely mobile (touch-primary, small viewport). */
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -31,6 +34,16 @@ function getInitialViewportHeight(): number {
* - Fallback: initial viewport height - vv.height - vv.offsetTop * - Fallback: initial viewport height - vv.height - vv.offsetTop
* Works on iOS Safari where window.innerHeight shrinks with the keyboard. * 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 { function getKeyboardOverlap(): number {
if (typeof window === "undefined" || !window.visualViewport) return 0; if (typeof window === "undefined" || !window.visualViewport) return 0;
const vv = window.visualViewport; const vv = window.visualViewport;
@@ -38,8 +51,17 @@ function getKeyboardOverlap(): number {
if (chromeOverlap > 0) return chromeOverlap; if (chromeOverlap > 0) return chromeOverlap;
const initialHeight = getInitialViewportHeight(); const initialHeight = getInitialViewportHeight();
const gap = initialHeight - vv.offsetTop - vv.height; const gap = Math.max(0, initialHeight - vv.offsetTop - vv.height);
return gap >= 30 && gap > 80 ? gap : 0;
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. */ /** Reset cached initial viewport height. Exported for tests only. */

View File

@@ -166,6 +166,7 @@ export function useQuickChat(
// Stream connection ref for cleanup // Stream connection ref for cleanup
const streamRef = useRef<{ close: () => void } | null>(null); const streamRef = useRef<{ close: () => void } | null>(null);
const cancelledByUserRef = useRef(false); const cancelledByUserRef = useRef(false);
const cancelStreamingFlushesRef = useRef<(() => void) | null>(null);
const pendingMessageRef = useRef(""); const pendingMessageRef = useRef("");
const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null); const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null);
@@ -356,6 +357,8 @@ export function useQuickChat(
if (!activeSession) return; if (!activeSession) return;
cancelledByUserRef.current = true; cancelledByUserRef.current = true;
cancelStreamingFlushesRef.current?.();
cancelStreamingFlushesRef.current = null;
streamRef.current?.close(); streamRef.current?.close();
streamRef.current = null; streamRef.current = null;
@@ -429,14 +432,42 @@ export function useQuickChat(
let capturedThinking = ""; let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = []; 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 = { const textHandlers = {
onThinking: (data: string) => { onThinking: (data: string) => {
capturedThinking += data; capturedThinking += data;
setStreamingThinking(capturedThinking); if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
}, },
onText: (data: string) => { onText: (data: string) => {
capturedText += data; capturedText += data;
setStreamingText(capturedText); if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
}, },
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => { onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
capturedToolCalls = [ capturedToolCalls = [
@@ -479,6 +510,7 @@ export function useQuickChat(
setStreamingToolCalls(capturedToolCalls); setStreamingToolCalls(capturedToolCalls);
}, },
onDone: (data: { messageId: string }) => { onDone: (data: { messageId: string }) => {
cancelStreamingFlushes();
const assistantMessage: ChatMessageInfo = { const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`, id: data.messageId || `msg-${Date.now()}`,
sessionId: activeSession.id, sessionId: activeSession.id,
@@ -508,6 +540,7 @@ export function useQuickChat(
} }
}, },
onError: (data: string) => { onError: (data: string) => {
cancelStreamingFlushes();
setStreamingText(""); setStreamingText("");
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);

View File

@@ -156,7 +156,7 @@ function stripTaskListHeavyFields<T>(task: T): T {
? existingTimed ? existingTimed
: sumTimedLogEntries(candidate.log); : sumTimedLogEntries(candidate.log);
return { ...task, log: [], timedExecutionMs } as T; return { ...task, log: [], timedExecutionMs, tokenUsage: candidate.tokenUsage, workflowStepResults: candidate.workflowStepResults } as T;
} }
function sumTimedLogEntries(log: unknown): number { function sumTimedLogEntries(log: unknown): number {

View File

@@ -9,8 +9,8 @@ import { getModels } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { streamViaCli } from "./src/provider.js"; import { streamViaCli } from "./src/provider.js";
import { import {
validateCliPresence, validateCliPresenceAsync,
validateCliAuth, validateCliAuthAsync,
killAllProcesses, killAllProcesses,
} from "./src/process-manager.js"; } from "./src/process-manager.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -26,6 +26,31 @@ process.on("exit", killAllProcesses);
const PROVIDER_ID = "pi-claude-cli"; const PROVIDER_ID = "pi-claude-cli";
/**
* Run CLI presence + auth probes at most once per process, asynchronously.
*
* The factory below is invoked on every `createFnAgent` call (the dashboard
* does this per chat message). Doing the probes synchronously with execSync
* froze the entire Node event loop for a few seconds while `claude` cold-
* started. Memoizing as a Promise + spawning the probes async means the
* factory returns immediately and other requests keep flowing; the result
* is logged once on first run and reused thereafter.
*/
let cliValidationPromise: Promise<void> | undefined;
function runCliValidationOnce(): Promise<void> {
if (cliValidationPromise) return cliValidationPromise;
cliValidationPromise = (async () => {
const presence = await validateCliPresenceAsync();
if (!presence.ok) {
console.warn(`[pi-claude-cli] ${presence.error.message}`);
return;
}
await validateCliAuthAsync();
})();
return cliValidationPromise;
}
let cachedMcpConfig: { hash: string; configPath: string } | undefined; let cachedMcpConfig: { hash: string; configPath: string } | undefined;
const DEBUG_MCP = process.env.PI_CLAUDE_CLI_DEBUG === "1"; const DEBUG_MCP = process.env.PI_CLAUDE_CLI_DEBUG === "1";
@@ -116,9 +141,10 @@ function ensureMcpConfig(
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
try { try {
// Startup validation // Startup validation: kick off async, memoized presence + auth probes
validateCliPresence(); // throws if CLI not on PATH // without blocking the factory. Failures surface via warnings; the actual
validateCliAuth(); // warns if not authenticated // `claude` subprocess in streamViaCli still reports hard errors on send.
void runCliValidationOnce();
const catalogModels = getModels("anthropic").map((model) => ({ const catalogModels = getModels("anthropic").map((model) => ({
id: model.id, id: model.id,

View File

@@ -168,10 +168,10 @@ describe("provider registration (default export)", () => {
}); });
}); });
describe("streamViaCli", () => { describe("streamViaCli", { timeout: 90_000 }, () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useFakeTimers(); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
delete process.env.PI_CLAUDE_CLI_DEBUG; delete process.env.PI_CLAUDE_CLI_DEBUG;
@@ -183,7 +183,7 @@ describe("streamViaCli", () => {
delete process.env.PI_CLAUDE_CLI_DEBUG; delete process.env.PI_CLAUDE_CLI_DEBUG;
}); });
it("returns an AssistantMessageEventStream", () => { it("returns an AssistantMessageEventStream", async () => {
const model = mockModels[0] as any; const model = mockModels[0] as any;
const context = { const context = {
messages: [{ role: "user", content: "Hello" }], messages: [{ role: "user", content: "Hello" }],
@@ -194,6 +194,16 @@ describe("streamViaCli", () => {
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.push).toBeDefined(); expect(result.push).toBeDefined();
expect(result.end).toBeDefined(); expect(result.end).toBeDefined();
// Ensure the spawned process/readline lifecycle completes so fake timers
// don't leave the test hanging on the inactivity timeout.
await vi.advanceTimersByTimeAsync(0);
const proc = (spawn as any).mock.results[0].value;
proc.stdout.write(
`${JSON.stringify({ type: "result", subtype: "success", result: "ok" })}\n`,
);
proc.stdout.end();
await vi.advanceTimersByTimeAsync(0);
}); });
it("logs PID and spawn args when debug mode is enabled", async () => { it("logs PID and spawn args when debug mode is enabled", async () => {

View File

@@ -241,3 +241,68 @@ export function validateCliAuth(): boolean {
return false; return false;
} }
} }
/**
* Run a one-shot `claude <args>` and resolve to the exit code.
*
* Why: the sync execSync variants block the Node event loop for the duration
* of a Claude CLI cold start (13s, occasionally longer). When pi-claude-cli's
* factory is invoked from a per-request createFnAgent path (Fusion dashboard
* does this on every chat send), those sync probes freeze every other request.
* This async variant uses spawn so the loop keeps turning while the subprocess
* starts up.
*/
function runClaudeProbe(args: string[], timeoutMs = 5000): Promise<number> {
return new Promise((resolve) => {
const proc = spawn("claude", args, { stdio: "ignore" });
const timer = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// already dead
}
resolve(124);
}, timeoutMs);
proc.once("error", () => {
clearTimeout(timer);
resolve(127);
});
proc.once("exit", (code) => {
clearTimeout(timer);
resolve(code ?? 1);
});
});
}
/**
* Async, non-blocking variant of validateCliPresence.
* Resolves with `{ok: true}` on success, `{ok: false, error}` on failure —
* never rejects, so callers can fire-and-forget without unhandled rejections.
*/
export async function validateCliPresenceAsync(): Promise<
{ ok: true } | { ok: false; error: Error }
> {
const code = await runClaudeProbe(["--version"]);
if (code === 0) return { ok: true };
return {
ok: false,
error: new Error(
"Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code\n" +
"Then authenticate: claude auth login",
),
};
}
/**
* Async, non-blocking variant of validateCliAuth.
* Returns true if authenticated. Logs a warning (does not throw) otherwise.
*/
export async function validateCliAuthAsync(): Promise<boolean> {
const code = await runClaudeProbe(["auth", "status"]);
if (code === 0) return true;
console.warn(
"[pi-claude-cli] Claude CLI is not authenticated. " +
"Run 'claude auth login' to authenticate.",
);
return false;
}