返回 DeepSeek-Reasonix
Message.tsx
根目录 / desktop / frontend / src / components / Message.tsx
1 import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2 import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
3 import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
4 import { Markdown } from "./Markdown";
5 import { CopyButton } from "./CopyButton";
6 import { ProcessBrainIcon } from "./ProcessCard";
7 import { ComposerContextCard } from "./ComposerContextCard";
8 import { formatAttachmentRefForDisplay, formatAttachmentRefForSubmit, parseAttachmentRefsForDisplay, sortDisplayAttachments } from "../lib/attachmentDisplay";
9 import type { DisplayAttachment } from "../lib/attachmentDisplay";
10 import { app } from "../lib/bridge";
11 import { replaySubmitTextPreservingSelectedContext } from "../lib/editReplay";
12 import { useT } from "../lib/i18n";
13 import { ImageViewer } from "./ImageViewer";
14 import { Tooltip } from "./Tooltip";
15 import { useGSAPCollapse } from "../lib/useGSAPCollapse";
16 import { displayReasoningText } from "../lib/reasoningDisplay";
17 import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
18 import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
19 import { invocationSegmentsFromMessage, type InvocationMetadataMap } from "../lib/invocationDisplay";
20 import type { Item, MessageActionScope } from "../lib/useController";
21 import type { CheckpointMeta, MemoryCitation } from "../lib/types";
22 import { InvocationBadge } from "./InvocationBadge";
23 import { CodeViewer } from "./CodeViewer";
24 import { formatSelectionLabels, languageFor, parseSelectedTextContext, stripSelectionLabels } from "../lib/selectedTextContext";
25
26 type AssistantItem = Extract<Item, { kind: "assistant" }>;
27 export type TurnActionMenu = "summary" | "rewind";
28 export const InvocationMetadataContext = createContext<InvocationMetadataMap>({});
29 type ImSourceMessage = {
30 provider: string;
31 label: string;
32 sender: string;
33 chat: string;
34 text: string;
35 };
36
37 const IM_SOURCE_START = "[[reasonix-im]]";
38 const IM_SOURCE_END = "[[/reasonix-im]]";
39
40 function parseImSourceMessage(text: string): ImSourceMessage | null {
41 // Display-only metadata: keep IM sender/chat details out of model prompts.
42 if (!text.startsWith(IM_SOURCE_START)) return null;
43 const end = text.indexOf(IM_SOURCE_END);
44 if (end < 0) return null;
45 const metaBlock = text.slice(IM_SOURCE_START.length, end).trim();
46 const body = text.slice(end + IM_SOURCE_END.length).replace(/^\r?\n/, "");
47 const meta: Record<string, string> = {};
48 for (const line of metaBlock.split(/\r?\n/)) {
49 const index = line.indexOf("=");
50 if (index <= 0) continue;
51 const key = line.slice(0, index).trim().toLowerCase();
52 const value = line.slice(index + 1).trim();
53 if (key) meta[key] = value;
54 }
55 return {
56 provider: meta.provider || "",
57 label: meta.label || "",
58 sender: meta.sender || meta.senderid || "",
59 chat: meta.chat || meta.chat_type || "",
60 text: body,
61 };
62 }
63
64 function imSourceLabel(source: ImSourceMessage, t: ReturnType<typeof useT>): string {
65 if (source.label.trim()) return source.label.trim();
66 const provider = source.provider.trim().toLowerCase();
67 if (provider === "lark") return "Lark";
68 if (provider === "weixin" || provider === "wechat") return t("settings.botWeixin");
69 return t("settings.botFeishu");
70 }
71
72 function attachmentIcon(kind: "image" | "file" | "folder") {
73 if (kind === "image") return <Image size={15} />;
74 if (kind === "folder") return <Folder size={15} />;
75 return <FileText size={15} />;
76 }
77
78 function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: DisplayAttachment[]): DisplayAttachment[] {
79 if (incoming.length === 0) return existing;
80 const seen = new Set(existing.map((attachment) => attachment.path));
81 const merged = [...existing];
82 for (const attachment of incoming) {
83 if (seen.has(attachment.path)) continue;
84 seen.add(attachment.path);
85 merged.push(attachment);
86 }
87 return merged;
88 }
89
90 type PastedBlockInfo = {
91 label: string;
92 content: string;
93 };
94
95 const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
96
97 export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
98 const labels = text.match(PASTE_LABEL_RE);
99 if (!labels || labels.length === 0 || !submitText) return [];
100 const unique = [...new Set(labels)];
101 const blocks: PastedBlockInfo[] = [];
102 for (const label of unique) {
103 const beginMarker = `--- Begin ${label} ---`;
104 const endMarker = `--- End ${label} ---`;
105 const beginIdx = submitText.indexOf(beginMarker);
106 const endIdx = submitText.indexOf(endMarker);
107 if (beginIdx < 0 || endIdx <= beginIdx) continue;
108 const contentStart = beginIdx + beginMarker.length;
109 const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
110 blocks.push({ label, content });
111 }
112 return blocks;
113 }
114
115 export type SelectedTextBlockInfo = {
116 label: string;
117 content: string;
118 path?: string;
119 start: number;
120 end: number;
121 kind: "chat" | "code";
122 };
123
124 export function parseSelectedTextBlocks(text: string, submitText?: string): SelectedTextBlockInfo[] {
125 const entries = parseSelectedTextContext(submitText);
126 if (entries.length === 0) return [];
127 const suffix = formatSelectionLabels(entries);
128 if (!suffix || !text.endsWith(suffix)) return [];
129
130 // Composer owns the exact trailing label suffix. Deriving it from the JSON
131 // entries avoids consuming label-shaped or unterminated authored prose.
132 let start = text.length - suffix.length;
133 return entries.map((entry) => {
134 const label = formatSelectionLabels([entry]);
135 const kind = entry.path ? "code" : "chat";
136 const block = {
137 label,
138 content: entry.text,
139 path: entry.path,
140 start,
141 end: start + label.length,
142 kind,
143 } satisfies SelectedTextBlockInfo;
144 start = block.end + 1;
145 return block;
146 });
147 }
148
149 function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
150 const t = useT();
151 const bodyRef = useRef<HTMLDivElement>(null);
152 const [open, setOpen] = useState(false);
153 const clean = visibleTranscriptMemoryCitations(citations)
154 .filter((citation) => (citation.source ?? citation.id ?? citation.note ?? "").trim() !== "")
155 .slice(0, 5);
156 useGSAPCollapse(bodyRef, open);
157 if (clean.length === 0) return null;
158 return (
159 <div className="msg-memory-citations">
160 <button
161 type="button"
162 className="msg-memory-citations__toggle"
163 aria-expanded={open}
164 onClick={() => setOpen((value) => !value)}
165 >
166 <ChevronRight className={`msg-memory-citations__chevron${open ? " msg-memory-citations__chevron--open" : ""}`} size={15} />
167 <span>{t("msg.memoryCompilerCitationsCount", { n: clean.length })}</span>
168 </button>
169 {open && (
170 <div ref={bodyRef} className="msg-memory-citations__body">
171 {clean.map((citation, index) => {
172 const lines = memoryCitationLines(citation, t);
173 return (
174 <div key={`${citation.id ?? citation.source}-${index}`} className="msg-memory-citations__item">
175 <div className="msg-memory-citations__source">
176 <span>{memoryCitationSource(citation)}</span>
177 {lines && <span className="msg-memory-citations__lines">{lines}</span>}
178 </div>
179 {citation.note && <div className="msg-memory-citations__note">{citation.note}</div>}
180 </div>
181 );
182 })}
183 </div>
184 )}
185 </div>
186 );
187 }
188
189 function memoryCitationSource(citation: MemoryCitation): string {
190 const source = (citation.source || citation.id || "Memory v5").trim();
191 if (citation.kind === "compiler_reference" && source === "Memory v5") return "Memory v5 compiler";
192 return source;
193 }
194
195 function memoryCitationLines(citation: MemoryCitation, t: ReturnType<typeof useT>): string {
196 const start = citation.lineStart ?? 0;
197 const end = citation.lineEnd ?? 0;
198 if (start <= 0) return "";
199 if (end > 0 && end !== start) return t("msg.memoryCitationLineRange", { start, end });
200 return t("msg.memoryCitationLine", { line: start });
201 }
202
203 function messageDate(value?: number): Date {
204 return new Date(typeof value === "number" && Number.isFinite(value) && value > 0 ? value : Date.now());
205 }
206
207 function formatMessageTime(date: Date): string {
208 const hours = String(date.getHours()).padStart(2, "0");
209 const minutes = String(date.getMinutes()).padStart(2, "0");
210 return `${hours}:${minutes}`;
211 }
212
213 export function UserMessage({
214 text,
215 submitText,
216 failed,
217 turn,
218 anchorId,
219 id,
220 createdAt,
221 onEdit,
222 editDisabled = false,
223 }: {
224 text: string;
225 submitText?: string;
226 failed?: boolean;
227 turn?: number;
228 anchorId?: string;
229 id?: string;
230 createdAt?: number;
231 onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
232 editDisabled?: boolean;
233 }) {
234 const t = useT();
235 const invocationMetadata = useContext(InvocationMetadataContext);
236 const imSource = parseImSourceMessage(text);
237 const actionText = stripMemoryCompilerExecution(imSource?.text ?? text);
238 const hasMemoryCompiler = Boolean(submitText?.includes("<memory-compiler-execution>"));
239 const selectedTextEntries = useMemo(() => parseSelectedTextContext(submitText), [submitText]);
240 const editableActionText = stripSelectionLabels(actionText, selectedTextEntries);
241 const { text: editableDisplayText, attachments } = parseAttachmentRefsForDisplay(editableActionText);
242 const selectionLabels = formatSelectionLabels(selectedTextEntries);
243 const displayText = [editableDisplayText, selectionLabels].filter(Boolean).join(editableDisplayText && selectionLabels ? " " : "");
244 const invocationSegments = imSource ? [] : invocationSegmentsFromMessage(displayText, submitText, invocationMetadata);
245 const hasInvocationSegments = invocationSegments.some((segment) => segment.type === "invocation");
246 const orderedAttachments = sortDisplayAttachments(attachments);
247 const sourceLabel = imSource ? imSourceLabel(imSource, t) : "";
248 const sentAt = createdAt === undefined ? null : messageDate(createdAt);
249 const canEdit = turn !== undefined && onEdit !== undefined && !editDisabled;
250 const [editing, setEditing] = useState(false);
251 const [draftText, setDraftText] = useState(editableDisplayText);
252 const [draftAttachments, setDraftAttachments] = useState<DisplayAttachment[]>(attachments);
253 const [editSubmitting, setEditSubmitting] = useState(false);
254 const editRef = useRef<HTMLTextAreaElement>(null);
255 const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
256 const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
257 const openImageViewer = useCallback(async (path: string, name: string) => {
258 let url = imagePreviews[path];
259 if (!url) {
260 try {
261 url = await app.AttachmentDataURL(path);
262 setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
263 } catch {
264 return;
265 }
266 }
267 setImageViewer({ open: true, url, name });
268 }, [imagePreviews]);
269
270 const closeImageViewer = useCallback(() => {
271 setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
272 }, []);
273
274 const pasteBlocks = useMemo(() => parsePastedBlocks(displayText, submitText), [displayText, submitText]);
275 const selectedTextBlocks = useMemo(() => parseSelectedTextBlocks(displayText, submitText), [displayText, submitText]);
276 const [expandedBlockKeys, setExpandedBlockKeys] = useState<Record<string, boolean>>({});
277
278 type DisplaySegment =
279 | { type: "text"; content: string }
280 | { type: "block"; key: string; block: PastedBlockInfo; kind: "paste" }
281 | { type: "block"; key: string; block: SelectedTextBlockInfo; kind: "chat" | "code" };
282
283 const displaySegments = useMemo((): DisplaySegment[] => {
284 if (pasteBlocks.length === 0 && selectedTextBlocks.length === 0) return [{ type: "text", content: displayText }];
285 const segments: DisplaySegment[] = [];
286 const ordered: Array<
287 | { block: PastedBlockInfo; start: number; end: number; kind: "paste" }
288 | { block: SelectedTextBlockInfo; start: number; end: number; kind: "chat" | "code" }
289 > = [
290 ...pasteBlocks.map((block) => {
291 const start = displayText.indexOf(block.label);
292 return { block, start, end: start + block.label.length, kind: "paste" as const };
293 }),
294 ...selectedTextBlocks.map((block) => ({ block, start: block.start, end: block.end, kind: block.kind })),
295 ].filter((block) => block.start >= 0).sort((a, b) => a.start - b.start);
296 let cursor = 0;
297 for (const item of ordered) {
298 if (item.start < cursor) continue;
299 // Text before the label: strip the trailing newline that separated the
300 // label from the preceding line so the card sits tight against the text.
301 if (item.start > cursor) {
302 let before = displayText.slice(cursor, item.start);
303 before = before.replace(/\n$/, "");
304 if (before) segments.push({ type: "text", content: before });
305 }
306 const key = `${item.kind}:${item.start}:${item.block.label}`;
307 if (item.kind === "paste") {
308 segments.push({ type: "block", key, block: item.block, kind: item.kind });
309 } else {
310 segments.push({ type: "block", key, block: item.block, kind: item.kind });
311 }
312 cursor = item.end;
313 }
314 // Strip the leading newline that followed the label.
315 const remaining = displayText.slice(cursor).replace(/^\n/, "");
316 if (remaining.trim()) segments.push({ type: "text", content: remaining });
317 return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
318 }, [displayText, pasteBlocks, selectedTextBlocks]);
319
320 const toggleBlockExpand = (key: string) => {
321 setExpandedBlockKeys((prev) => ({
322 ...prev,
323 [key]: !prev[key],
324 }));
325 };
326 const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
327 const imagePreviewKey = orderedAttachments
328 .concat(orderedDraftAttachments)
329 .filter((attachment) => attachment.kind === "image" && attachment.source === "attachment")
330 .map((attachment) => attachment.path)
331 .join("\n");
332
333 useEffect(() => {
334 if (editing) return;
335 const parsed = parseAttachmentRefsForDisplay(editableActionText);
336 setDraftText(parsed.text);
337 setDraftAttachments(parsed.attachments);
338 }, [editableActionText, editing]);
339
340 useEffect(() => {
341 if (!editing) return;
342 requestAnimationFrame(() => {
343 const node = editRef.current;
344 if (!node) return;
345 node.focus();
346 node.selectionStart = node.selectionEnd = node.value.length;
347 });
348 }, [editing]);
349
350 const startEdit = () => {
351 if (!canEdit) return;
352 const parsed = parseAttachmentRefsForDisplay(editableActionText);
353 setDraftText(parsed.text);
354 setDraftAttachments(parsed.attachments);
355 setEditing(true);
356 };
357
358 const cancelEdit = () => {
359 const parsed = parseAttachmentRefsForDisplay(editableActionText);
360 setDraftText(parsed.text);
361 setDraftAttachments(parsed.attachments);
362 setEditing(false);
363 };
364
365 const updateDraftText = (value: string) => {
366 const parsed = parseAttachmentRefsForDisplay(value);
367 if (parsed.attachments.length > 0) {
368 setDraftText(parsed.text);
369 setDraftAttachments((prev) => mergeDisplayAttachments(prev, parsed.attachments));
370 return;
371 }
372 setDraftText(value);
373 };
374
375 const removeDraftAttachment = (path: string) => {
376 setDraftAttachments((prev) => prev.filter((attachment) => attachment.path !== path));
377 };
378
379 const submitEdit = async (event?: FormEvent) => {
380 event?.preventDefault();
381 if (!canEdit || editSubmitting) return;
382 const parsedDraft = parseAttachmentRefsForDisplay(draftText);
383 const nextAttachments = sortDisplayAttachments(mergeDisplayAttachments(draftAttachments, parsedDraft.attachments));
384 const bodyText = parsedDraft.text.trim();
385 const displayRefs = nextAttachments.map(formatAttachmentRefForDisplay).join(" ");
386 const submitRefs = nextAttachments.map(formatAttachmentRefForSubmit).join(" ");
387 const nextEditable = [bodyText, displayRefs].filter(Boolean).join(bodyText && displayRefs ? " " : "");
388 const next = [nextEditable, selectionLabels].filter(Boolean).join(nextEditable && selectionLabels ? " " : "");
389 const fallbackSubmit = [bodyText, submitRefs].filter(Boolean).join(bodyText && submitRefs ? " " : "");
390 const submit = replaySubmitTextPreservingSelectedContext(submitText, editableActionText, nextEditable, fallbackSubmit);
391 if (!next) return;
392 setEditSubmitting(true);
393 try {
394 const ok = await onEdit?.(turn as number, next, submit);
395 if (ok !== false) setEditing(false);
396 } finally {
397 setEditSubmitting(false);
398 }
399 };
400
401 const onEditKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
402 if (event.key === "Escape") {
403 event.preventDefault();
404 cancelEdit();
405 return;
406 }
407 if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
408 void submitEdit();
409 }
410 };
411
412 useEffect(() => {
413 const paths = imagePreviewKey ? imagePreviewKey.split("\n") : [];
414 if (paths.length === 0) return;
415 let cancelled = false;
416 for (const path of paths) {
417 if (imagePreviews[path]) continue;
418 app.AttachmentDataURL(path)
419 .then((url) => {
420 if (cancelled) return;
421 setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
422 })
423 .catch(() => {});
424 }
425 return () => {
426 cancelled = true;
427 };
428 }, [imagePreviewKey]);
429 return (
430 <div
431 className={`msg msg--user${imSource ? " msg--im-source" : ""}${failed ? " msg--user-failed" : ""}`}
432 id={anchorId}
433 data-question-anchor={anchorId}
434 data-turn={turn}
435 data-im-source={imSource?.provider || undefined}
436 data-history-restore={id && id.startsWith("h") ? "" : undefined}
437 data-entrance={id || undefined}
438 >
439 <div className={`msg__body${editing ? " msg__body--editing" : ""}`}>
440 {editing ? (
441 <form className="msg-edit" onSubmit={(event) => void submitEdit(event)}>
442 {orderedDraftAttachments.length > 0 && (
443 <div className="msg-edit__attachments composer-context" aria-label={t("composer.contextItems")}>
444 {orderedDraftAttachments.map((attachment) => {
445 const imagePreview = attachment.kind === "image" ? imagePreviews[attachment.path] : undefined;
446 const imageOnly = Boolean(imagePreview) && orderedDraftAttachments.every((item) => item.kind === "image" && imagePreviews[item.path]);
447 return (
448 <ComposerContextCard
449 key={attachment.path}
450 variant={attachment.source === "workspace" ? "workspace" : "attachment"}
451 tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
452 removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
453 removeDisabled={editSubmitting}
454 onRemove={() => removeDraftAttachment(attachment.path)}
455 previewUrl={imagePreview}
456 onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
457 imageOnly={imageOnly}
458 folder={attachment.kind === "folder"}
459 label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
460 name={attachment.name}
461 meta={attachment.ext || t("msg.fileAttachment")}
462 icon={attachment.kind === "image" ? <Image size={20} /> : undefined}
463 />
464 );
465 })}
466 </div>
467 )}
468 <textarea
469 ref={editRef}
470 className="msg-edit__input"
471 value={draftText}
472 rows={Math.max(2, Math.min(8, draftText.split(/\r?\n/).length))}
473 aria-label={t("common.edit")}
474 disabled={editSubmitting}
475 onChange={(event) => updateDraftText(event.target.value)}
476 onKeyDown={onEditKeyDown}
477 />
478 <div className="msg-edit__actions">
479 <button className="msg-edit__btn" type="button" disabled={editSubmitting} onClick={cancelEdit}>
480 {t("common.cancel")}
481 </button>
482 <button className="msg-edit__btn msg-edit__btn--primary" type="submit" disabled={editSubmitting || (draftText.trim() === "" && draftAttachments.length === 0 && selectedTextEntries.length === 0)}>
483 {t("msg.editSend")}
484 </button>
485 </div>
486 </form>
487 ) : imSource ? (
488 <div className="im-source-card">
489 <div className="im-source-card__head">
490 <MessageSquare size={14} />
491 <span>{t("msg.fromIm", { source: sourceLabel })}</span>
492 </div>
493 {displayText && <div className="im-source-card__text">{displayText}</div>}
494 {(imSource.sender || imSource.chat) && (
495 <div className="im-source-card__meta">
496 {imSource.sender && <span>{t("msg.imSender", { id: imSource.sender })}</span>}
497 {imSource.chat && <span>{imSource.chat}</span>}
498 </div>
499 )}
500 </div>
501 ) : (
502 <>
503 {hasInvocationSegments && pasteBlocks.length === 0 && selectedTextBlocks.length === 0 ? (
504 <div className="msg__text msg__rich-text">
505 {invocationSegments.map((segment, index) => segment.type === "text"
506 ? <span key={`text:${segment.start}:${index}`}>{segment.content}</span>
507 : (
508 <InvocationBadge
509 key={`invocation:${segment.invocation.name}:${segment.offset}:${index}`}
510 invocation={segment.invocation}
511 kind={segment.invocation.kind}
512 variant="message"
513 />
514 ))}
515 </div>
516 ) : displaySegments.map((seg, i) => {
517 if (seg.type === "text") {
518 return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
519 }
520 const expanded = Boolean(expandedBlockKeys[seg.key]);
521 return (
522 <div className="msg-pasted" key={seg.key}>
523 <div className="msg-pasted-block">
524 <div className="msg-pasted-head">
525 {seg.kind === "chat" ? <MessageSquare size={15} /> : <FileText size={15} />}
526 <span className="msg-pasted-label">{seg.block.label}</span>
527 <div className="msg-pasted-actions">
528 <Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
529 <button type="button" onClick={() => toggleBlockExpand(seg.key)}>
530 {expanded ? t("common.collapse") : t("composer.pastedExpand")}
531 </button>
532 </Tooltip>
533 </div>
534 </div>
535 {expanded && (
536 <div className="msg-pasted-expanded">
537 {seg.kind === "chat"
538 ? <Markdown text={seg.block.content} />
539 : seg.kind === "code"
540 ? <CodeViewer value={seg.block.content} language={languageFor(seg.block.path ?? "")} maxHeight={360} />
541 : seg.block.content}
542 </div>
543 )}
544 </div>
545 </div>
546 );
547 })}
548 </>
549 )}
550 {failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
551 {orderedAttachments.length > 0 && (
552 <div className="msg-attachments" aria-label={t("msg.attachments")}>
553 {orderedAttachments.map((attachment, index) => {
554 const isImage = attachment.kind === "image";
555 const el = (
556 <div
557 className={`msg-attachment msg-attachment--${attachment.kind}`}
558 key={isImage ? undefined : `${attachment.path}:${index}`}
559 title={isImage ? undefined : attachment.path}
560 onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
561 role={isImage ? "button" : undefined}
562 tabIndex={isImage ? 0 : undefined}
563 onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
564 >
565 <span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
566 {isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
567 </span>
568 <span className="msg-attachment__main">
569 <span className="msg-attachment__name">{attachment.name}</span>
570 <span className="msg-attachment__meta">
571 {attachment.kind === "folder"
572 ? t("msg.folderReference")
573 : `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
574 </span>
575 </span>
576 </div>
577 );
578 if (isImage) {
579 return (
580 <Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
581 {el}
582 </Tooltip>
583 );
584 }
585 return el;
586 })}
587 <ImageViewer
588 open={imageViewer.open}
589 imageUrl={imageViewer.url}
590 imageName={imageViewer.name}
591 onClose={closeImageViewer}
592 />
593 </div>
594 )}
595 </div>
596 {!editing && (
597 <div className="msg-meta" role="group" aria-label={t("rewind.label")}>
598 {sentAt && (
599 <time className="msg-meta__time" dateTime={sentAt.toISOString()} title={sentAt.toLocaleString()}>
600 {formatMessageTime(sentAt)}
601 </time>
602 )}
603 {hasMemoryCompiler && (
604 <span className="msg-meta__indicator" title={t("msg.memoryCompilerApplied")} aria-hidden="true">
605 <BrainCircuit size={14} />
606 </span>
607 )}
608 <CopyButton text={actionText} label={t("msg.copy")} showInlineLabel={false} className="msg-meta__btn msg-meta__copy" />
609 {onEdit && (
610 <button
611 className="msg-meta__btn"
612 type="button"
613 aria-label={t("common.edit")}
614 title={t("common.edit")}
615 disabled={!canEdit}
616 onClick={startEdit}
617 >
618 <Pencil size={14} />
619 </button>
620 )}
621 </div>
622 )}
623 </div>
624 );
625 }
626
627 export function TurnActions({
628 text,
629 turn,
630 openMenu,
631 onOpenMenu,
632 onRewind,
633 checkpoint,
634 actionPending = false,
635 rewindDisabled = false,
636 hoverMenus = false,
637 isLastTurn = false,
638 }: {
639 text: string;
640 turn?: number;
641 openMenu?: TurnActionMenu | null;
642 onOpenMenu?: (menu: TurnActionMenu | null) => void;
643 onRewind?: (turn: number, scope: MessageActionScope) => void;
644 checkpoint?: CheckpointMeta;
645 actionPending?: boolean;
646 rewindDisabled?: boolean;
647 hoverMenus?: boolean;
648 /** true when this is the last user turn — disables "summarize after" */
649 isLastTurn?: boolean;
650 }) {
651 const t = useT();
652 const [confirmScope, setConfirmScope] = useState<MessageActionScope | null>(null);
653 const canAct = onRewind != null && turn != null;
654 const actionDisabledReason = (scope: string): string => {
655 if (rewindDisabled || actionPending) return t("rewind.disabledRunning");
656 if (!checkpoint) return t("rewind.disabledNoCheckpoint");
657 if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
658 return t("rewind.disabledNoBoundary");
659 }
660 if (scope === "summ-from" && isLastTurn) {
661 return t("rewind.disabledNoLater");
662 }
663 if (scope === "summ-upto") {
664 if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
665 if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
666 }
667 if (scope === "code" && !checkpoint.canCode) return t("rewind.disabledNoCode");
668 if (scope === "both") {
669 if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
670 if (!checkpoint.canCode) return t("rewind.disabledNoCode");
671 }
672 return "";
673 };
674 const actionLabel = (scope: MessageActionScope): string => {
675 if (confirmScope !== scope) {
676 switch (scope) {
677 case "fork":
678 return t("rewind.fork");
679 case "summ-from":
680 return t("rewind.summFrom");
681 case "summ-upto":
682 return t("rewind.summUpto");
683 case "conversation":
684 return t("rewind.conversation");
685 case "code":
686 return t("rewind.code");
687 default:
688 return t("rewind.both");
689 }
690 }
691 switch (scope) {
692 case "fork":
693 return t("rewind.confirmFork");
694 case "summ-from":
695 return t("rewind.confirmSummFrom");
696 case "summ-upto":
697 return t("rewind.confirmSummUpto");
698 case "conversation":
699 return t("rewind.confirmConversation");
700 case "code":
701 return t("rewind.confirmCode");
702 default:
703 return t("rewind.confirmBoth");
704 }
705 };
706 const actionMeta = (scope: MessageActionScope): string => {
707 const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
708 if ((scope === "code" || scope === "both") && total > 0) {
709 const turnCount = checkpoint?.turnFileCount ?? 0;
710 if (turnCount > 0 && turnCount < total) {
711 return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
712 }
713 return t("rewind.filesChanged", { count: total });
714 }
715 return "";
716 };
717 const actionTooltipLabel = (scope: MessageActionScope) => {
718 const reason = actionDisabledReason(scope);
719 if (reason) return <span>{reason}</span>;
720 const files = checkpoint?.files ?? [];
721 const total = checkpoint?.fileCount ?? files.length;
722 if ((scope === "code" || scope === "both") && total > 0) {
723 const hidden = Math.max(0, total - files.length);
724 return (
725 <div className="rewind__files-tooltip">
726 {files.map((file) => (
727 <div key={file}>{file.split(/[/\\]/).pop() || file}</div>
728 ))}
729 {hidden > 0 && <div>+{hidden}</div>}
730 </div>
731 );
732 }
733 return undefined;
734 };
735 const runAction = (scope: MessageActionScope) => {
736 setConfirmScope(null);
737 onOpenMenu?.(null);
738 onRewind?.(turn as number, scope);
739 };
740 const selectRewind = (scope: MessageActionScope) => {
741 if (actionDisabledReason(scope)) return;
742 if (confirmScope !== scope) {
743 setConfirmScope(scope);
744 return;
745 }
746 runAction(scope);
747 };
748 const renderAction = (scope: MessageActionScope, danger = false) => {
749 const disabledReason = actionDisabledReason(scope);
750 const meta = actionMeta(scope);
751 const tipLabel = actionTooltipLabel(scope);
752 const button = (
753 <button
754 className={[
755 "rewind__menu-item",
756 danger ? "rewind__menu-danger" : "",
757 confirmScope === scope ? "rewind__menu-confirm" : "",
758 ].filter(Boolean).join(" ")}
759 type="button"
760 disabled={Boolean(disabledReason)}
761 {...(tipLabel ? {} : { title: disabledReason || undefined })}
762 onClick={() => selectRewind(scope)}
763 >
764 <span>{actionLabel(scope)}</span>
765 {meta && <span className="rewind__menu-meta">{meta}</span>}
766 </button>
767 );
768 return tipLabel ? <Tooltip key={scope} label={tipLabel} side="top" block fill>{button}</Tooltip> : button;
769 };
770 const forkDisabledReason = canAct ? actionDisabledReason("fork") : "";
771 const toggleMenu = (menu: TurnActionMenu) => {
772 setConfirmScope(null);
773 onOpenMenu?.(openMenu === menu ? null : menu);
774 };
775 const openHoverMenu = (menu: TurnActionMenu) => {
776 if (!hoverMenus || openMenu === menu) return;
777 setConfirmScope(null);
778 onOpenMenu?.(menu);
779 };
780 return (
781 <div className={`turn-actions${openMenu ? " turn-actions--open" : ""}${hoverMenus ? " turn-actions--hover-menu" : ""}`}>
782 <CopyButton text={text} label={t("msg.copy")} />
783 {canAct && (
784 <>
785 <button
786 className={`turn-actions__btn${confirmScope === "fork" ? " turn-actions__btn--confirm" : ""}`}
787 type="button"
788 disabled={Boolean(forkDisabledReason)}
789 title={forkDisabledReason || undefined}
790 onClick={() => selectRewind("fork")}
791 >
792 <GitBranch size={13} />
793 <span>{actionLabel("fork")}</span>
794 </button>
795 <div
796 className={`turn-actions__group${openMenu === "summary" ? " turn-actions__group--open" : ""}`}
797 onMouseEnter={() => openHoverMenu("summary")}
798 >
799 <button
800 className="turn-actions__btn"
801 type="button"
802 aria-haspopup="menu"
803 aria-expanded={openMenu === "summary"}
804 onClick={() => toggleMenu("summary")}
805 >
806 <ScrollText size={13} />
807 <span>{t("turnActions.summary")}</span>
808 <ChevronDown size={12} />
809 </button>
810 {openMenu === "summary" && (
811 <div className="rewind__menu turn-actions__menu" role="menu">
812 {rewindDisabled && <div className="rewind__menu-hint">{t("rewind.disabledRunning")}</div>}
813 {!rewindDisabled && !checkpoint && <div className="rewind__menu-hint">{t("rewind.disabledNoCheckpoint")}</div>}
814 {renderAction("summ-from")}
815 {renderAction("summ-upto")}
816 </div>
817 )}
818 </div>
819 <div
820 className={`turn-actions__group${openMenu === "rewind" ? " turn-actions__group--open" : ""}`}
821 onMouseEnter={() => openHoverMenu("rewind")}
822 >
823 <button
824 className="turn-actions__btn"
825 type="button"
826 aria-haspopup="menu"
827 aria-expanded={openMenu === "rewind"}
828 onClick={() => toggleMenu("rewind")}
829 >
830 <RotateCcw size={13} />
831 <span>{t("turnActions.rewind")}</span>
832 <ChevronDown size={12} />
833 </button>
834 {openMenu === "rewind" && (
835 <div className="rewind__menu turn-actions__menu" role="menu">
836 {rewindDisabled && <div className="rewind__menu-hint">{t("rewind.disabledRunning")}</div>}
837 {!rewindDisabled && !checkpoint && <div className="rewind__menu-hint">{t("rewind.disabledNoCheckpoint")}</div>}
838 {renderAction("conversation")}
839 {renderAction("code")}
840 {renderAction("both", true)}
841 </div>
842 )}
843 </div>
844 </>
845 )}
846 </div>
847 );
848 }
849
850 function reasoningDurationLabel(durationMs: number | undefined, t: ReturnType<typeof useT>): string {
851 if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) {
852 return t("msg.thinkingDone");
853 }
854 const seconds = Math.max(1, Math.round(durationMs / 1000));
855 return t("msg.thinkingDuration", { s: seconds });
856 }
857
858 function ReasoningPanel({
859 item,
860 defaultExpanded,
861 expandWhileStreaming,
862 truncateStreamingReasoning,
863 }: {
864 item: AssistantItem;
865 defaultExpanded: boolean;
866 expandWhileStreaming: boolean;
867 truncateStreamingReasoning: boolean;
868 }) {
869 const t = useT();
870 const reasoningBodyRef = useRef<HTMLDivElement>(null);
871 // Thinking streams in before the answer — show it live while the model is still
872 // working, then it stays available behind the toggle once the answer arrives.
873 const [reasoningOpen, setReasoningOpen] = useState((expandWhileStreaming && item.streaming) || defaultExpanded);
874 const userOverridden = useRef(false);
875 const prevStreamingRef = useRef(item.streaming);
876 const prevReasoningCompleteRef = useRef(item.reasoningComplete ?? false);
877 useGSAPCollapse(reasoningBodyRef, reasoningOpen);
878
879 // Follow the current display mode while streaming unless the user manually
880 // toggled this message; auto-close at stream end for untouched messages.
881 useEffect(() => {
882 const wasStreaming = prevStreamingRef.current;
883 const nowStreaming = item.streaming;
884 prevStreamingRef.current = nowStreaming;
885
886 const wasRC = prevReasoningCompleteRef.current;
887 const nowRC = item.reasoningComplete ?? false;
888 prevReasoningCompleteRef.current = nowRC;
889
890 if (nowStreaming) {
891 if (!wasStreaming) userOverridden.current = false;
892 if (defaultExpanded) {
893 setReasoningOpen(true);
894 } else if (!userOverridden.current) {
895 setReasoningOpen(expandWhileStreaming && !nowRC);
896 }
897 } else if (nowRC && !wasRC) {
898 // Reasoning just finished — auto-close while we wait for text.
899 if (!defaultExpanded && !userOverridden.current) {
900 setReasoningOpen(false);
901 }
902 } else if (wasStreaming) {
903 // Stream fully ended — auto-close if user didn't interact.
904 if (!defaultExpanded && !userOverridden.current) {
905 setReasoningOpen(false);
906 }
907 }
908 }, [item.streaming, item.reasoningComplete, defaultExpanded, expandWhileStreaming]);
909
910 const toggleReasoning = () => {
911 userOverridden.current = true;
912 setReasoningOpen((v) => !v);
913 };
914 const isReasoningRunning = item.streaming && !item.reasoningComplete;
915 const visibleReasoning = reasoningOpen
916 ? displayReasoningText(item.reasoning, {
917 streaming: item.streaming,
918 truncateStreaming: truncateStreamingReasoning,
919 })
920 : "";
921 const label = isReasoningRunning ? t("msg.thinkingRunning") : t("msg.thinking");
922 const meta = isReasoningRunning ? "" : reasoningDurationLabel(item.reasoningDurationMs, t);
923
924 return (
925 <div className="reasoning">
926 <button
927 type="button"
928 className="reasoning__head"
929 data-running={isReasoningRunning ? "" : undefined}
930 onClick={toggleReasoning}
931 aria-expanded={reasoningOpen}
932 >
933 <ProcessBrainIcon size={12} />
934 <span data-creation-label={t("creation.reasoningLabel")}>{label}</span>
935 {meta && <span className="reasoning__meta">{meta}</span>}
936 <ChevronRight className={`reasoning__chevron${reasoningOpen ? " reasoning__chevron--open" : ""}`} size={12} />
937 </button>
938 {reasoningOpen && (
939 <div ref={reasoningBodyRef} className="reasoning__body">{visibleReasoning}</div>
940 )}
941 </div>
942 );
943 }
944
945 export const AssistantMessage = memo(function AssistantMessage({
946 item,
947 defaultExpanded = false,
948 expandWhileStreaming = true,
949 truncateStreamingReasoning = false,
950 creationMode = false,
951 }: {
952 item: AssistantItem;
953 defaultExpanded?: boolean;
954 /** false in compact mode: completed steps fold away, so auto-open + fold reads as flicker. */
955 expandWhileStreaming?: boolean;
956 /** Opt-in for compact mode to keep live DeepSeek reasoning from growing an unbounded DOM. */
957 truncateStreamingReasoning?: boolean;
958 creationMode?: boolean;
959 }) {
960 const hasText = item.streaming || item.text.trim() !== "";
961 const processOnly = Boolean(item.reasoning) && !hasText;
962 const processWithText = Boolean(item.reasoning) && hasText;
963 return (
964 <div className={`msg msg--assistant${processOnly ? " msg--process-only" : ""}${processWithText ? " msg--process-with-text" : ""}`} data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}>
965 {item.reasoning && (
966 <ReasoningPanel
967 item={item}
968 defaultExpanded={defaultExpanded}
969 expandWhileStreaming={expandWhileStreaming}
970 truncateStreamingReasoning={truncateStreamingReasoning}
971 />
972 )}
973 {hasText && (
974 <div className="msg__body">
975 <Markdown text={item.text} plainStatusBlocks={creationMode} streaming={item.streaming} />
976 </div>
977 )}
978 <MemoryCitations citations={item.memoryCitations} />
979 </div>
980 );
981 });
982
982 lines Plain Text