Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.factory
.factory

6 changes: 6 additions & 0 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
chatInputRef?.current?.insertIfEmpty(content);
}, [chatInputRef]);

/** Insert a quoted reply into the input box (user decides how to send it). */
const handleQuoteReply = useCallback((quote: string) => {
chatInputRef?.current?.prependText(quote);
}, [chatInputRef]);

const {
loading, error, messages, entryIds, streamState,
agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel,
Expand Down Expand Up @@ -584,6 +589,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
onNavigate={sessionBusy ? undefined : handleNavigate}
prevAssistantEntryId={sessionBusy ? undefined : prevAssistantEntryId}
onEditContent={handleEditContent}
onQuoteReply={handleQuoteReply}
showTimestamp={showTimestamp}
prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined}
sessionId={session?.id ?? sessionIdRef.current ?? undefined}
Expand Down
198 changes: 185 additions & 13 deletions components/MarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
"use client";

import { useMemo, type MouseEvent } from "react";
import { createContext, useContext, useEffect, useId, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { resolveLocalFileHref } from "@/lib/file-links";
import { encodeFilePathForApi } from "@/lib/file-paths";
import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
import { MermaidBlock, CodeBlock } from "./MermaidBlock";
import { QuoteReplyPopover } from "./QuoteReplyPopover";
import { useI18n } from "@/hooks/useI18n";
import { parseParagraph, type ParsedSegment } from "@/lib/quote-reply";

interface MarkdownBodyProps {
children: string;
className?: string;
isStreaming?: boolean;
cwd?: string;
onOpenFile?: (filePath: string) => void;
onOpenFile?: (filePath: string, fileName?: string) => void;
/** When set (assistant messages), each paragraph becomes hoverable/clickable
* to pop a quote-reply popover. */
onQuoteReply?: (quote: string) => void;
}

export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) {
/** Exactly one quote-reply popover can be open at a time (per message body). */
const QuoteOpenContext = createContext<{ openId: string | null; setOpenId: (id: string | null) => void }>({
openId: null,
setOpenId: () => {},
});

export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile, onQuoteReply }: MarkdownBodyProps) {
const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]);
const [openId, setOpenId] = useState<string | null>(null);
// Stable renderer identities keep stateful blocks mounted across message hover updates.
const components = useMemo<Components>(() => ({
code({ className, children, ...props }) {
Expand Down Expand Up @@ -79,24 +92,183 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
// eslint-disable-next-line @next/next/no-img-element
return <img src={imageSrc} alt={alt ?? ""} loading="lazy" {...props} />;
},
p({ children, ...props }) {
delete props.node;
const pid = useId();
if (!onQuoteReply) return <p {...props}>{children}</p>;
return (
<QuoteableParagraph pid={pid} onQuoteReply={onQuoteReply} onOpenFile={onOpenFile} cwd={cwd}>
{children}
</QuoteableParagraph>
);
},
li({ children, ...props }) {
delete props.node;
const pid = useId();
if (!onQuoteReply) return <li {...props}>{children}</li>;
return (
<QuoteableParagraph as="li" pid={pid} onQuoteReply={onQuoteReply} onOpenFile={onOpenFile} cwd={cwd}>
{children}
</QuoteableParagraph>
);
},
table({ children }) {
return (
<div className="markdown-table-wrap">
<table>{children}</table>
</div>
);
},
}), [cwd, isStreaming, onOpenFile]);
tr({ children, ...props }) {
delete props.node;
const pid = useId();
if (!onQuoteReply) return <tr {...props}>{children}</tr>;
return (
<QuoteableParagraph as="tr" pid={pid} onQuoteReply={onQuoteReply} onOpenFile={onOpenFile} cwd={cwd}>
{children}
</QuoteableParagraph>
);
},
}), [cwd, isStreaming, onOpenFile, onQuoteReply]);

return (
<QuoteOpenContext.Provider value={{ openId, setOpenId }}>
<div className={["markdown-body", className].filter(Boolean).join(" ")}>
<ReactMarkdown
remarkPlugins={markdownRemarkPlugins}
rehypePlugins={markdownRehypePlugins}
components={components}
>
{normalizedMarkdown}
</ReactMarkdown>
</div>
</QuoteOpenContext.Provider>
);
}

/** A <p>/<li> whose plain text is parsed on hover (desktop) / click (mobile)
* to pop a quote-reply popover. The parse result is locked once shown so a
* streaming tail doesn't make the popover flicker; re-engaging re-parses. */
/** Plain text of a quoteable element, excluding transient UI children (the
* follow-mouse tooltip) so the quoted reply isn't polluted. */
function getQuoteText(el: HTMLElement | null): string {
if (!el) return "";
const clone = el.cloneNode(true) as HTMLElement;
clone.querySelectorAll("[data-quote-tip]").forEach((n) => n.remove());
return clone.textContent ?? "";
}

function QuoteableParagraph({ children, onQuoteReply, onOpenFile, cwd, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; onOpenFile?: (filePath: string, fileName?: string) => void; cwd?: string; as?: "p" | "li" | "tr"; pid: string }) {
const { openId, setOpenId } = useContext(QuoteOpenContext);
const { t } = useI18n();
const open = openId === pid;
const ref = useRef<HTMLElement>(null);
const [segments, setSegments] = useState<ParsedSegment[] | null>(null);
const [showTip, setShowTip] = useState(false);
const tipRef = useRef<HTMLSpanElement>(null);
// Detect touch capability lazily (same approach as useIsMobile but local).
const [coarse] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches);

// Another paragraph opened its popover → close ours (only one popover at a time).
useEffect(() => {
if (!open && segments) setSegments(null);
}, [open, segments]);

// When the popover opens, ensure it's in view (the paragraph near the
// bottom of the viewport would otherwise push it out of sight).
const popoverRef = useRef<HTMLElement>(null);
useEffect(() => {
if (segments && popoverRef.current) {
popoverRef.current.scrollIntoView({ block: "nearest" });
}
}, [segments]);

const openPopover = () => {
if (segments || open) return;
// For table rows, join cell text with " | " so the quoted line reads like
// a markdown row instead of all cells mashed together.
const el = ref.current;
const text = as === "tr" && el
? Array.from(el.querySelectorAll("td, th")).map((c) => (c.textContent ?? "").trim()).join(" | ")
: getQuoteText(el);
// Any paragraph is quoteable (not just questions): closed questions get
// option buttons, everything else gets a fallback quote button.
const parsed = parseParagraph(text);
if (parsed.length > 0) {
setSegments(parsed);
setOpenId(pid);
}
};
const closePopover = () => {
setSegments(null);
setOpenId(null);
};
// Click toggles: show on first click, hide on the second.
const toggle = () => {
if (segments) closePopover();
else openPopover();
};

const Tag = as as React.ElementType;
// Follow-the-mouse tooltip: position updated imperatively on mousemove (no
// re-render per move); mouseenter sets it via rAF so it shows even if the
// pointer doesn't move afterwards.
const moveTip = (x: number, y: number) => {
if (tipRef.current) {
tipRef.current.style.left = `${x + 12}px`;
tipRef.current.style.top = `${y + 14}px`;
}
};
const showTooltip = (e: MouseEvent<HTMLElement>) => {
if (coarse) return;
setShowTip(true);
const { clientX, clientY } = e;
requestAnimationFrame(() => moveTip(clientX, clientY));
};
const hideTooltip = () => {
setShowTip(false);
};
return (
<div className={["markdown-body", className].filter(Boolean).join(" ")}>
<ReactMarkdown
remarkPlugins={markdownRemarkPlugins}
rehypePlugins={markdownRehypePlugins}
components={components}
>
{normalizedMarkdown}
</ReactMarkdown>
</div>
<Tag
ref={ref}
onMouseEnter={showTooltip}
onMouseMove={coarse ? undefined : (e: MouseEvent<HTMLElement>) => moveTip(e.clientX, e.clientY)}
onMouseLeave={hideTooltip}
onClick={toggle}
style={{ position: "relative", cursor: "pointer" }}
>
{children}
{showTip && !segments && (
<span
ref={tipRef}
data-quote-tip
style={{
position: "fixed",
left: -9999,
top: -9999,
fontSize: 11,
color: "var(--text-dim)",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "2px 6px",
pointerEvents: "none",
zIndex: 50,
whiteSpace: "nowrap",
}}
>
{t("chat.quoteReplyHint")}
</span>
)}
{segments && (
<QuoteReplyPopover
innerRef={popoverRef}
segments={segments}
onPick={(q) => { onQuoteReply(q); closePopover(); }}
onOpenFile={onOpenFile}
cwd={cwd}
/>
)}
</Tag>
);
}
17 changes: 10 additions & 7 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function loadThinkingContent(sessionId: string, entryId: string, blockIndex: num
}

interface Props {
onQuoteReply?: (quote: string) => void;
message: AgentMessage;
isStreaming?: boolean;
toolResults?: Map<string, ToolResultMessage>;
Expand Down Expand Up @@ -98,12 +99,12 @@ function haveSameRelevantToolResults(
return true;
}

export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) {
export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onQuoteReply, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) {
if (message.role === "user") {
return <UserMessageView message={message as UserMessage} cwd={cwd} onOpenFile={onOpenFile} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />;
}
if (message.role === "assistant") {
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />;
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} onQuoteReply={onQuoteReply} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />;
}
if (message.role === "toolResult") {
// Rendered inline under its toolCall — skip standalone rendering if paired
Expand Down Expand Up @@ -345,6 +346,7 @@ function AssistantMessageView({
modelNames,
cwd,
onOpenFile,
onQuoteReply,
showTimestamp,
prevTimestamp,
sessionId,
Expand All @@ -356,6 +358,7 @@ function AssistantMessageView({
modelNames?: Record<string, string>;
cwd?: string;
onOpenFile?: (filePath: string) => void;
onQuoteReply?: (quote: string) => void;
showTimestamp?: boolean;
prevTimestamp?: number;
sessionId?: string;
Expand Down Expand Up @@ -529,7 +532,7 @@ function AssistantMessageView({

<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{blockItems.map(({ block, originalIndex }) => (
<BlockView key={`${entryId ?? "stream"}-${originalIndex}`} block={block} toolResults={toolResults} isStreaming={isStreaming} streamingDuration={streamingDurations.get(originalIndex) ?? (block.type === "thinking" ? thinkingDurationFromFile : undefined)} toolCallDurations={toolCallDurations} cwd={cwd} onOpenFile={onOpenFile} sessionId={sessionId} entryId={entryId} blockIndex={originalIndex} />
<BlockView key={`${entryId ?? "stream"}-${originalIndex}`} block={block} toolResults={toolResults} isStreaming={isStreaming} streamingDuration={streamingDurations.get(originalIndex) ?? (block.type === "thinking" ? thinkingDurationFromFile : undefined)} toolCallDurations={toolCallDurations} cwd={cwd} onOpenFile={onOpenFile} onQuoteReply={onQuoteReply} sessionId={sessionId} entryId={entryId} blockIndex={originalIndex} />
))}
</div>

Expand Down Expand Up @@ -603,9 +606,9 @@ function AssistantMessageView({
);
}

function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage>; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map<string, number>; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) {
function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, onQuoteReply, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage>; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map<string, number>; cwd?: string; onOpenFile?: (filePath: string) => void; onQuoteReply?: (quote: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) {
if (block.type === "text") {
return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} />;
return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} onQuoteReply={onQuoteReply} />;
}
if (block.type === "thinking") {
return <ThinkingBlock block={block as ThinkingContent} duration={streamingDuration} sessionId={sessionId} entryId={entryId} blockIndex={blockIndex} />;
Expand All @@ -619,8 +622,8 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal
return null;
}

function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) {
return <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</MarkdownBody>;
function TextBlock({ block, isStreaming, cwd, onOpenFile, onQuoteReply }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; onQuoteReply?: (quote: string) => void }) {
return <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} onQuoteReply={onQuoteReply}>{block.text}</MarkdownBody>;
}

function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
Expand Down
Loading