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/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Changesets are committed with published package changes.
|
|
||||||
!.changeset/
|
|
||||||
!.changeset/*.md
|
|
||||||
!.changeset/*.json
|
|
||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 (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