chore: remove changeset negation rules from .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-mobile-chat-keyboard-overlap.md
Normal file
5
.changeset/fix-mobile-chat-keyboard-overlap.md
Normal 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
5
.gitignore
vendored
@@ -10,11 +10,6 @@ node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Changesets are committed with published package changes.
|
||||
!.changeset/
|
||||
!.changeset/*.md
|
||||
!.changeset/*.json
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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.
|
||||
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 remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
File,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useChat, type ToolCallInfo } from "../hooks/useChat";
|
||||
import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
|
||||
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) {
|
||||
const {
|
||||
activeSession,
|
||||
@@ -888,109 +1040,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
[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
|
||||
const handleInputKeyDown = useCallback(
|
||||
@@ -1464,47 +1513,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => {
|
||||
const isAssistantMessage = message.role === "assistant";
|
||||
const forcePlain = plainTextMessageIds.has(message.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
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 && <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>
|
||||
);
|
||||
})}
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={plainTextMessageIds.has(message.id)}
|
||||
agentName={agentName}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
<div className="chat-message-avatar">
|
||||
|
||||
@@ -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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./QuickChatFAB.css";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
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({
|
||||
projectId,
|
||||
addToast,
|
||||
@@ -1327,49 +1414,6 @@ export function QuickChatFAB({
|
||||
[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) => {
|
||||
setPlainTextMessageIds((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>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message: ChatMessageInfo) => {
|
||||
const isSent = message.role === "user";
|
||||
const forcePlain = !isSent && plainTextMessageIds.has(message.id);
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
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>{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>
|
||||
);
|
||||
})}
|
||||
{messages.map((message: ChatMessageInfo) => (
|
||||
<QuickChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
{/* Streaming message bubble */}
|
||||
{isStreaming && (
|
||||
<div
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -156,7 +156,7 @@ function stripTaskListHeavyFields<T>(task: T): T {
|
||||
? existingTimed
|
||||
: 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 {
|
||||
|
||||
@@ -9,8 +9,8 @@ import { getModels } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { streamViaCli } from "./src/provider.js";
|
||||
import {
|
||||
validateCliPresence,
|
||||
validateCliAuth,
|
||||
validateCliPresenceAsync,
|
||||
validateCliAuthAsync,
|
||||
killAllProcesses,
|
||||
} from "./src/process-manager.js";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -26,6 +26,31 @@ process.on("exit", killAllProcesses);
|
||||
|
||||
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;
|
||||
const DEBUG_MCP = process.env.PI_CLAUDE_CLI_DEBUG === "1";
|
||||
|
||||
@@ -116,9 +141,10 @@ function ensureMcpConfig(
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
try {
|
||||
// Startup validation
|
||||
validateCliPresence(); // throws if CLI not on PATH
|
||||
validateCliAuth(); // warns if not authenticated
|
||||
// Startup validation: kick off async, memoized presence + auth probes
|
||||
// without blocking the factory. Failures surface via warnings; the actual
|
||||
// `claude` subprocess in streamViaCli still reports hard errors on send.
|
||||
void runCliValidationOnce();
|
||||
|
||||
const catalogModels = getModels("anthropic").map((model) => ({
|
||||
id: model.id,
|
||||
|
||||
@@ -168,10 +168,10 @@ describe("provider registration (default export)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamViaCli", () => {
|
||||
describe("streamViaCli", { timeout: 90_000 }, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
delete process.env.PI_CLAUDE_CLI_DEBUG;
|
||||
@@ -183,7 +183,7 @@ describe("streamViaCli", () => {
|
||||
delete process.env.PI_CLAUDE_CLI_DEBUG;
|
||||
});
|
||||
|
||||
it("returns an AssistantMessageEventStream", () => {
|
||||
it("returns an AssistantMessageEventStream", async () => {
|
||||
const model = mockModels[0] as any;
|
||||
const context = {
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
@@ -194,6 +194,16 @@ describe("streamViaCli", () => {
|
||||
expect(result).toBeDefined();
|
||||
expect(result.push).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 () => {
|
||||
|
||||
@@ -241,3 +241,68 @@ export function validateCliAuth(): boolean {
|
||||
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 (1–3s, 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user