源码
packages/ui/src/components/Conversation.tsx
import { buildMentionMarkdown, imageLimits, parseMentions } from "@zpi-dev/coding-agent/input";
import {
ArrowDown,
ArrowUp,
Bot,
Brain,
ChevronRight,
FileClock,
GitBranch,
Hand,
Info,
Loader,
MessagesSquare,
Paperclip,
Pencil,
Plus,
Square,
TrendingUpDown,
WandSparkles,
X,
} from "lucide-react";
import type { ReactNode } from "react";
import {
Fragment,
memo,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { conversationQueries, conversationRunPartition } from "../conversation-run-partition.ts";
import {
buildSelectionPrompt,
type ConversationSelection,
parseSelectionPrompt,
} from "../conversation-selections.ts";
import {
type FindMatch,
type FindRequest,
type FindState,
type FindTarget,
normalizeFindQuery,
} from "../find.ts";
import {
type FileLocation,
type FileOpenHandler,
type LinkContext,
splitFileLocation,
type WebOpenOptions,
} from "../link-target.ts";
import type { PreviewServices } from "../preview/preview-lifecycle.ts";
import { type ReadingBookmark, readingAnchor, readReadingBookmark } from "../reading-position.ts";
import { progressSummary } from "../reducer.ts";
import type {
CompactionView,
FileActionHandler,
FileRewindConflict,
InputQueue,
InputSuggestion,
RunView,
SessionMentionSuggestion,
SessionView,
SubagentTask,
ViewBlock,
} from "../types.ts";
import { attachmentLimitMessage, userErrorMessage } from "../user-error.ts";
import { AnswerSources } from "./AnswerSources.tsx";
import { AssistantTurnPreviews, usePreviewAutoOpen } from "./AssistantPreviewCards.tsx";
import { collectAnswerSources } from "./answer-sources.ts";
import { ChangedFiles } from "./ChangedFiles.tsx";
import { CompactionDivider } from "./CompactionDivider.tsx";
import { ConversationHistory, historyChunkForRun, historyWindowThreshold } from "./ConversationHistory.tsx";
import { ConversationNavigator } from "./ConversationNavigator.tsx";
import { ConversationQueuePanel, type QueueActions } from "./ConversationQueuePanel.tsx";
import { ConversationSelectionMenu, SelectionReferenceChip } from "./ConversationSelections.tsx";
import {
customCommandNames,
slashSuggestionLabel,
slashSuggestions,
slashTokenEnd,
subagentNames,
} from "./command-suggestions.ts";
import { useConversationFindTargets } from "./conversation-find.ts";
import { DraftGreeting } from "./DraftGreeting.tsx";
import { FileRewindConflictDialog } from "./FileRewindConflictDialog.tsx";
import { fileMentionQuery } from "./file-mention-query.ts";
import {
AttachmentPreview,
type ComposerContext,
type ComposerDraft,
ComposerDraftStore,
} from "./InputContext.tsx";
import { InputNotice } from "./InputNotice.tsx";
import { Markdown } from "./Markdown.tsx";
import { MentionEditor, type MentionEditorHandle } from "./MentionEditor.tsx";
import { ActionHint, CopyMessage, MessageAction, messageTime } from "./MessageActions.tsx";
import { ModelWaitNotice } from "./ModelWaitNotice.tsx";
import { Notice } from "./Notice.tsx";
import { reasoningDuration, reasoningSummary, runPresentation } from "./process-presentation.ts";
import { appendPromptHistory, readPromptHistory, savePromptHistory } from "./prompt-history.ts";
import { FileIcon, messageReferences, Reference } from "./Reference.tsx";
import { StreamEntrance } from "./StreamEntrance.tsx";
import { SubagentContext } from "./SubagentToolBlock.tsx";
import { SuggestionOptions } from "./SuggestionOptions.tsx";
import { filterSessionSuggestions } from "./session-suggestions.ts";
import { filterSkillSuggestions, filterSubagentSuggestions } from "./skill-suggestions.ts";
import { ToolBlock } from "./ToolBlock.tsx";
import { ToolGroup } from "./ToolGroup.tsx";
import { type ProcessItem, processItems } from "./tool-presentation.ts";
import { UserMessageBody } from "./UserMessageBody.tsx";
import { useTextFind } from "./use-text-find.ts";
export interface MessageEditInput {
workspaceMode?: "preserve" | "rewind";
text: string;
fileReferences: string[];
attachments: string[];
}
export type EditMessage = (
runId: string,
input: MessageEditInput,
) => Promise<undefined | { conflicts: FileRewindConflict[] }>;
function UserMessageEditor({
run,
sessionId,
context,
onEdit,
onCancel,
suggestions,
onFile,
}: {
run: RunView;
sessionId: string;
context?: ComposerContext;
onEdit: EditMessage;
onCancel: () => void;
suggestions?: InputSuggestion[];
onFile?: FileOpenHandler;
}) {
const storage = useRef(new ComposerDraftStore());
const [conflict, setConflict] = useState<{ files: FileRewindConflict[]; input: MessageEditInput }>();
const [resolving, setResolving] = useState(false);
const report = (error: unknown) => {
const draft = storage.current.get(sessionId);
if (draft) storage.current.set(sessionId, { ...draft, error: userErrorMessage(error) });
};
const parsed = parseSelectionPrompt(run.userMessage);
if (!storage.current.has(sessionId))
storage.current.set(sessionId, {
text: parsed.text,
selections: parsed.selections,
fileReferences: run.fileReferences ?? [],
attachments: run.attachments ?? [],
pending: 0,
});
return (
<section className="user-message-editor" aria-label="编辑消息">
<ChatComposer
sessionId={sessionId}
busy={false}
draftStorage={storage.current}
context={context}
suggestions={suggestions}
onFile={onFile}
showSendButton
onStop={() => {}}
onCancel={onCancel}
resetFiles={{
disabled:
run.status === "running" ||
!run.orderedBlocks.some((block) => block.type === "tool" && block.fileChange),
description:
run.status === "running"
? "请等待当前工作停止"
: run.orderedBlocks.some((block) => block.type === "tool" && block.fileChange)
? "恢复本轮文件、重置对话并发送"
: "本轮没有可安全恢复的文件改动",
}}
onSubmit={async (text, input) => {
try {
const edit: MessageEditInput = {
text,
fileReferences: input.fileReferences,
attachments: input.attachments,
...(input.workspaceMode ? { workspaceMode: input.workspaceMode } : {}),
};
const result = await onEdit(run.runId, edit);
if (result?.conflicts) {
setConflict({ files: result.conflicts, input: edit });
return "blocked";
}
onCancel();
} catch (error) {
report(error);
throw error;
}
}}
/>
{conflict && (
<FileRewindConflictDialog
conflicts={conflict.files}
pending={resolving}
onClose={() => setConflict(undefined)}
onContinue={() => {
setResolving(true);
void onEdit(run.runId, { ...conflict.input, workspaceMode: "preserve" })
.then(onCancel)
.catch(report)
.finally(() => setResolving(false));
}}
/>
)}
</section>
);
}
function ProcessBlock({
block,
expanded,
toggle,
onLink,
workspace,
onCopy,
onFile,
onFileAction,
onImage,
onDownloadImage,
onChanges,
onLoadPatch,
}: {
onImage?: (path: string, location?: FileLocation) => Promise<string>;
onDownloadImage?: (src: string) => Promise<void>;
onChanges?: (path: string, toolCallId: string) => void;
onLoadPatch?: (toolCallId: string) => Promise<string>;
block: ViewBlock;
onFile?: FileOpenHandler;
onFileAction?: FileActionHandler;
expanded: boolean;
toggle: () => void;
workspace?: LinkContext;
onLink: (url: string, options?: WebOpenOptions) => void;
onCopy?: (text: string) => Promise<void>;
}) {
const summary = useRef<HTMLSpanElement>(null);
const streaming = block.type === "thinking" && Boolean(block.streaming);
const streamingLabel = streaming && !expanded;
// 对齐 ZCode ReasoningTrigger:末行预览只属于运行态,完成态显示耗时。
const thinkingSummary = block.type === "thinking" && streamingLabel ? reasoningSummary(block.text) : "";
const [summaryOverflowing, setSummaryOverflowing] = useState(false);
const [reasoningNow, setReasoningNow] = useState(Date.now);
useLayoutEffect(() => {
const viewport = summary.current;
if (!viewport || !thinkingSummary) return;
const sync = () => {
setSummaryOverflowing(viewport.scrollWidth > viewport.clientWidth + 1);
viewport.scrollLeft = viewport.scrollWidth;
};
sync();
const observer = new ResizeObserver(sync);
observer.observe(viewport);
return () => observer.disconnect();
}, [thinkingSummary]);
useEffect(() => {
// 收起时耗时不可见,和 ZCode 一样只在展开流式思考时每秒更新。
if (!streaming || !expanded) return;
setReasoningNow(Date.now());
const timer = setInterval(() => setReasoningNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [streaming, expanded]);
if (block.type === "tool")
return (
<ToolBlock
block={block}
expanded={expanded}
toggle={toggle}
workspace={workspace}
onCopy={onCopy}
onFile={onFile}
onChanges={onChanges}
onLoadPatch={onLoadPatch}
onLink={onLink}
/>
);
if (block.type === "thinking") {
const duration =
block.startedAt !== undefined && (streaming || block.endedAt !== undefined)
? reasoningDuration((block.endedAt ?? reasoningNow) - block.startedAt)
: reasoningDuration();
return (
<div className="process-thinking" data-testid="thinking-block">
<button className="block-toggle" aria-expanded={expanded} onClick={toggle}>
<Brain size={16} aria-hidden="true" className="process-icon" />
<strong className={streamingLabel ? "thinking-label-streaming" : undefined}>
{block.interrupted ? "思考已中断" : streamingLabel ? "正在思考" : "思考"}
</strong>
{(thinkingSummary || !streamingLabel) && <span className="process-separator">·</span>}
{thinkingSummary && (
<span
ref={summary}
className="block-summary thinking-summary"
data-overflowing={summaryOverflowing}
>
{thinkingSummary}
</span>
)}
{!streamingLabel && <span className="reasoning-duration">{duration}</span>}
<ChevronRight
size={16}
aria-hidden="true"
className={`process-chevron ${expanded ? "rotated" : ""}`}
/>
</button>
{expanded && (
<pre
className="thinking-body"
data-conversation-selectable="reasoning"
data-selection-key={block.id}
>
{block.text}
</pre>
)}
</div>
);
}
const content = (
<div
className="process-text answer"
data-find-key={block.id}
data-conversation-selectable="assistant"
data-selection-key={block.id}
>
<Markdown
workspace={workspace}
onImage={onImage}
onDownloadImage={onDownloadImage}
text={block.text}
streaming={Boolean(block.streaming)}
onLink={onLink}
onCopy={onCopy}
onFile={onFile}
onFileAction={onFileAction}
/>
</div>
);
if (!block.interrupted) return content;
return (
<div className="process-thinking" data-testid="interrupted-reply">
<button className="block-toggle" aria-expanded={expanded} onClick={toggle}>
<Hand size={16} aria-hidden="true" className="process-icon" />
<strong>回复已中断</strong>
<ChevronRight
size={16}
aria-hidden="true"
className={`process-chevron ${expanded ? "rotated" : ""}`}
/>
</button>
{expanded && <div className="interrupted-reply-body">{content}</div>}
</div>
);
}
const WorkProgress = memo(function WorkProgress({
run,
expanded,
defaultOpen,
toggle,
}: {
run: RunView;
expanded: boolean;
defaultOpen: boolean;
toggle: () => void;
}) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (run.status !== "running") return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [run.runId, run.status]);
return (
<div className="progress-heading">
<button className="progress" aria-expanded={expanded} onClick={toggle} data-testid="progress">
<span>{progressSummary(run, now)}</span>
{!defaultOpen && <ChevronRight size={16} aria-hidden="true" className={expanded ? "rotated" : ""} />}
</button>
</div>
);
});
function MessageReferences({
text,
fileReferences,
commandNames = [],
agentNames = [],
onFile,
onFileAction,
}: {
text: string;
fileReferences?: readonly string[];
commandNames?: readonly string[];
agentNames?: readonly string[];
onFile?: FileOpenHandler;
onFileAction?: FileActionHandler;
}) {
const references = messageReferences(text, fileReferences, commandNames, agentNames);
const content: ReactNode[] = [];
let offset = 0;
for (const mention of references.inline) {
content.push(text.slice(offset, mention.start));
const file = mention.kind === "file" ? splitFileLocation(mention.path) : null;
content.push(
<Reference
key={mention.start}
kind={mention.kind}
path={file?.path ?? mention.path}
location={file?.location}
label={mention.label}
onOpen={onFile}
onAction={onFileAction}
/>,
);
offset = mention.end;
}
content.push(text.slice(offset));
for (const { path, label } of references.trailing) {
const file = splitFileLocation(path);
content.push(
<Reference
key={path}
kind="file"
path={file?.path ?? path}
location={file?.location}
label={label}
onOpen={onFile}
onAction={onFileAction}
/>,
);
}
return content;
}
function GuideMessage({
guide,
runId,
sessionId,
context,
onFile,
onFileAction,
}: {
guide: NonNullable<RunView["guides"]>[number];
runId: string;
sessionId?: string;
context?: ComposerContext;
onFile?: FileOpenHandler;
onFileAction?: FileActionHandler;
}) {
const parsed = parseSelectionPrompt(guide.text);
const key = `${runId}:guide:${guide.id}`;
return (
<div className="user-message-row guide-message" data-guide-id={guide.id} data-query-key={key}>
<SelectionReferenceChip references={parsed.selections} onFile={onFile} />
{context &&
sessionId &&
guide.attachments.map((attachment) => (
<AttachmentPreview
key={attachment.id}
sessionId={sessionId}
image={attachment}
read={context.readAttachment}
/>
))}
<div className="user-message">
<UserMessageBody text={guide.text}>
<div
className="user-message-text"
data-find-key={key}
data-conversation-selectable="user"
data-selection-key={`${sessionId}:${key}`}
>
<MessageReferences
text={parsed.text}
fileReferences={guide.fileReferences}
onFile={onFile}
onFileAction={onFileAction}
/>
</div>
</UserMessageBody>
</div>
</div>
);
}
export const RunGroup = memo(function RunGroup({
run,
previewServices,
previewAutoOpen,
onLink,
workspace,
expanded,
onToggle,
blocks,
onBlockToggle,
finding = false,
findTargets,
context,
readWebSourceIcon = context?.readWebSourceIcon,
onChanges,
onLoadToolPatch,
onFileAction,
onCopy,
onFile,
sessionId,
onEdit,
onFork,
onRetryModel,
suggestions,
}: {
suggestions?: InputSuggestion[];
onEdit?: EditMessage;
onFork?: (runId: string) => Promise<void>;
onRetryModel?: (runId: string) => Promise<unknown>;
run: RunView;
previewServices?: PreviewServices;
previewAutoOpen?: ReturnType<typeof usePreviewAutoOpen>;
sessionId?: string;
context?: ComposerContext;
readWebSourceIcon?: ComposerContext["readWebSourceIcon"];
onChanges?: (runId: string, path?: string, toolCallId?: string) => void;
onLoadToolPatch?: (runId: string, toolCallId: string) => Promise<string>;
onFileAction?: FileActionHandler;
onCopy?: (text: string) => Promise<void>;
onFile?: FileOpenHandler;
workspace?: LinkContext;
onLink: (url: string, options?: WebOpenOptions) => void;
expanded: boolean;
onToggle: (runId: string, value: boolean) => void;
blocks: Record<string, boolean>;
onBlockToggle: (blockId: string, value: boolean) => void;
finding?: boolean;
findTargets?: ReadonlyMap<string, FindTarget>;
}) {
const openToolChange = useCallback(
(path: string, toolCallId: string) => onChanges?.(run.runId, path, toolCallId),
[onChanges, run.runId],
);
const loadToolPatch = useCallback(
(toolCallId: string) => onLoadToolPatch?.(run.runId, toolCallId) ?? Promise.resolve(""),
[onLoadToolPatch, run.runId],
);
const onImage = useCallback(
(path: string, location?: FileLocation) =>
context?.readImage && sessionId
? context.readImage(sessionId, path, location)
: Promise.reject(new Error("图片预览不可用")),
[context, sessionId],
);
const [editing, setEditing] = useState(false);
const [forking, setForking] = useState(false);
const [actionError, setActionError] = useState("");
const cancelEdit = useCallback(() => setEditing(false), []);
const parsedUser = parseSelectionPrompt(run.userMessage);
const commandNames = useMemo(() => customCommandNames(suggestions ?? []), [suggestions]);
const agentNames = useMemo(() => subagentNames(suggestions ?? []), [suggestions]);
useEffect(() => {
if (!onEdit) setEditing(false);
}, [onEdit]);
const { process, answer, defaultOpen } = runPresentation(run);
const answerSources = useMemo(() => collectAnswerSources(run, answer?.id), [run, answer?.id]);
const latestText = run.orderedBlocks.findLast((block) => block.type === "text");
const previews =
previewServices && sessionId && workspace && latestText && run.status !== "running" ? (
<AssistantTurnPreviews
run={run}
sessionId={sessionId}
workspace={workspace}
services={previewServices}
autoOpen={previewAutoOpen}
/>
) : null;
const markers = run.compactions ?? [];
const compacting = markers.some((marker) => marker.status === "running");
const modelWaiting = !run.preparation && Boolean(run.modelWait || run.apiRetry?.attempt);
type Boundary =
| { kind: "compaction"; id: string; marker: CompactionView }
| { kind: "guide"; id: string; guide: NonNullable<RunView["guides"]>[number] };
const items: (ProcessItem | Boundary)[] = [];
const boundaries: Boundary[] = [
...markers.map((marker) => ({ kind: "compaction" as const, id: marker.id, marker })),
...(run.guides ?? []).map((guide) => ({ kind: "guide" as const, id: guide.id, guide })),
];
const positions = new Map<string, number>();
if (boundaries.length)
for (const [index, block] of process.entries())
for (const id of [block.id, block.lastBlockId])
if (id !== undefined && !positions.has(id)) positions.set(id, index + 1);
const positioned = boundaries.map((item) => {
const after = item.kind === "guide" ? item.guide.afterBlockId : item.marker.afterBlockId;
return { item, position: after ? (positions.get(after) ?? 0) : 0 };
});
positioned.sort((left, right) => left.position - right.position);
let offset = 0;
for (const { item, position } of positioned) {
const boundary = Math.max(offset, position);
items.push(...processItems(process.slice(offset, boundary), false), item);
offset = boundary;
}
items.push(...processItems(process.slice(offset), run.status === "running" && !compacting));
// 对齐 ZCode 后台结果轮:直接展示过程和正文,不再套普通回复的工时折叠。
const open = !!run.subagentNotification || defaultOpen || expanded;
if (run.kind === "compact")
return (
<div className="compaction-run" data-run-id={run.runId}>
{(markers.length
? markers
: [{ id: run.runId, status: run.status, origin: "manual" as const, error: run.error }]
).map((marker) => (
<CompactionDivider key={marker.id} marker={marker} />
))}
</div>
);
return (
<article className="run-group" data-testid="run" data-run-id={run.runId} data-status={run.status}>
<div className="assistant-turn">
{run.subagentNotification ? (
<div
className="background-result-heading"
data-testid="background-result-heading"
data-find-key={`${run.runId}:user`}
>
{run.subagentNotification.description}
</div>
) : (
<div className="user-message-row">
{editing && onEdit && sessionId ? (
<UserMessageEditor
run={run}
sessionId={sessionId}
context={context}
suggestions={suggestions}
onEdit={onEdit}
onCancel={cancelEdit}
onFile={onFile}
/>
) : (
<>
<div className="message-images">
<SelectionReferenceChip references={parsedUser.selections} onFile={onFile} />
{context &&
sessionId &&
run.attachments?.map((image) => (
<AttachmentPreview
key={image.id}
sessionId={sessionId}
image={image}
read={context.readAttachment}
/>
))}
</div>
<div className="user-message">
<UserMessageBody key={run.runId} text={run.userMessage}>
<div
className="user-message-text"
data-find-key={`${run.runId}:user`}
data-query-key={`${run.runId}:user`}
data-conversation-selectable="user"
data-selection-key={`${sessionId}:${run.runId}:user`}
>
<MessageReferences
text={parsedUser.text}
fileReferences={run.fileReferences}
commandNames={commandNames}
agentNames={agentNames}
onFile={onFile}
onFileAction={onFileAction}
/>
</div>
</UserMessageBody>
</div>
<div className="message-actions user-message-actions">
<CopyMessage text={parsedUser.text} onCopy={onCopy} />
{onEdit && (
<MessageAction label="编辑" onClick={() => setEditing(true)}>
<Pencil size={14} />
</MessageAction>
)}
</div>
</>
)}
</div>
)}
{!run.subagentNotification && (
<WorkProgress
run={run}
expanded={open}
defaultOpen={defaultOpen}
toggle={() => {
if (!defaultOpen) onToggle(run.runId, !expanded);
}}
/>
)}
{open && items.length > 0 && (
<div className="process" data-testid="process">
{items.map((item) =>
item.kind === "compaction" ? (
<CompactionDivider key={item.id} marker={item.marker} />
) : item.kind === "guide" ? (
<GuideMessage
key={item.id}
guide={item.guide}
runId={run.runId}
sessionId={sessionId}
context={context}
onFile={onFile}
onFileAction={onFileAction}
/>
) : item.kind !== "block" ? (
<StreamEntrance
key={item.id}
id={`${sessionId}:${run.runId}:${item.id}`}
active={run.status === "running"}
>
<ToolGroup
group={item}
blocks={blocks}
toggle={(id) => onBlockToggle(id, !(blocks[id] ?? false))}
workspace={workspace}
onCopy={onCopy}
onFile={onFile}
onChanges={onChanges ? openToolChange : undefined}
onLoadPatch={onLoadToolPatch ? loadToolPatch : undefined}
/>
</StreamEntrance>
) : (
<Fragment key={item.id}>
<StreamEntrance
id={`${sessionId}:${run.runId}:${item.id}`}
active={run.status === "running" && item.block.type === "tool"}
>
<ProcessBlock
workspace={workspace}
key={item.id}
onImage={onImage}
onDownloadImage={context?.downloadImage}
block={item.block}
expanded={blocks[item.id] ?? false}
toggle={() => onBlockToggle(item.id, !(blocks[item.id] ?? false))}
onLink={onLink}
onCopy={onCopy}
onFile={onFile}
onFileAction={onFileAction}
onChanges={onChanges ? openToolChange : undefined}
onLoadPatch={onLoadToolPatch ? loadToolPatch : undefined}
/>
</StreamEntrance>
{item.id === latestText?.id && previews}
</Fragment>
),
)}
</div>
)}
{!open && finding && (
// Search rendered assistant text independently of the work fold, without mounting hidden tools.
<div hidden data-find-index>
{process.map((block) =>
block.type === "text" ? (
<div key={block.id} data-find-key={block.id}>
{findTargets?.has(block.id) && !findTargets.get(block.id)?.rendered ? (
findTargets.get(block.id)?.text
) : (
<Markdown
text={block.text}
workspace={workspace}
streaming={false}
onLink={onLink}
onFile={onFile}
/>
)}
</div>
) : null,
)}
</div>
)}
{!open && markers.map((marker) => <CompactionDivider key={marker.id} marker={marker} />)}
{!open &&
run.guides?.map((guide) => (
<GuideMessage
key={guide.id}
guide={guide}
runId={run.runId}
sessionId={sessionId}
context={context}
onFile={onFile}
onFileAction={onFileAction}
/>
))}
{run.notice && (
<div className="run-notice" role="status">
{run.notice}
</div>
)}
{run.status === "running" && (!compacting || modelWaiting) && (
<div
className={`chat-loading-slot${modelWaiting ? " model-wait-row" : ""}`}
data-stream-entrance={run.preparation ? undefined : "true"}
>
{run.preparation ? (
<span className="run-preparation" data-testid="run-preparation" role="status">
<Loader className="chat-loading-spinner" size={16} aria-hidden="true" />
{run.preparation}
</span>
) : (
<div
className="chat-loading-icon"
role="status"
aria-label="加载中"
aria-hidden={modelWaiting || undefined}
>
<Loader className="chat-loading-spinner" size={16} aria-hidden="true" />
</div>
)}
{modelWaiting && (
<ModelWaitNotice
wait={run.modelWait}
retryStatus={run.apiRetry}
onRetry={onRetryModel ? () => onRetryModel(run.runId) : undefined}
/>
)}
</div>
)}
{run.error && run.status !== "running" && (
<Notice scope="turn" message={userErrorMessage(run.error)} />
)}
<div className="assistant-message-row">
{answer && (
<div
className="answer"
data-find-key={`${run.runId}:answer`}
data-conversation-selectable="assistant"
data-selection-key={`${sessionId}:${run.runId}:answer`}
>
<Markdown
workspace={workspace}
onImage={onImage}
onDownloadImage={context?.downloadImage}
text={answer.text}
streaming={Boolean(answer.streaming)}
onLink={onLink}
onCopy={onCopy}
onFile={onFile}
onFileAction={onFileAction}
/>
</div>
)}
{answer && previews}
<ChangedFiles
run={run}
sessionId={sessionId}
readFileChanges={context?.readFileChanges}
cwd={workspace?.cwd}
onChanges={onChanges}
onFile={onFile}
onFileAction={onFileAction}
/>
{answer && (
<div className="message-actions assistant-message-actions">
<CopyMessage text={answer.text} onCopy={onCopy} />
{onFork && (
<MessageAction
label="分叉"
disabled={forking}
onClick={() => {
setForking(true);
setActionError("");
void onFork(run.runId)
.catch((error) => setActionError(userErrorMessage(error)))
.finally(() => setForking(false));
}}
>
<TrendingUpDown size={14} />
</MessageAction>
)}
{answerSources.length > 0 && (
<AnswerSources
key={JSON.stringify([sessionId, run.runId, answer.id])}
sources={answerSources}
sessionId={sessionId}
runId={run.runId}
readIcon={readWebSourceIcon}
onLink={onLink}
/>
)}
<span className="message-time">{messageTime(answer.startedAt ?? run.startedAt)}</span>
</div>
)}
{actionError && <Notice scope="turn" message={actionError} />}
</div>
</div>
</article>
);
});
function historyViewportAnchor(root: HTMLElement): { id: string; top: number } | undefined {
const viewportTop = root.getBoundingClientRect().top;
const viewportBottom = viewportTop + root.clientHeight;
let overlapping: { id: string; top: number } | undefined;
for (const node of root.querySelectorAll<HTMLElement>("[data-history-run]")) {
const bounds = node.getBoundingClientRect();
if (!node.dataset.historyRun) continue;
// Filling the prefix of a partial first run can grow inside that run. Anchor
// the next fully visible run when available, rather than its unchanged wrapper top.
if (bounds.top >= viewportTop && bounds.top < viewportBottom)
return { id: node.dataset.historyRun, top: bounds.top };
if (!overlapping && bounds.top < viewportBottom && bounds.bottom > viewportTop)
overlapping = { id: node.dataset.historyRun, top: bounds.top };
}
return overlapping;
}
export function Conversation({
view,
onOpenSubagent,
previewServices,
onLink,
workspace,
context,
readWebSourceIcon,
onChanges,
onLoadToolPatch,
onFileAction,
onCopy,
onFile,
hasEarlier = false,
loadingEarlier = false,
onLoadEarlier,
onLoadAllEarlier,
historyRevision = 0,
historyCursor,
onEdit,
onFork,
onRetryModel,
onAddSelection,
addSelectionLabel,
onSideChat,
sideChatDisabled,
onNavigateOrigin,
findRequest,
onFindStateChange,
findBar,
suggestions,
persistReading = true,
}: {
persistReading?: boolean;
suggestions?: InputSuggestion[];
onOpenSubagent?: (task: SubagentTask) => void;
findRequest?: FindRequest;
onFindStateChange?: (state: FindState) => void;
findBar?: ReactNode;
onNavigateOrigin?: (origin: NonNullable<SessionView["forkOrigin"]>) => void;
onEdit?: EditMessage;
onFork?: (runId: string) => Promise<void>;
onRetryModel?: (runId: string) => Promise<unknown>;
onAddSelection?: (reference: ConversationSelection) => void;
addSelectionLabel?: string;
onSideChat?: (reference: ConversationSelection) => void;
sideChatDisabled?: boolean;
hasEarlier?: boolean;
loadingEarlier?: boolean;
onLoadEarlier?: () => Promise<void>;
onLoadAllEarlier?: () => Promise<"loaded" | "stale" | "not-enough">;
historyCursor?: number | null;
historyRevision?: number;
view: SessionView;
previewServices?: PreviewServices;
workspace?: LinkContext;
onLink: (url: string, options?: WebOpenOptions) => void;
context?: ComposerContext;
readWebSourceIcon?: ComposerContext["readWebSourceIcon"];
onChanges?: (runId: string, path?: string, toolCallId?: string) => void;
onLoadToolPatch?: (runId: string, toolCallId: string) => Promise<string>;
onFileAction?: FileActionHandler;
onCopy?: (text: string) => Promise<void>;
onFile?: FileOpenHandler;
}) {
const { sessionId, forkOrigin } = view;
const previewAutoOpen = usePreviewAutoOpen(view);
const subagentContext = useMemo(
() => ({ tasks: view.subagents ?? [], backgroundTasks: view.backgroundTasks, open: onOpenSubagent }),
[view.subagents, view.backgroundTasks, onOpenSubagent],
);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [blocks, setBlocks] = useState<Record<string, boolean>>({});
const lifecycle = useRef(new Map<string, boolean>());
const manualBlocks = useRef(new Set<string>());
const processedRuns = useRef(new WeakSet<RunView>());
const processedBlocks = useRef(new WeakSet<ViewBlock>());
const processedSession = useRef("");
useEffect(() => {
if (processedSession.current !== view.sessionId) {
processedSession.current = view.sessionId;
processedRuns.current = new WeakSet();
processedBlocks.current = new WeakSet();
}
const runUpdates: Record<string, boolean> = {};
const blockUpdates: Record<string, boolean> = {};
for (const run of view.runs) {
if (processedRuns.current.has(run)) continue;
processedRuns.current.add(run);
const key = `run:${view.sessionId}:${run.runId}`;
const wasRunning = lifecycle.current.get(key);
if (wasRunning && run.status !== "running") runUpdates[run.runId] = false;
lifecycle.current.set(key, run.status === "running");
for (const block of runPresentation(run).process) {
if (block.type !== "thinking" || processedBlocks.current.has(block)) continue;
processedBlocks.current.add(block);
const key = `block:${view.sessionId}:${run.runId}:${block.id}`;
const wasStreaming = lifecycle.current.get(key);
if (wasStreaming && !block.streaming && !manualBlocks.current.has(`${view.sessionId}:${block.id}`))
blockUpdates[block.id] = false;
lifecycle.current.set(key, Boolean(block.streaming));
}
}
if (Object.keys(runUpdates).length) setExpanded((current) => ({ ...current, ...runUpdates }));
if (Object.keys(blockUpdates).length) setBlocks((current) => ({ ...current, ...blockUpdates }));
}, [view.runs, view.sessionId]);
const toggleBlock = useCallback(
(blockId: string, value: boolean) => {
manualBlocks.current.add(`${view.sessionId}:${blockId}`);
setBlocks((current) => ({ ...current, [blockId]: value }));
},
[view.sessionId],
);
const toggle = useCallback(
(runId: string, value: boolean) => setExpanded((current) => ({ ...current, [runId]: value })),
[],
);
const ref = useRef<HTMLDivElement>(null);
const following = useRef(true);
const saveReadingRef = useRef(() => {});
const readingReady = useRef(true);
const scrollNavigation = useRef(0);
const findQuery = normalizeFindQuery(findRequest?.query ?? "");
const [findRun, setFindRun] = useState<{ sessionId: string; query: string; runId: string }>();
const lastScrollTop = useRef(0);
const latestRun = useRef<string | undefined>(undefined);
const navigateFind = useCallback(
(match: FindMatch, range?: Range) => {
const root = ref.current;
if (!root) return;
scrollNavigation.current++;
readingReady.current = true;
if (match.runId)
setFindRun((current) =>
current?.sessionId === sessionId && current.query === findQuery && current.runId === match.runId
? current
: { sessionId, query: findQuery, runId: match.runId as string },
);
if (!range) {
const target = root.querySelector(`[data-find-key="${CSS.escape(match.key)}"]`);
const runId = match.runId ?? target?.closest<HTMLElement>("[data-run-id]")?.dataset.runId;
if (runId) {
following.current = false;
if (match.key !== `${runId}:user` && match.key !== `${runId}:answer`) {
setExpanded((current) => (current[runId] ? current : { ...current, [runId]: true }));
setBlocks((current) => (current[match.key] ? current : { ...current, [match.key]: true }));
}
const row = root.querySelector<HTMLElement>(`[data-history-run="${CSS.escape(runId)}"]`);
const index = view.runs.findIndex((run) => run.runId === runId);
const chunk = index < 0 ? undefined : historyChunkForRun(root, index);
const location = row ?? chunk;
if (location && !target)
root.scrollTop += location.getBoundingClientRect().top - root.getBoundingClientRect().top;
}
return;
}
following.current = false;
const node = range.startContainer;
const element = node instanceof Element ? node : node.parentElement;
const content = element?.closest(".user-message-content-inner");
const scroll = () => {
// Explicit navigation replaces the reading anchor, including an adjustment queued by pagination.
const anchoring = root.style.overflowAnchor;
root.style.overflowAnchor = "none";
const top = root.scrollTop;
const bounds = range.getBoundingClientRect();
const viewport = root.getBoundingClientRect();
root.scrollTo({
top: top + bounds.top - viewport.top - root.clientHeight / 2 + bounds.height / 2,
// ZCode first brings the target turn into view, then refines the visible hit smoothly.
behavior: bounds.bottom < viewport.top || bounds.top > viewport.bottom ? "instant" : "smooth",
});
root.style.overflowAnchor = anchoring;
};
if (content) {
content.dispatchEvent(new Event("zpi-reveal-user-message"));
requestAnimationFrame(scroll);
} else scroll();
},
[sessionId, findQuery, view.runs],
);
const commandNames = useMemo(() => customCommandNames(suggestions ?? []), [suggestions]);
const agentNames = useMemo(() => subagentNames(suggestions ?? []), [suggestions]);
const findTargets = useConversationFindTargets({
runs: view.runs,
active: Boolean(findQuery),
sessionId: view.sessionId,
workspace,
commandNames,
agentNames,
});
const findTargetsByKey = useMemo(
() => new Map(findTargets.map((target) => [target.key, target])),
[findTargets],
);
const pinnedFindRuns = useMemo(() => {
const ids = new Set(
findTargets.filter((target) => target.rendered && target.runId).map((target) => target.runId as string),
);
if (findQuery && findRun?.sessionId === view.sessionId && findRun.query === findQuery)
ids.add(findRun.runId);
return ids;
}, [findTargets, findQuery, findRun, view.sessionId]);
useTextFind({
rootRef: ref,
request: findRequest,
content: view.runs,
scopeKey: view.sessionId,
targets: findTargets,
onStateChange: onFindStateChange,
onNavigate: navigateFind,
navigateUnmounted: false,
});
const windowed = view.runs.length > historyWindowThreshold;
const reading = useMemo(() => {
try {
return persistReading ? readReadingBookmark(localStorage.getItem(`zpi.reading.${sessionId}`)) : null;
} catch {
return null;
}
}, [sessionId, persistReading]);
const restoreReadingRef = useRef(() => {});
const scrollToBottom = () => {
const el = ref.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
lastScrollTop.current = el.scrollTop;
};
const [showLatest, setShowLatest] = useState(false);
const [navigatorWide, setNavigatorWide] = useState(false);
const [navigatorTarget, setNavigatorTarget] = useState<string>();
const pinnedRuns = useMemo(
() => (navigatorTarget ? new Set([...pinnedFindRuns, navigatorTarget]) : pinnedFindRuns),
[navigatorTarget, pinnedFindRuns],
);
useEffect(() => {
const element = ref.current;
if (!element) return;
const update = () => setNavigatorWide(element.clientWidth >= 864);
update();
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, [sessionId]);
const runPartition = conversationRunPartition(view.runs);
const navigationHistory = useMemo(
() => runPartition.history.flatMap(conversationQueries),
[runPartition.history],
);
const navigationTail = useMemo(
() => (runPartition.tail ? conversationQueries(runPartition.tail) : []),
[runPartition.tail],
);
const navigatorCount = navigationHistory.length + navigationTail.length;
const navigatorRuns = useRef(view.runs);
navigatorRuns.current = view.runs;
const navigatorJumpFrame = useRef<number | undefined>(undefined);
useEffect(
() => () => {
if (navigatorJumpFrame.current !== undefined) cancelAnimationFrame(navigatorJumpFrame.current);
},
[sessionId],
);
const jumpNavigator = useCallback((id: string, queryKey = `${id}:user`) => {
const root = ref.current;
if (!root) return;
const target = root.querySelector<HTMLElement>(`[data-history-run="${CSS.escape(id)}"]`);
const index = navigatorRuns.current.findIndex((run) => run.runId === id);
const chunk = index < 0 ? undefined : historyChunkForRun(root, index);
if (!target && !chunk) return;
if (navigatorJumpFrame.current !== undefined) cancelAnimationFrame(navigatorJumpFrame.current);
const navigation = ++scrollNavigation.current;
readingReady.current = true;
following.current = false;
setNavigatorTarget(id);
// Bring the placeholder into view first so its actual message can mount and be measured.
root.scrollTop +=
((target ?? chunk)?.getBoundingClientRect().top ?? 0) - root.getBoundingClientRect().top;
let attempts = 120;
let commandedTop: number | undefined;
let stableFrames = 0;
const align = () => {
navigatorJumpFrame.current = undefined;
if (ref.current !== root || navigation !== scrollNavigation.current) return;
const message =
root.querySelector<HTMLElement>(`[data-query-key="${CSS.escape(queryKey)}"]`) ??
root.querySelector<HTMLElement>(`[data-find-key="${CSS.escape(queryKey)}"]`);
if (message) {
const top = root.scrollTop + message.getBoundingClientRect().top - root.getBoundingClientRect().top;
if (commandedTop === undefined || Math.abs(top - commandedTop) > 1) {
root.scrollTo({
top,
// Nearby rows can mount and resize during the initial smooth scroll.
// Correct a changed destination immediately, then wait for layout to settle.
behavior:
commandedTop !== undefined || window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
commandedTop = top;
stableFrames = 0;
}
const destination = Math.max(0, Math.min(top, root.scrollHeight - root.clientHeight));
stableFrames = Math.abs(root.scrollTop - destination) <= 1 ? stableFrames + 1 : 0;
if (stableFrames >= 3) return;
}
if (--attempts > 0) navigatorJumpFrame.current = requestAnimationFrame(align);
};
navigatorJumpFrame.current = requestAnimationFrame(align);
}, []);
useEffect(() => {
const navigate = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId: string; runId: string }>).detail;
const root = ref.current;
if (!root || detail.sessionId !== view.sessionId) return;
const target = root.querySelector<HTMLElement>(`[data-history-run="${CSS.escape(detail.runId)}"]`);
if (!target) {
jumpNavigator(detail.runId);
return;
}
scrollNavigation.current++;
readingReady.current = true;
following.current = false;
root.scrollTop += target.getBoundingClientRect().top - root.getBoundingClientRect().top;
setShowLatest(root.scrollHeight - root.scrollTop - root.clientHeight >= 60);
saveReadingRef.current();
};
window.addEventListener("zpi:scroll-to-run", navigate);
return () => window.removeEventListener("zpi:scroll-to-run", navigate);
}, [view.sessionId, jumpNavigator]);
// An upward wheel gesture is reading intent; disabling following here wins the race
// against the next stream chunk's stick-to-bottom snap reverting the user's scroll.
useEffect(() => {
const root = ref.current;
if (!root) return;
const wheel = (event: WheelEvent) => {
if (navigatorJumpFrame.current !== undefined) {
cancelAnimationFrame(navigatorJumpFrame.current);
navigatorJumpFrame.current = undefined;
}
readingReady.current = true;
// At the top of the conversation an upward gesture has nothing to reveal.
if (event.deltaY >= 0 || root.scrollTop <= 0) return;
for (
let node = event.target instanceof Element ? event.target : null;
node && node !== root;
node = node.parentElement
) {
// An inner scroller still holding content consumes the gesture first.
if (node.scrollTop > 0 && node.scrollHeight > node.clientHeight + 1) {
const overflow = getComputedStyle(node).overflowY;
if (overflow === "auto" || overflow === "scroll") return;
}
}
following.current = false;
};
root.addEventListener("wheel", wheel, { passive: true });
return () => root.removeEventListener("wheel", wheel);
}, [view.sessionId]);
const prepend = useRef<{
cursor: number | null | undefined;
height: number;
top: number;
navigation: number;
anchor?: { id: string; top: number };
} | null>(null);
const historyTrigger = useRef<HTMLButtonElement>(null);
const historyRequest = useRef<object | null>(null);
const [historyPending, setHistoryPending] = useState(false);
const autoLoadAttempt = useRef("");
const [historyError, setHistoryError] = useState("");
const loadHistory = useCallback(
(background = false) => {
const el = ref.current;
const load = background ? onLoadAllEarlier : onLoadEarlier;
if (!el || !hasEarlier || loadingEarlier || !load || historyRequest.current) return;
const request = {};
historyRequest.current = request;
setHistoryPending(true);
if (!background) following.current = false;
prepend.current = {
cursor: historyCursor,
height: el.scrollHeight,
top: el.scrollTop,
navigation: scrollNavigation.current,
};
prepend.current.anchor = historyViewportAnchor(el);
if (!background) setHistoryError("");
return load()
.then((result) => {
if (background && result !== "loaded" && historyRequest.current === request) prepend.current = null;
return result;
})
.catch((error) => {
if (historyRequest.current !== request) return;
prepend.current = null;
if (!background) setHistoryError(userErrorMessage(error));
return "failure" as const;
})
.finally(() => {
if (historyRequest.current === request) {
historyRequest.current = null;
setHistoryPending(false);
}
});
},
[hasEarlier, loadingEarlier, onLoadEarlier, onLoadAllEarlier, historyCursor],
);
const navigatorAttempt = useRef({
key: "",
status: "idle" as "idle" | "loading" | "waiting" | "done",
failures: 0,
timer: 0,
});
const [navigatorRetry, setNavigatorRetry] = useState(0);
useEffect(
() => () => {
window.clearTimeout(navigatorAttempt.current.timer);
navigatorAttempt.current.key = "";
},
[sessionId],
);
useEffect(() => {
if (!navigatorWide || !hasEarlier || loadingEarlier || historyPending || !onLoadAllEarlier) return;
const attempt = navigatorAttempt.current;
const key = `${sessionId}:${historyRevision}:${historyCursor}:${navigatorCount}`;
if (attempt.key !== key) {
window.clearTimeout(attempt.timer);
Object.assign(attempt, { key, status: "idle", failures: 0, timer: 0 });
}
if (attempt.status !== "idle") return;
const request = loadHistory(true);
if (!request) return;
attempt.status = "loading";
void request.then((result) => {
if (attempt.key !== key) return;
if (result === "failure") {
const delay = [250, 1000][attempt.failures++];
if (delay === undefined) {
attempt.status = "done";
return;
}
attempt.status = "waiting";
attempt.timer = window.setTimeout(() => {
attempt.status = "idle";
setNavigatorRetry((value) => value + 1);
}, delay);
} else if (result === "stale") {
attempt.status = "idle";
setNavigatorRetry((value) => value + 1);
} else attempt.status = "done";
});
}, [
navigatorWide,
hasEarlier,
loadingEarlier,
historyPending,
onLoadAllEarlier,
sessionId,
historyCursor,
historyRevision,
loadHistory,
navigatorRetry,
navigatorCount,
]);
const findLoadAttempt = useRef("");
useEffect(() => {
if (
!findQuery ||
historyPending ||
!hasEarlier ||
loadingEarlier ||
!onLoadEarlier ||
view.runs.reduce((count, run) => count + 1 + run.orderedBlocks.length, 0) >= 1200
)
return;
const attempt = `${sessionId}:${findQuery}:${historyCursor}`;
if (findLoadAttempt.current === attempt) return;
findLoadAttempt.current = attempt;
loadHistory();
}, [
findQuery,
hasEarlier,
loadingEarlier,
onLoadEarlier,
historyCursor,
view.runs,
sessionId,
loadHistory,
historyPending,
]);
const readingWrite = useRef<{
key: string;
sessionId: string;
root: HTMLDivElement;
bookmark: ReadingBookmark;
windowed: boolean;
} | null>(null);
const readingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const flushReading = useCallback(() => {
clearTimeout(readingTimer.current);
readingTimer.current = undefined;
const pending = readingWrite.current;
readingWrite.current = null;
if (pending) {
try {
if (
!pending.bookmark.following &&
pending.windowed &&
pending.root.dataset.readingSession === pending.sessionId
) {
const anchor = readingAnchor(pending.root);
if (anchor) Object.assign(pending.bookmark, anchor);
}
localStorage.setItem(pending.key, JSON.stringify(pending.bookmark));
} catch {
// A bookmark is optional when browser storage is unavailable.
}
}
}, []);
useEffect(() => {
window.addEventListener("pagehide", flushReading);
return () => window.removeEventListener("pagehide", flushReading);
}, [flushReading]);
const saveReading = () => {
if (!persistReading || !readingReady.current) return;
const el = ref.current;
if (!el) return;
const bookmark: ReadingBookmark = {
top: el.scrollTop,
following: following.current,
cursor: historyCursor ?? 0,
};
readingWrite.current = { key: `zpi.reading.${sessionId}`, sessionId, root: el, bookmark, windowed };
readingTimer.current ??= setTimeout(flushReading, 250);
};
saveReadingRef.current = saveReading;
restoreReadingRef.current = () => {
const el = ref.current;
if (!el || readingReady.current) return;
if (reading?.runId && reading.offset !== undefined) {
const anchor = el.querySelector<HTMLElement>(`[data-history-run="${CSS.escape(reading.runId)}"]`);
if (!anchor) return;
el.scrollTop += anchor.getBoundingClientRect().top - el.getBoundingClientRect().top + reading.offset;
readingReady.current = true;
} else if (reading && reading.top <= el.scrollHeight - el.clientHeight + 1) {
el.scrollTop = reading.top;
readingReady.current = true;
}
lastScrollTop.current = el.scrollTop;
};
useLayoutEffect(() => {
prepend.current = null;
historyRequest.current = null;
setHistoryPending(false);
autoLoadAttempt.current = "";
findLoadAttempt.current = "";
setHistoryError("");
following.current = reading?.following ?? true;
readingReady.current = following.current || !reading;
latestRun.current = view.runs.at(-1)?.runId;
if (ref.current)
ref.current.scrollTop = following.current ? ref.current.scrollHeight : (reading?.top ?? 0);
restoreReadingRef.current();
const el = ref.current;
lastScrollTop.current = el?.scrollTop ?? 0;
setShowLatest(Boolean(el && el.scrollHeight - el.clientHeight - el.scrollTop >= 60));
return flushReading;
}, [sessionId, flushReading, reading]);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const tail = view.runs.at(-1)?.runId;
restoreReadingRef.current();
if (tail !== latestRun.current) {
latestRun.current = tail;
if (!findQuery) {
following.current = true;
readingReady.current = true;
}
}
if (prepend.current && prepend.current.cursor !== historyCursor) {
// The page can fill a partial first turn as well as prepend rows. Apply the explicit
// page anchor with native anchoring suspended for this layout, then resume it for images/diagrams.
const anchor = prepend.current.anchor;
const node = anchor
? el.querySelector<HTMLElement>(`[data-history-run="${CSS.escape(anchor.id)}"]`)
: null;
const anchoring = el.style.overflowAnchor;
el.style.overflowAnchor = "none";
if (prepend.current.navigation === scrollNavigation.current) {
if (node && anchor) el.scrollTop += node.getBoundingClientRect().top - anchor.top;
else el.scrollTop = prepend.current.top + el.scrollHeight - prepend.current.height;
}
el.style.overflowAnchor = anchoring;
lastScrollTop.current = el.scrollTop;
prepend.current = null;
} else if (following.current) scrollToBottom();
setShowLatest(el.scrollHeight - el.scrollTop - el.clientHeight >= 60);
saveReading();
}, [view.runs, historyCursor]);
useEffect(() => {
const root = ref.current;
const trigger = historyTrigger.current;
if (!root || !trigger || !hasEarlier || loadingEarlier) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
const attempt = `${view.sessionId}:${historyCursor}`;
if (autoLoadAttempt.current === attempt || historyRequest.current) return;
autoLoadAttempt.current = attempt;
loadHistory();
},
{ root, rootMargin: "120px 0px 0px 0px" },
);
observer.observe(trigger);
return () => observer.disconnect();
}, [view.sessionId, historyCursor, hasEarlier, loadingEarlier, loadHistory]);
useLayoutEffect(() => {
const root = ref.current;
if (!root) return;
const column = root.firstElementChild as HTMLElement | null;
const owner = root.parentElement?.parentElement;
const composer = owner?.querySelector(":scope > .composer-container");
const shared = new Map<string, string>();
const shareColumn = () => {
if (!column || !owner || !composer) return;
const width = column.getBoundingClientRect().width;
if (!width) return;
// Share size constraints only. Auto margins can change without a resize;
// the sibling composer must calculate its position in CSS instead of caching it.
for (const [property, value] of [
["--conversation-column-width", `${width}px`],
["--conversation-scrollbar-width", `${root.offsetWidth - root.clientWidth}px`],
]) {
if (owner.style.getPropertyValue(property) !== value) owner.style.setProperty(property, value);
shared.set(property, value);
}
};
shareColumn();
const observer = new ResizeObserver(() => {
shareColumn();
restoreReadingRef.current();
const el = ref.current;
if (!el) return;
if (following.current) scrollToBottom();
setShowLatest(el.scrollHeight - el.scrollTop - el.clientHeight >= 60);
});
observer.observe(root);
if (column) observer.observe(column);
return () => {
observer.disconnect();
for (const [property, value] of shared)
if (owner?.style.getPropertyValue(property) === value) owner.style.removeProperty(property);
};
}, [view.sessionId]);
const latestRunId = view.runs.at(-1)?.runId;
const renderRun = useCallback(
(run: RunView) => (
<Fragment>
<RunGroup
suggestions={suggestions}
workspace={workspace}
run={run}
previewServices={previewServices}
previewAutoOpen={previewAutoOpen}
sessionId={sessionId}
context={context}
readWebSourceIcon={readWebSourceIcon}
onEdit={run.runId === latestRunId ? onEdit : undefined}
onFork={onFork}
onRetryModel={onRetryModel}
onChanges={onChanges}
onLoadToolPatch={onLoadToolPatch}
onFileAction={onFileAction}
onCopy={onCopy}
onFile={onFile}
onLink={onLink}
expanded={Boolean(expanded[run.runId])}
finding={Boolean(findQuery)}
findTargets={findTargetsByKey}
onToggle={toggle}
blocks={blocks}
onBlockToggle={toggleBlock}
/>
{forkOrigin?.runId === run.runId && (
<button
type="button"
className="fork-notice"
onClick={() => forkOrigin && onNavigateOrigin?.(forkOrigin)}
>
<span />
<strong>
<GitBranch size={14} />
<span>从对话中派生</span>
</strong>
<span />
</button>
)}
</Fragment>
),
[
suggestions,
workspace,
previewServices,
previewAutoOpen,
sessionId,
context,
readWebSourceIcon,
latestRunId,
onEdit,
onFork,
onRetryModel,
onChanges,
onLoadToolPatch,
onFileAction,
onCopy,
onFile,
onLink,
expanded,
findQuery,
findTargetsByKey,
toggle,
blocks,
toggleBlock,
forkOrigin,
onNavigateOrigin,
],
);
return (
<SubagentContext.Provider value={subagentContext}>
<div className="conversation-wrap">
{findBar}
<div
ref={ref}
className="conversation"
data-windowed={windowed}
data-reading-session={sessionId}
onPointerDownCapture={() => {
readingReady.current = true;
}}
onScroll={() => {
const el = ref.current;
if (el) {
// A reader can keep scrolling while an asynchronous page is loading.
// Keep its baseline current instead of restoring the request's old viewport.
if (prepend.current && prepend.current.cursor === historyCursor) {
prepend.current.top = el.scrollTop;
prepend.current.height = el.scrollHeight;
prepend.current.anchor = historyViewportAnchor(el);
}
// Layout growth and delayed diagram rendering must not disable following.
// Only movement towards older content changes the reading mode.
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
if (el.scrollTop < lastScrollTop.current - 1 && distance >= 60) following.current = false;
// Returning to the bottom resumes following. Only downward movement counts, so
// momentum scrolling away from the bottom cannot re-snap mid-gesture.
else if (el.scrollTop > lastScrollTop.current + 1 && distance <= 8) following.current = true;
lastScrollTop.current = el.scrollTop;
setShowLatest(distance >= 60);
saveReading();
}
}}
>
<div className="conversation-inner">
{hasEarlier && (
<button
ref={historyTrigger}
className="load-history"
disabled={loadingEarlier}
onClick={() => loadHistory()}
>
{loadingEarlier ? "正在加载…" : historyError ? "加载失败,点击重试" : "加载更早的十次调用"}
</button>
)}
{historyError && <Notice scope="turn" message={historyError} />}
{view.runs.length ? (
<ConversationHistory
key={view.sessionId}
runs={view.runs}
root={ref}
windowed={windowed}
initialRunId={reading?.runId}
pinnedRunIds={pinnedRuns}
renderRun={renderRun}
/>
) : (
<div className="empty-chat">
<DraftGreeting />
</div>
)}
</div>
</div>
<ConversationNavigator
enabled={navigatorWide}
history={navigationHistory}
tail={navigationTail}
scrollRoot={ref}
onJump={jumpNavigator}
busy={hasEarlier || loadingEarlier}
/>
<ConversationSelectionMenu
rootRef={ref}
scopeKey={view.sessionId}
onAdd={onAddSelection}
addLabel={addSelectionLabel}
onSideChat={onSideChat}
sideChatDisabled={sideChatDisabled}
/>
{showLatest && (
<button
className="latest"
type="button"
aria-label="回到最新"
onClick={() => {
readingReady.current = true;
following.current = true;
scrollToBottom();
setShowLatest(false);
saveReading();
}}
>
<ArrowDown size={16} aria-hidden="true" />
</button>
)}
</div>
</SubagentContext.Provider>
);
}
export function ChatComposer({
busy,
onSubmit,
onStop,
sessionId,
toolbar,
header,
layout = "stacked",
historyScope,
sentMessages = [],
suggestions = [],
showSendButton = true,
draftStorage,
context,
queue,
queueActions,
onFile,
onCancel,
resetFiles,
shortcutCommand,
onSelectModel,
onConfigureEnvironment,
}: {
shortcutCommand?: (
event: KeyboardEvent,
) => "send-next" | "select-model" | "configure-environment" | undefined;
onSelectModel?: () => void;
onConfigureEnvironment?: () => void;
onCancel?: () => void;
resetFiles?: { disabled: boolean; description: string };
onFile?: FileOpenHandler;
busy: boolean;
onSubmit: (
text: string,
input: {
fileReferences: string[];
attachments: string[];
queueDisposition?: "keep" | "clear";
immediate?: boolean;
workspaceMode?: "preserve" | "rewind";
},
context: { draftRevision: number },
) => Promise<void> | Promise<"confirmationRequired" | "blocked" | undefined>;
onStop: () => void;
sessionId: string;
toolbar?: ReactNode;
header?: ReactNode;
layout?: "stacked" | "inline";
historyScope?: string;
sentMessages?: readonly string[];
suggestions?: InputSuggestion[];
showSendButton?: boolean;
draftStorage?: ComposerDraftStore;
context?: ComposerContext;
queue?: InputQueue;
queueActions?: Omit<QueueActions, "edit"> & { edit(itemId: string): Promise<ComposerDraft> };
}) {
const emptyDraft = (): ComposerDraft => ({ text: "", fileReferences: [], attachments: [], pending: 0 });
const drafts = useRef(draftStorage ?? new ComposerDraftStore());
const [value, setValue] = useState<ComposerDraft>(() => {
const initial = drafts.current.get(sessionId) ?? emptyDraft();
drafts.current.set(sessionId, initial);
return initial;
});
const draft = value.text;
const workspace = historyScope ?? sessionId;
const promptHistory = useRef<string[]>([]);
const currentWorkspace = useRef(workspace);
const recalled = useRef<{ index: number; text: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [editing, setEditing] = useState(false);
const [draggingFiles, setDraggingFiles] = useState(false);
const dragDepth = useRef(0);
useEffect(() => {
setDraggingFiles(false);
dragDepth.current = 0;
}, [sessionId]);
const [queueConfirmation, setQueueConfirmation] = useState(false);
const submitLock = useRef(false);
const queueSendLocks = useRef(new Set<string>());
const composing = useRef(false);
const textarea = useRef<MentionEditorHandle>(null);
const root = useRef<HTMLFieldSetElement>(null);
const addButton = useRef<HTMLButtonElement>(null);
const filePanel = useRef<HTMLDivElement>(null);
const filePanelId = useId();
useEffect(() => {
if (onCancel) {
const frame = requestAnimationFrame(() => textarea.current?.focus());
return () => cancelAnimationFrame(frame);
}
}, [onCancel]);
const [inputQuery, setInputQuery] = useState<{
start: number;
end: number;
trigger: "/" | "$" | "#";
query: string;
}>();
const dismissedInput = useRef<string | undefined>(undefined);
const sessionsEnabled = inputQuery?.trigger === "#";
const [sessionCatalog, setSessionCatalog] = useState<{
id: string;
items: SessionMentionSuggestion[];
error?: string;
}>();
useEffect(() => {
setSessionCatalog(undefined);
if (!sessionsEnabled || !context?.listSessionMentions) return;
let active = true;
void context.listSessionMentions(sessionId).then(
(items) => {
if (active) setSessionCatalog({ id: sessionId, items });
},
(error) => {
if (active) setSessionCatalog({ id: sessionId, items: [], error: userErrorMessage(error) });
},
);
return () => {
active = false;
};
}, [sessionsEnabled, sessionId, context]);
const sessionLoading =
sessionsEnabled && Boolean(context?.listSessionMentions) && sessionCatalog?.id !== sessionId;
const sessionError = sessionsEnabled && sessionCatalog?.id === sessionId ? sessionCatalog.error : undefined;
const updateValue = (id: string, change: (old: ComposerDraft) => ComposerDraft) => {
const old = drafts.current.get(id);
if (!old) return;
const next = change(old);
if (next === old) return;
drafts.current.set(id, next);
};
const updateDraft = (text: string) => updateValue(currentSession.current, (old) => ({ ...old, text }));
const [fileQuery, setFileQuery] = useState<{
start: number;
end: number;
query: string;
explicit?: boolean;
}>();
const [fileMatches, setFileMatches] = useState<Awaited<ReturnType<ComposerContext["searchFiles"]>>>([]);
const [fileError, setFileError] = useState("");
const [fileLoading, setFileLoading] = useState(false);
// @ 原先只路由到文件搜索,完整名称却会被编辑器渲染为机器人标签,导致候选与展示脱节。
// 与 / 共用当前会话的有效目录;+ 添加面板仍只提供附件和文件。
const agentMatches = useMemo(
() => (fileQuery && !fileQuery.explicit ? filterSubagentSuggestions(suggestions, fileQuery.query) : []),
[suggestions, fileQuery],
);
const fileOptions = useMemo(
() => (fileQuery?.explicit ? [null, ...fileMatches] : [...fileMatches, ...agentMatches]),
[fileQuery?.explicit, fileMatches, agentMatches],
);
useLayoutEffect(() => {
if (!fileQuery?.explicit) return;
const panel = filePanel.current;
const composer = root.current;
if (!panel || !composer) return;
const clippingContainers: HTMLElement[] = [];
for (let container = composer.parentElement; container; container = container.parentElement)
if (["hidden", "clip", "auto", "scroll"].includes(getComputedStyle(container).overflowY))
clippingContainers.push(container);
const fit = () => {
const top = Math.max(
8,
...clippingContainers.map((container) => container.getBoundingClientRect().top + container.clientTop),
);
const footerHeight = panel.querySelector(".composer-add-hint")?.getBoundingClientRect().height ?? 0;
panel.style.setProperty(
"--composer-add-list-height",
`${Math.max(0, composer.getBoundingClientRect().top - top - footerHeight - 6)}px`,
);
};
fit();
panel.focus();
const observer = new ResizeObserver(fit);
observer.observe(composer);
for (const container of clippingContainers) observer.observe(container);
window.addEventListener("resize", fit);
return () => {
observer.disconnect();
window.removeEventListener("resize", fit);
};
}, [fileQuery?.explicit]);
useEffect(() => {
let active = true;
setFileMatches([]);
setFileError("");
setFileLoading(Boolean(fileQuery && context));
if (fileQuery && context)
void context
.searchFiles(sessionId, fileQuery.query)
.then((files) => {
if (active) {
setFileMatches(files);
setFileLoading(false);
}
})
.catch((e) => {
if (active) {
setFileError(userErrorMessage(e));
setFileLoading(false);
}
});
return () => {
active = false;
};
}, [sessionId, fileQuery?.query, fileQuery?.explicit, context]);
const insertFileMention = (
file: { name: string; absolutePath: string; type: "file" | "directory" },
start: number,
end: number,
) => {
const markdown = buildMentionMarkdown(
file.name,
file.absolutePath + (file.type === "directory" ? "/" : ""),
);
updateValue(sessionId, (old) => ({
...old,
text: `${old.text.slice(0, start) + markdown} ${old.text.slice(end)}`,
fileReferences: [],
}));
textarea.current?.focus();
textarea.current?.setSelectionRange(start + markdown.length + 1, start + markdown.length + 1);
};
const chooseFile = (file: (typeof fileMatches)[number]) => {
if (!fileQuery) return;
insertFileMention(file, fileQuery.start, fileQuery.end);
setFileQuery(undefined);
setInputQuery(undefined);
};
const addAttachments = async (operation: () => Promise<import("@zpi-dev/coding-agent").Attachment[]>) => {
const id = sessionId;
updateValue(id, (old) => ({ ...old, pending: old.pending + 1, error: undefined }));
try {
const images = await operation();
const old = drafts.current.get(id);
if (!old) {
await Promise.all(images.map((image) => context?.removeAttachment(id, image.id).catch(() => {})));
return;
}
if (old.attachments.length + images.length > imageLimits.count) {
await Promise.all(images.map((image) => context?.removeAttachment(id, image.id)));
throw new Error(attachmentLimitMessage);
}
updateValue(id, (old) => ({ ...old, attachments: [...old.attachments, ...images] }));
} catch (e) {
updateValue(id, (old) => ({ ...old, error: userErrorMessage(e) }));
} finally {
updateValue(id, (old) => ({ ...old, pending: Math.max(0, old.pending - 1) }));
}
};
const importFiles = async (files: File[]) => {
const imported: import("@zpi-dev/coding-agent").Attachment[] = [];
if (!context) return imported;
const id = sessionId;
try {
for (const file of files) imported.push(await context.importAttachment(id, file));
return imported;
} catch (error) {
await Promise.all(imported.map((attachment) => context.removeAttachment(id, attachment.id)));
throw error;
}
};
const chooseAttachment = () => {
setFileQuery(undefined);
setInputQuery(undefined);
const id = sessionId;
if (context)
void addAttachments(() => context.pickAttachments(id)).finally(() => {
if (currentSession.current === id) textarea.current?.focus();
});
};
const chooseFileOption = () => {
const option = fileOptions[highlighted] ?? fileOptions[0];
if (option) {
if ("insert" in option) chooseAgent(option);
else chooseFile(option);
} else if (option === null) chooseAttachment();
};
const currentSession = useRef(sessionId);
const [highlighted, setHighlighted] = useState(0);
const commandNames = useMemo(() => customCommandNames(suggestions), [suggestions]);
const agentNames = useMemo(() => subagentNames(suggestions), [suggestions]);
const slash = useMemo(
() => slashSuggestions(suggestions, inputQuery?.query ?? ""),
[suggestions, inputQuery?.query],
);
const matches = useMemo(
() =>
inputQuery
? inputQuery.trigger === "#"
? filterSessionSuggestions(
sessionCatalog?.id === sessionId ? sessionCatalog.items : [],
inputQuery.query,
)
: inputQuery.trigger === "$"
? filterSkillSuggestions(suggestions, inputQuery.query)
: slash.options
: [],
[inputQuery, suggestions, sessionCatalog, sessionId, slash],
);
useLayoutEffect(() => {
if (inputQuery?.trigger === "/") setHighlighted(slash.selectedIndex);
}, [inputQuery?.trigger, slash]);
const menuOpen = Boolean(inputQuery);
const insertAt = (suggestion: InputSuggestion, start: number, end: number) => {
const inserted = suggestion.insert;
updateDraft(draft.slice(0, start) + inserted + draft.slice(end));
setInputQuery(undefined);
setFileQuery(undefined);
dismissedInput.current = undefined;
// The editor applies this caret when it renders the new text, before another user selection.
textarea.current?.focus();
textarea.current?.setSelectionRange(start + inserted.length, start + inserted.length);
};
const insert = (suggestion: InputSuggestion) => {
if (!inputQuery) return;
const { start } = inputQuery;
const end =
inputQuery.trigger === "/"
? slashTokenEnd(draft, inputQuery.end, inputQuery.query, suggestion.name)
: inputQuery.end;
insertAt(suggestion, start, end);
};
const chooseAgent = (suggestion: InputSuggestion) => {
if (!fileQuery || fileQuery.explicit) return;
insertAt(
suggestion,
fileQuery.start,
slashTokenEnd(draft, fileQuery.end, fileQuery.query, suggestion.name),
);
};
useLayoutEffect(() => {
currentSession.current = sessionId;
if (!drafts.current.has(sessionId)) drafts.current.set(sessionId, emptyDraft());
setValue(drafts.current.get(sessionId) ?? emptyDraft());
setFileQuery(undefined);
setInputQuery(undefined);
setQueueConfirmation(false);
dismissedInput.current = undefined;
currentWorkspace.current = workspace;
promptHistory.current = readPromptHistory(workspace, sentMessages);
recalled.current = null;
}, [sessionId, workspace]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
if (
Array.from(
document.querySelectorAll<HTMLElement>(
'dialog[open], [role="dialog"], [role="menu"], [role="listbox"], .settings-screen',
),
).some(
(element) =>
!element.closest('[hidden], [inert], [aria-hidden="true"]') &&
element.getClientRects().length > 0 &&
!["hidden", "collapse"].includes(getComputedStyle(element).visibility),
)
)
return;
const positions = drafts.current.get(sessionId)?.selection;
textarea.current?.focus();
if (positions) textarea.current?.setSelectionRange(...positions);
});
return () => cancelAnimationFrame(frame);
}, [sessionId]);
useEffect(
() =>
drafts.current.subscribe((id) => {
if (id === currentSession.current) setValue(drafts.current.get(id) ?? emptyDraft());
}),
[],
);
useEffect(() => {
const close = (e: PointerEvent) => {
if (!(e.target instanceof Node)) return;
if (!root.current?.contains(e.target)) {
setInputQuery(undefined);
setFileQuery(undefined);
} else if (
fileQuery?.explicit &&
!filePanel.current?.contains(e.target) &&
!addButton.current?.contains(e.target)
) {
setFileQuery(undefined);
}
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [fileQuery?.explicit]);
const hasDraft = Boolean(
draft.trim() ||
value.attachments.length ||
value.fileReferences.length ||
value.selections?.length ||
value.pending,
);
const submit = async (
queueDisposition?: "keep" | "clear",
workspaceMode?: "preserve" | "rewind",
immediate = false,
) => {
if (
editing ||
submitLock.current ||
drafts.current.submitting.has(sessionId) ||
submitting ||
value.pending ||
(!draft.trim() &&
!value.attachments.length &&
!value.fileReferences.length &&
!value.selections?.length)
)
return;
const submitted = value,
submittedSession = sessionId,
submittedRevision = drafts.current.revision(sessionId),
submittedHistory = promptHistory.current;
submitLock.current = true;
drafts.current.submitting.add(submittedSession);
setSubmitting(true);
updateValue(sessionId, (old) => ({ ...old, error: undefined }));
try {
const result = await onSubmit(
buildSelectionPrompt(submitted.text, submitted.selections ?? []),
{
...(workspaceMode ? { workspaceMode } : {}),
...(queueDisposition ? { queueDisposition } : {}),
...(immediate ? { immediate } : {}),
fileReferences: [
...new Set([
...submitted.fileReferences,
...parseMentions(submitted.text)
.filter((m) => m.kind === "file")
.map((m) => m.path),
]),
],
attachments: submitted.attachments.map((e) => e.id),
},
{ draftRevision: submittedRevision },
);
if (result === "confirmationRequired") {
if (currentSession.current === submittedSession) setQueueConfirmation(true);
return;
}
if (result === "blocked") return;
setQueueConfirmation(false);
const entries = appendPromptHistory(readPromptHistory(workspace, submittedHistory), submitted.text);
savePromptHistory(workspace, entries);
if (currentWorkspace.current === workspace) {
promptHistory.current = entries;
recalled.current = null;
}
const current = drafts.current.get(submittedSession);
if (current) {
const unchanged =
drafts.current.revision(submittedSession) === submittedRevision &&
current.text === submitted.text &&
JSON.stringify(current.fileReferences) === JSON.stringify(submitted.fileReferences) &&
JSON.stringify(current.selections) === JSON.stringify(submitted.selections);
const usedImages = new Set(submitted.attachments.map((image) => image.id));
const next = {
...current,
error: undefined,
...(unchanged
? { text: "", fileReferences: [], selections: [], selection: [0, 0] as [number, number] }
: {}),
attachments: current.attachments.filter((image) => !usedImages.has(image.id)),
};
drafts.current.set(submittedSession, next);
if (unchanged) {
drafts.current.history(submittedSession).clear();
if (currentSession.current === submittedSession) {
setFileQuery(undefined);
setInputQuery(undefined);
}
}
}
} catch {
/* Caller displays the business error; preserve the complete draft. */
} finally {
submitLock.current = false;
drafts.current.submitting.delete(submittedSession);
setSubmitting(false);
}
};
const editQueued = async (itemId: string) => {
if (!queueActions || editing || submitLock.current) return;
const id = sessionId,
current = drafts.current.get(id);
if (
current &&
(current.text.trim() ||
current.fileReferences.length ||
current.selections?.length ||
current.attachments.length ||
current.pending)
)
throw new Error("请先发送或清空当前草稿,再编辑队列消息。");
setEditing(true);
submitLock.current = true;
drafts.current.submitting.add(id);
try {
const restored = await queueActions.edit(itemId);
drafts.current.set(id, restored);
if (currentSession.current === id) {
requestAnimationFrame(() => {
if (currentSession.current !== id) return;
textarea.current?.focus();
textarea.current?.setSelectionRange(restored.text.length, restored.text.length);
});
}
} finally {
drafts.current.submitting.delete(id);
submitLock.current = false;
setEditing(false);
}
};
const sendQueuedNow = async (itemId: string) => {
const id = sessionId;
if (!queueActions || editing || submitLock.current || queueSendLocks.current.has(id)) return;
queueSendLocks.current.add(id);
try {
await queueActions.sendNow(itemId);
} finally {
queueSendLocks.current.delete(id);
}
};
return (
<form
onSubmit={(event) => event.preventDefault()}
className={`composer-stack${header ? " with-header" : ""}`}
onKeyDownCapture={(event) => {
// Capture before the editor's Enter handling. Portal menus belong to the
// React tree too, but only keys from this focused composer are commands.
if (
event.defaultPrevented ||
composing.current ||
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
!root.current?.contains(event.target as Node) ||
!root.current.contains(document.activeElement)
)
return;
const command = shortcutCommand?.(event.nativeEvent);
if (!command) return;
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
if (command === "select-model") onSelectModel?.();
else if (command === "configure-environment") onConfigureEnvironment?.();
else if (hasDraft) void submit(undefined, undefined, true);
else {
const first = queue?.items[0];
if (first?.state === "queued")
void sendQueuedNow(first.id).catch((error) =>
updateValue(sessionId, (old) => ({ ...old, error: userErrorMessage(error) })),
);
}
}}
onKeyDown={(event) => {
if (
onCancel &&
!submitting &&
event.key === "Escape" &&
!event.defaultPrevented &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
onCancel();
}
}}
>
{header}
{queue && queueActions && (
<ConversationQueuePanel
key={sessionId}
queue={queue}
actions={{ ...queueActions, edit: editQueued, sendNow: sendQueuedNow }}
onError={(error) => updateValue(sessionId, (old) => ({ ...old, error: userErrorMessage(error) }))}
/>
)}
<fieldset
className={`composer${draggingFiles ? " dragging-files" : ""}`}
data-layout={layout}
ref={root}
aria-label="输入消息"
onDragEnter={(event) => {
if (!context || editing || !event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
dragDepth.current += 1;
setDraggingFiles(true);
}}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (!dragDepth.current) setDraggingFiles(false);
}}
onDragOver={(event) => {
if (
context &&
(event.dataTransfer.types.includes("Files") ||
event.dataTransfer.types.includes("application/x-zpi-workspace-file"))
)
event.preventDefault();
}}
onDrop={(event) => {
dragDepth.current = 0;
setDraggingFiles(false);
if (!context || editing) return;
const workspaceFile = event.dataTransfer.getData("application/x-zpi-workspace-file");
if (workspaceFile) {
event.preventDefault();
try {
const file = JSON.parse(workspaceFile);
if (
typeof file.path !== "string" ||
!file.path ||
file.path.includes("\0") ||
typeof file.name !== "string" ||
!["file", "directory"].includes(file.type)
)
return;
const start = textarea.current?.selectionStart ?? draft.length;
const end = textarea.current?.selectionEnd ?? start;
insertFileMention({ name: file.name, absolutePath: file.path, type: file.type }, start, end);
} catch {
/* Ignore malformed drag data from another application. */
}
return;
}
const files = Array.from(event.dataTransfer.files);
if (!files.length) return;
event.preventDefault();
setFileQuery(undefined);
setInputQuery(undefined);
const id = sessionId;
void addAttachments(() => importFiles(files)).finally(() => {
if (currentSession.current === id) textarea.current?.focus();
});
}}
>
{draggingFiles && (
<div className="composer-drop-overlay" role="status">
<div>
<Hand size={16} aria-hidden="true" />
<span>松开以添加附件</span>
</div>
</div>
)}
<div className="input-context">
{context && value.attachments.length > 0 && (
<div className="input-context-row" data-composer-file-attachments-row="">
{value.attachments.map((image) => (
<AttachmentPreview
key={image.id}
sessionId={sessionId}
image={image}
read={context.readAttachment}
remove={() => {
void context
.removeAttachment(sessionId, image.id)
.then(() =>
updateValue(sessionId, (old) => ({
...old,
attachments: old.attachments.filter((e) => e.id !== image.id),
})),
)
.catch((e) =>
updateValue(sessionId, (old) => ({ ...old, error: userErrorMessage(e) })),
);
}}
/>
))}
</div>
)}
{Boolean(value.selections?.length) && (
<div className="input-context-row" data-composer-context-attachments-row="">
<SelectionReferenceChip
references={value.selections ?? []}
onCommentsEmpty={() => textarea.current?.focus()}
onFile={onFile}
onChange={(selections) => updateValue(sessionId, (old) => ({ ...old, selections }))}
/>
</div>
)}
</div>
{value.pending > 0 && <small role="status">正在处理附件…</small>}
{fileQuery && (
<div
id={filePanelId}
className={`command-panel${fileQuery.explicit ? " composer-add-panel" : ""}`}
role="listbox"
aria-label={fileQuery.explicit ? "添加上下文" : "引用上下文"}
tabIndex={-1}
ref={filePanel}
onKeyDown={(e) => {
if (!fileQuery.explicit || e.nativeEvent.isComposing || e.keyCode === 229) return;
if (!["Escape", "ArrowDown", "ArrowUp", "Enter", "Tab", " "].includes(e.key)) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setFileQuery(undefined);
addButton.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
setHighlighted(
(n) => (n + (e.key === "ArrowDown" ? 1 : -1) + fileOptions.length) % fileOptions.length,
);
} else chooseFileOption();
}}
>
<SuggestionOptions selectedIndex={highlighted} options={fileOptions}>
{fileQuery.explicit && (
<>
<div className="composer-add-heading">添加</div>
<button
type="button"
role="option"
aria-selected={highlighted === 0}
className="composer-attachment-option"
onMouseDown={(e) => e.preventDefault()}
onClick={chooseAttachment}
>
<Paperclip size={16} aria-hidden="true" />
<strong>附件</strong>
</button>
<div className="composer-add-heading">文件</div>
</>
)}
{fileError && <p role="alert">{fileError}</p>}
{fileLoading && <p role="status">正在搜索…</p>}
{!fileQuery.explicit && fileMatches.length > 0 && (
<div className="command-section-title">文件</div>
)}
{fileMatches.map((file, i) => (
<button
type="button"
role="option"
aria-selected={i + (fileQuery.explicit ? 1 : 0) === highlighted}
className="file-option"
key={file.path}
onMouseDown={(e) => e.preventDefault()}
onClick={() => chooseFile(file)}
>
<FileIcon path={file.path + (file.type === "directory" ? "/" : "")} />
<strong>{file.name}</strong>
{file.path.includes("/") && (
<span>{file.path.slice(0, file.path.lastIndexOf("/") + 1)}</span>
)}
</button>
))}
{agentMatches.length > 0 && <div className="command-section-title">子智能体</div>}
{agentMatches.map((suggestion, index) => (
<button
key={suggestion.insert}
className="subagent-option"
type="button"
role="option"
aria-selected={fileMatches.length + index === highlighted}
onMouseDown={(event) => event.preventDefault()}
onClick={() => chooseAgent(suggestion)}
>
<Bot size={14} aria-hidden="true" />
<strong>{suggestion.name}</strong>
<span>{suggestion.description}</span>
</button>
))}
{!fileMatches.length && !agentMatches.length && !fileError && !fileLoading && (
<p>{fileQuery.explicit ? "暂无匹配文件" : "暂无匹配文件或子智能体"}</p>
)}
</SuggestionOptions>
{fileQuery.explicit ? (
<div className="composer-add-hint">
{[
["@", "添加上下文"],
["/", "选择能力"],
["$", "选择技能"],
].map(([key, label]) => (
<span key={key}>
<code>{key}</code>
{label}
</span>
))}
<span>
<Info size={16} aria-hidden="true" />
输入内容以搜索文件
</span>
</div>
) : !fileQuery.query ? (
<div className="command-hint">
<Info size={16} aria-hidden="true" />
在输入框中输入 @ 搜索文件或子智能体
</div>
) : null}
</div>
)}
{menuOpen && !fileQuery && (
<div
className={`command-panel${inputQuery?.trigger === "$" ? " skill-panel" : sessionsEnabled ? " session-panel" : " slash-panel"}`}
>
<SuggestionOptions
selectedIndex={highlighted}
options={matches}
label={sessionsEnabled ? "会话" : inputQuery?.trigger === "$" ? "技能" : "指令"}
>
{sessionError && <p role="alert">{sessionError}</p>}
{sessionLoading && <p role="status">正在加载…</p>}
{!matches.length && inputQuery?.trigger !== "/" && !sessionError && !sessionLoading && (
<p>
{sessionsEnabled
? "没有匹配的近期会话"
: `暂无匹配${inputQuery?.trigger === "$" ? "技能" : "指令"}`}
</p>
)}
{(inputQuery?.trigger === "/"
? slash.sections
: [{ label: "", options: matches, offset: 0 }]
).map((section) => (
<Fragment key={section.label}>
{section.label && <div className="command-section-title">{section.label}</div>}
{section.label && !section.options.length && <p>暂无匹配{section.label}</p>}
{section.options.map((suggestion, index) => (
<button
key={`${suggestion.group}:${suggestion.insert}`}
type="button"
role="option"
aria-selected={section.offset + index === highlighted}
title={
suggestion.argumentHint ? `/${suggestion.name} ${suggestion.argumentHint}` : undefined
}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insert(suggestion)}
>
{inputQuery?.trigger === "#" ? (
<MessagesSquare size={14} aria-hidden="true" />
) : inputQuery?.trigger === "$" ? (
<WandSparkles size={14} aria-hidden="true" />
) : null}
<strong>
{inputQuery?.trigger === "/" ? slashSuggestionLabel(suggestion) : suggestion.name}
</strong>
<span>{suggestion.description}</span>
</button>
))}
</Fragment>
))}
</SuggestionOptions>
{!inputQuery?.query && (
<div className="command-hint">
<Info size={16} aria-hidden="true" />
输入内容以搜索{sessionsEnabled ? "近期会话" : inputQuery?.trigger === "$" ? "技能" : "指令"}
</div>
)}
</div>
)}
{value.warnings?.map((warning) => (
<small key={warning} role="status" className="draft-warning">
{warning}
</small>
))}
<MentionEditor
key={sessionId}
ref={textarea}
onFile={(path) => {
const file = splitFileLocation(path);
onFile?.(file?.path ?? path, file?.location);
}}
history={drafts.current.history(sessionId)}
initialSelection={value.selection}
onSelection={(selection) =>
updateValue(sessionId, (old) => {
if (old.selection?.[0] === selection[0] && old.selection?.[1] === selection[1]) return old;
return { ...old, selection };
})
}
value={draft}
commandNames={commandNames}
subagentNames={agentNames}
onBlur={() => {
setInputQuery(undefined);
dismissedInput.current = undefined;
}}
disabled={editing}
placeholder={busy ? "继续输入以排队后续修改" : sentMessages.length ? "提出后续修改要求" : undefined}
onPaste={(e) => {
if (!context) return;
const images = Array.from(e.clipboardData.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((f): f is File => Boolean(f));
if (!images.length) return;
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
const start = textarea.current?.selectionStart ?? draft.length,
end = textarea.current?.selectionEnd ?? start;
if (text) {
updateDraft(draft.slice(0, start) + text + draft.slice(end));
textarea.current?.setSelectionRange(start + text.length, start + text.length);
}
void addAttachments(() => importFiles(images));
}}
onChange={(text, caret) => {
if (text !== recalled.current?.text) recalled.current = null;
updateValue(sessionId, (old) => ({
...old,
text,
warnings: old.warnings?.filter(
(warning) =>
parseMentions(text).some((mention) => warning.endsWith(mention.path)) ||
old.fileReferences.some((path) => warning.endsWith(path)),
),
selection: [caret, caret],
}));
const prefix = text.slice(0, caret);
setFileQuery(context || agentNames.length ? fileMentionQuery(prefix) : undefined);
const command =
/(?:^|\s)(\/)([^\s@$#¥¥]*)$/.exec(prefix) ?? /(?:^|\s)([$#¥¥])([^\s/@$#¥¥]*)$/.exec(prefix);
const signature = command ? `${command[1]}:${command[2]}` : undefined;
if (signature !== dismissedInput.current) dismissedInput.current = undefined;
setInputQuery(
command && (!signature || signature !== dismissedInput.current)
? {
start: caret - command[2].length - 1,
end: caret,
trigger: command[1] === "/" ? "/" : command[1] === "#" ? "#" : "$",
query: command[2],
}
: undefined,
);
setHighlighted(0);
}}
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={(e) => {
const isComposing = composing.current || e.nativeEvent.isComposing || e.keyCode === 229;
if (!isComposing && (fileQuery || inputQuery)) {
const options = fileQuery ? fileOptions : matches;
if (e.key === "Escape") {
e.preventDefault();
if (inputQuery) dismissedInput.current = `${inputQuery.trigger}:${inputQuery.query}`;
setFileQuery(undefined);
setInputQuery(undefined);
return;
}
if (options.length && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
setHighlighted((n) => {
const next = n + (e.key === "ArrowDown" ? 1 : -1);
return inputQuery?.trigger === "/"
? Math.max(0, Math.min(next, options.length - 1))
: (next + options.length) % options.length;
});
return;
}
if (
options.length &&
((e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) ||
e.key === "Tab")
) {
e.preventDefault();
if (fileQuery) chooseFileOption();
else {
const suggestion = matches[highlighted] ?? matches[0];
if (suggestion) insert(suggestion);
}
return;
}
}
if (
(e.key === "ArrowUp" || e.key === "ArrowDown") &&
!e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!isComposing &&
(draft.length === 0 || recalled.current?.text === draft) &&
!value.attachments.length &&
!value.fileReferences.length &&
!value.pending &&
promptHistory.current.length
) {
e.preventDefault();
const entries = promptHistory.current;
const at = recalled.current?.index;
const index =
at === undefined ? entries.length - 1 : e.key === "ArrowUp" ? Math.max(0, at - 1) : at + 1;
const text = entries[index] ?? "";
recalled.current = text ? { index, text } : null;
updateValue(sessionId, (old) => ({
...old,
text,
error: undefined,
warnings: undefined,
selection: [text.length, text.length],
}));
setFileQuery(undefined);
setInputQuery(undefined);
textarea.current?.setSelectionRange(text.length, text.length);
return;
}
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && !isComposing) {
e.preventDefault();
void submit();
}
}}
/>
<div className="composer-footer">
<ActionHint label="添加上下文" appearance="control" open={fileQuery?.explicit ? false : undefined}>
<button
ref={addButton}
type="button"
className="composer-plus"
aria-label="添加上下文"
aria-haspopup="listbox"
aria-expanded={Boolean(fileQuery?.explicit)}
aria-controls={fileQuery?.explicit ? filePanelId : undefined}
disabled={editing || !context}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (fileQuery?.explicit) {
setFileQuery(undefined);
textarea.current?.focus();
} else {
setFileQuery({
start: textarea.current?.selectionStart ?? draft.length,
end: textarea.current?.selectionEnd ?? draft.length,
query: "",
explicit: true,
});
setInputQuery(undefined);
setHighlighted(0);
}
}}
>
<Plus size={16} aria-hidden="true" />
</button>
</ActionHint>
<div className="composer-actions">
{toolbar}
{value.error && (
<InputNotice
key={`${sessionId}:${drafts.current.errorVersion(sessionId)}`}
message={userErrorMessage(value.error)}
onDismiss={() => updateValue(sessionId, (old) => ({ ...old, error: undefined }))}
/>
)}
{onCancel && (
<MessageAction label="取消" disabled={submitting} onClick={onCancel}>
<X size={16} />
</MessageAction>
)}
{resetFiles && (
<ActionHint label="与文件一起重置" description={resetFiles.description} side="top">
<span className="edit-rewind-tooltip">
<button
type="button"
className="edit-rewind"
aria-label="对话 + 文件重置"
disabled={submitting || resetFiles.disabled || !hasDraft || value.pending > 0}
onClick={() => void submit(undefined, "rewind")}
>
<FileClock size={16} />
</button>
</span>
</ActionHint>
)}
{busy && !hasDraft ? (
<ActionHint
label="停止生成"
appearance="control"
open={layout === "inline" ? false : undefined}
>
<button type="button" className="send stop" aria-label="停止" onClick={onStop}>
<Square size={16} fill="currentColor" />
</button>
</ActionHint>
) : showSendButton || busy ? (
<ActionHint
label={busy ? "加入队列" : "发送"}
appearance="control"
open={layout === "inline" ? false : undefined}
>
<button
className="send"
aria-label={busy ? "加入队列" : "发送"}
disabled={
submitting ||
editing ||
value.pending > 0 ||
(!draft.trim() &&
!value.attachments.length &&
!value.fileReferences.length &&
!value.selections?.length)
}
onClick={() => void submit()}
>
<ArrowUp size={16} />
</button>
</ActionHint>
) : null}
</div>
</div>
</fieldset>
{queueConfirmation && (
<dialog
className="queue-confirmation"
ref={(element) => {
if (element && !element.open) element.showModal();
}}
onCancel={() => setQueueConfirmation(false)}
>
<h3>发送消息?</h3>
<p>你即将发送一条消息。要清除之前已排队的 {queue?.items.length ?? 0} 条消息吗?</p>
<div>
<button className="secondary" onClick={() => setQueueConfirmation(false)}>
取消
</button>
<button className="secondary" disabled={submitting} onClick={() => void submit("clear")}>
清空队列
</button>
<button disabled={submitting} className="primary" onClick={() => void submit("keep")}>
发送消息
</button>
</div>
</dialog>
)}
</form>
);
}