v0.1.4-p4

main
mula.liu 2026-04-02 20:42:56 +08:00
parent 3ca7eff38b
commit 08b35d632b
3 changed files with 478 additions and 268 deletions

View File

@ -216,11 +216,10 @@
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
border: 1px dashed color-mix(in oklab, #6f94ff 46%, var(--line) 54%); border: 1px dashed color-mix(in oklab, #aeb7c4 52%, var(--line) 48%);
border-radius: 14px; border-radius: 14px;
background: background: color-mix(in oklab, #eef1f4 82%, var(--panel) 18%);
linear-gradient(180deg, color-mix(in oklab, var(--panel) 68%, #dfe9ff 32%), color-mix(in oklab, var(--panel) 82%, #d5e3ff 18%)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
} }
.ops-staged-submission-item { .ops-staged-submission-item {
@ -229,9 +228,9 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid color-mix(in oklab, #7aa2ff 18%, var(--line) 82%); border: 1px solid color-mix(in oklab, #bcc5cf 26%, var(--line) 74%);
border-radius: 12px; border-radius: 12px;
background: color-mix(in oklab, var(--panel) 84%, white 16%); background: color-mix(in oklab, #f5f7f9 88%, var(--panel) 12%);
} }
.ops-staged-submission-index { .ops-staged-submission-index {
@ -242,9 +241,9 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 11px; font-size: 11px;
color: var(--brand); color: color-mix(in oklab, var(--text) 72%, var(--muted) 28%);
background: color-mix(in oklab, var(--panel) 64%, white 36%); background: color-mix(in oklab, #ffffff 72%, #e5e9ee 28%);
border: 1px solid color-mix(in oklab, var(--line) 70%, transparent); border: 1px solid color-mix(in oklab, #c7ced6 46%, transparent);
} }
.ops-staged-submission-body { .ops-staged-submission-body {
@ -272,12 +271,12 @@
.ops-staged-submission-pill { .ops-staged-submission-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border: 1px solid color-mix(in oklab, var(--line) 74%, transparent); border: 1px solid color-mix(in oklab, #c3cad2 44%, transparent);
border-radius: 999px; border-radius: 999px;
padding: 2px 8px; padding: 2px 8px;
font-size: 11px; font-size: 11px;
color: var(--subtitle); color: var(--subtitle);
background: color-mix(in oklab, var(--panel) 82%, transparent); background: color-mix(in oklab, #ffffff 74%, #e8edf2 26%);
} }
.ops-staged-submission-actions { .ops-staged-submission-actions {
@ -290,9 +289,9 @@
width: 28px; width: 28px;
height: 28px; height: 28px;
padding: 0; padding: 0;
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent); border: 1px solid color-mix(in oklab, #c2c9d2 42%, transparent);
border-radius: 999px; border-radius: 999px;
background: color-mix(in oklab, var(--panel) 88%, white 12%); background: color-mix(in oklab, #fbfcfd 82%, #e6ebf0 18%);
color: var(--icon-muted); color: var(--icon-muted);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -303,8 +302,8 @@
.ops-staged-submission-icon-btn:hover:not(:disabled) { .ops-staged-submission-icon-btn:hover:not(:disabled) {
color: var(--icon); color: var(--icon);
background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%); background: color-mix(in oklab, #edf1f5 78%, #d8dde4 22%);
border-color: color-mix(in oklab, var(--brand) 38%, var(--line) 62%); border-color: color-mix(in oklab, #97a3af 34%, var(--line) 66%);
transform: translateY(-1px); transform: translateY(-1px);
} }

View File

@ -1,6 +1,6 @@
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Pencil, Plus, RefreshCw, RotateCcw, Square, Trash2, X } from 'lucide-react'; import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Pencil, Plus, RefreshCw, RotateCcw, Square, Trash2, X } from 'lucide-react';
import type { Components } from 'react-markdown'; import type { Components } from 'react-markdown';
import type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react'; import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import nanobotLogo from '../../../assets/nanobot-logo.png'; import nanobotLogo from '../../../assets/nanobot-logo.png';
@ -121,6 +121,145 @@ interface DashboardChatPanelProps {
onSubmitAction: () => Promise<void> | void; onSubmitAction: () => Promise<void> | void;
} }
interface DashboardChatTranscriptProps {
conversation: ChatMessage[];
isZh: boolean;
labels: DashboardChatPanelLabels;
chatScrollRef: RefObject<HTMLDivElement | null>;
onChatScroll: () => void;
expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>;
deletingMessageIdMap: Record<number, boolean>;
feedbackSavingByMessageId: Record<number, boolean>;
markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onToggleProgressExpand: (key: string) => void;
onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void;
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void;
onCopyAssistantReply: (text: string) => Promise<void> | void;
isThinking: boolean;
}
const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
conversation,
isZh,
labels,
chatScrollRef,
onChatScroll,
expandedProgressByKey,
expandedUserByKey,
deletingMessageIdMap,
feedbackSavingByMessageId,
markdownComponents,
workspaceDownloadExtensionSet,
onToggleProgressExpand,
onToggleUserExpand,
onEditUserPrompt,
onCopyUserPrompt,
onDeleteConversationMessage,
onOpenWorkspacePath,
onSubmitAssistantFeedback,
onQuoteAssistantReply,
onCopyAssistantReply,
isThinking,
}: DashboardChatTranscriptProps) {
return (
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
{conversation.length === 0 ? (
<div className="ops-chat-empty">
{labels.noConversation}
</div>
) : (
<DashboardConversationMessages
conversation={conversation}
isZh={isZh}
labels={{
badReply: labels.badReply,
copyPrompt: labels.copyPrompt,
copyReply: labels.copyReply,
deleteMessage: labels.deleteMessage,
download: labels.download,
editPrompt: labels.editPrompt,
fileNotPreviewable: labels.fileNotPreviewable,
goodReply: labels.goodReply,
previewTitle: labels.previewTitle,
quoteReply: labels.quoteReply,
quotedReplyLabel: labels.quotedReplyLabel,
user: labels.user,
you: labels.you,
}}
expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey}
deletingMessageIdMap={deletingMessageIdMap}
feedbackSavingByMessageId={feedbackSavingByMessageId}
markdownComponents={markdownComponents}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
onToggleProgressExpand={onToggleProgressExpand}
onToggleUserExpand={onToggleUserExpand}
onEditUserPrompt={onEditUserPrompt}
onCopyUserPrompt={onCopyUserPrompt}
onDeleteConversationMessage={onDeleteConversationMessage}
onOpenWorkspacePath={onOpenWorkspacePath}
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onQuoteAssistantReply={onQuoteAssistantReply}
onCopyAssistantReply={onCopyAssistantReply}
/>
)}
{isThinking ? (
<div className="ops-chat-row is-assistant">
<div className="ops-chat-item is-assistant">
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
<div className="ops-thinking-bubble">
<div className="ops-thinking-cloud">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<div className="ops-thinking-text">{labels.thinking}</div>
</div>
</div>
</div>
) : null}
<div />
</div>
);
}, (prev, next) => (
prev.conversation === next.conversation
&& prev.isZh === next.isZh
&& prev.isThinking === next.isThinking
&& prev.chatScrollRef === next.chatScrollRef
&& prev.expandedProgressByKey === next.expandedProgressByKey
&& prev.expandedUserByKey === next.expandedUserByKey
&& prev.deletingMessageIdMap === next.deletingMessageIdMap
&& prev.feedbackSavingByMessageId === next.feedbackSavingByMessageId
&& prev.markdownComponents === next.markdownComponents
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
&& prev.labels.badReply === next.labels.badReply
&& prev.labels.copyPrompt === next.labels.copyPrompt
&& prev.labels.copyReply === next.labels.copyReply
&& prev.labels.deleteMessage === next.labels.deleteMessage
&& prev.labels.download === next.labels.download
&& prev.labels.editPrompt === next.labels.editPrompt
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
&& prev.labels.goodReply === next.labels.goodReply
&& prev.labels.noConversation === next.labels.noConversation
&& prev.labels.previewTitle === next.labels.previewTitle
&& prev.labels.quoteReply === next.labels.quoteReply
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
&& prev.labels.thinking === next.labels.thinking
&& prev.labels.user === next.labels.user
&& prev.labels.you === next.labels.you
));
export function DashboardChatPanel({ export function DashboardChatPanel({
conversation, conversation,
isZh, isZh,
@ -195,68 +334,29 @@ export function DashboardChatPanel({
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
return ( return (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}> <div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}> <MemoizedChatTranscript
{conversation.length === 0 ? ( conversation={conversation}
<div className="ops-chat-empty"> isZh={isZh}
{labels.noConversation} labels={labels}
</div> chatScrollRef={chatScrollRef}
) : ( onChatScroll={onChatScroll}
<DashboardConversationMessages expandedProgressByKey={expandedProgressByKey}
conversation={conversation} expandedUserByKey={expandedUserByKey}
isZh={isZh} deletingMessageIdMap={deletingMessageIdMap}
labels={{ feedbackSavingByMessageId={feedbackSavingByMessageId}
badReply: labels.badReply, markdownComponents={markdownComponents}
copyPrompt: labels.copyPrompt, workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
copyReply: labels.copyReply, onToggleProgressExpand={onToggleProgressExpand}
deleteMessage: labels.deleteMessage, onToggleUserExpand={onToggleUserExpand}
download: labels.download, onEditUserPrompt={onEditUserPrompt}
editPrompt: labels.editPrompt, onCopyUserPrompt={onCopyUserPrompt}
fileNotPreviewable: labels.fileNotPreviewable, onDeleteConversationMessage={onDeleteConversationMessage}
goodReply: labels.goodReply, onOpenWorkspacePath={onOpenWorkspacePath}
previewTitle: labels.previewTitle, onSubmitAssistantFeedback={onSubmitAssistantFeedback}
quoteReply: labels.quoteReply, onQuoteAssistantReply={onQuoteAssistantReply}
quotedReplyLabel: labels.quotedReplyLabel, onCopyAssistantReply={onCopyAssistantReply}
user: labels.user, isThinking={isThinking}
you: labels.you, />
}}
expandedProgressByKey={expandedProgressByKey}
expandedUserByKey={expandedUserByKey}
deletingMessageIdMap={deletingMessageIdMap}
feedbackSavingByMessageId={feedbackSavingByMessageId}
markdownComponents={markdownComponents}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
onToggleProgressExpand={onToggleProgressExpand}
onToggleUserExpand={onToggleUserExpand}
onEditUserPrompt={onEditUserPrompt}
onCopyUserPrompt={onCopyUserPrompt}
onDeleteConversationMessage={onDeleteConversationMessage}
onOpenWorkspacePath={onOpenWorkspacePath}
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onQuoteAssistantReply={onQuoteAssistantReply}
onCopyAssistantReply={onCopyAssistantReply}
/>
)}
{isThinking ? (
<div className="ops-chat-row is-assistant">
<div className="ops-chat-item is-assistant">
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
<div className="ops-thinking-bubble">
<div className="ops-thinking-cloud">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<div className="ops-thinking-text">{labels.thinking}</div>
</div>
</div>
</div>
) : null}
<div />
</div>
<div className="ops-chat-dock"> <div className="ops-chat-dock">
{stagedSubmissions.length > 0 ? ( {stagedSubmissions.length > 0 ? (

View File

@ -1,4 +1,5 @@
import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react'; import { ChevronDown, ChevronUp, Copy, Download, Eye, FileText, Pencil, Reply, ThumbsDown, ThumbsUp, Trash2, UserRound } from 'lucide-react';
import { memo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown'; import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize'; import rehypeSanitize from 'rehype-sanitize';
@ -50,6 +51,29 @@ interface DashboardConversationMessagesProps {
onCopyAssistantReply: (text: string) => Promise<void> | void; onCopyAssistantReply: (text: string) => Promise<void> | void;
} }
interface DashboardConversationMessageRowProps {
item: ChatMessage;
itemKey: string;
isZh: boolean;
labels: DashboardConversationLabels;
showDateDivider: boolean;
expandedProgress: boolean;
expandedUser: boolean;
isDeleting: boolean;
isFeedbackSaving: boolean;
markdownComponents: Components;
workspaceDownloadExtensionSet: ReadonlySet<string>;
onToggleProgressExpand: (key: string) => void;
onToggleUserExpand: (key: string) => void;
onEditUserPrompt: (text: string) => void;
onCopyUserPrompt: (text: string) => Promise<void> | void;
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
onOpenWorkspacePath: (path: string) => Promise<void> | void;
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
onQuoteAssistantReply: (message: ChatMessage) => void;
onCopyAssistantReply: (text: string) => Promise<void> | void;
}
function shouldCollapseProgress(text: string) { function shouldCollapseProgress(text: string) {
const normalized = String(text || '').trim(); const normalized = String(text || '').trim();
if (!normalized) return false; if (!normalized) return false;
@ -65,6 +89,261 @@ function getConversationItemKey(item: ChatMessage, idx: number) {
return `temp:${item.role}:${item.kind || 'final'}:${item.ts}:${idx}`; return `temp:${item.role}:${item.kind || 'final'}:${item.ts}:${idx}`;
} }
function sameAttachments(left?: string[], right?: string[]) {
const aa = left || [];
const bb = right || [];
if (aa.length !== bb.length) return false;
return aa.every((value, index) => value === bb[index]);
}
const DashboardConversationMessageRow = memo(function DashboardConversationMessageRow({
item,
itemKey,
isZh,
labels,
showDateDivider,
expandedProgress,
expandedUser,
isDeleting,
isFeedbackSaving,
markdownComponents,
workspaceDownloadExtensionSet,
onToggleProgressExpand,
onToggleUserExpand,
onEditUserPrompt,
onCopyUserPrompt,
onDeleteConversationMessage,
onOpenWorkspacePath,
onSubmitAssistantFeedback,
onQuoteAssistantReply,
onCopyAssistantReply,
}: DashboardConversationMessageRowProps) {
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user';
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
const userCollapsible = isUserBubble && userLineCount > 5;
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
const expanded = isProgressBubble ? expandedProgress : expandedUser;
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
return (
<div
key={itemKey}
data-chat-message-id={item.id ? String(item.id) : undefined}
>
{showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span>
</div>
) : null}
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
{item.role !== 'user' && (
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
)}
{item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onEditUserPrompt(item.text)}
tooltip={labels.editPrompt}
aria-label={labels.editPrompt}
>
<Pencil size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyUserPrompt(item.text)}
tooltip={labels.copyPrompt}
aria-label={labels.copyPrompt}
>
<Copy size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onDeleteConversationMessage(item)}
disabled={isDeleting}
tooltip={labels.deleteMessage}
aria-label={labels.deleteMessage}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
) : null}
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
<div className="ops-chat-meta">
<span>{item.role === 'user' ? labels.you : 'Nanobot'}</span>
<div className="ops-chat-meta-right">
<span className="mono">{formatClock(item.ts)}</span>
{collapsible ? (
<LucentIconButton
className="ops-chat-expand-icon-btn"
onClick={() => {
if (isProgressBubble) {
onToggleProgressExpand(itemKey);
return;
}
onToggleUserExpand(itemKey);
}}
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
) : null}
</div>
</div>
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
{item.text ? (
item.role === 'user' ? (
<>
{item.quoted_reply ? (
<div className="ops-user-quoted-reply">
<div className="ops-user-quoted-label">{labels.quotedReplyLabel}</div>
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
</div>
) : null}
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
</>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(displayText)}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href="#"
onClick={(event) => {
event.preventDefault();
void onOpenWorkspacePath(filePath);
}}
title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})}
</div>
) : null}
{item.role === 'assistant' && !isProgressBubble ? (
<div className="ops-chat-reply-actions">
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
disabled={isFeedbackSaving}
tooltip={labels.goodReply}
aria-label={labels.goodReply}
>
<ThumbsUp size={13} />
</LucentIconButton>
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
disabled={isFeedbackSaving}
tooltip={labels.badReply}
aria-label={labels.badReply}
>
<ThumbsDown size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onQuoteAssistantReply(item)}
tooltip={labels.quoteReply}
aria-label={labels.quoteReply}
>
<Reply size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyAssistantReply(item.text)}
tooltip={labels.copyReply}
aria-label={labels.copyReply}
>
<Copy size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onDeleteConversationMessage(item)}
disabled={isDeleting}
tooltip={labels.deleteMessage}
aria-label={labels.deleteMessage}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
) : null}
</div>
</div>
</div>
{item.role === 'user' && (
<div className="ops-avatar user" title={labels.user}>
<UserRound size={18} />
</div>
)}
</div>
</div>
);
}, (prev, next) => (
prev.itemKey === next.itemKey
&& prev.isZh === next.isZh
&& prev.showDateDivider === next.showDateDivider
&& prev.expandedProgress === next.expandedProgress
&& prev.expandedUser === next.expandedUser
&& prev.isDeleting === next.isDeleting
&& prev.isFeedbackSaving === next.isFeedbackSaving
&& prev.markdownComponents === next.markdownComponents
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
&& prev.labels.badReply === next.labels.badReply
&& prev.labels.copyPrompt === next.labels.copyPrompt
&& prev.labels.copyReply === next.labels.copyReply
&& prev.labels.deleteMessage === next.labels.deleteMessage
&& prev.labels.download === next.labels.download
&& prev.labels.editPrompt === next.labels.editPrompt
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
&& prev.labels.goodReply === next.labels.goodReply
&& prev.labels.previewTitle === next.labels.previewTitle
&& prev.labels.quoteReply === next.labels.quoteReply
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
&& prev.labels.user === next.labels.user
&& prev.labels.you === next.labels.you
&& prev.item.id === next.item.id
&& prev.item.role === next.item.role
&& prev.item.text === next.item.text
&& prev.item.quoted_reply === next.item.quoted_reply
&& prev.item.ts === next.item.ts
&& prev.item.kind === next.item.kind
&& prev.item.feedback === next.item.feedback
&& sameAttachments(prev.item.attachments, next.item.attachments)
));
export function DashboardConversationMessages({ export function DashboardConversationMessages({
conversation, conversation,
isZh, isZh,
@ -89,202 +368,34 @@ export function DashboardConversationMessages({
<> <>
{conversation.map((item, idx) => { {conversation.map((item, idx) => {
const itemKey = getConversationItemKey(item, idx); const itemKey = getConversationItemKey(item, idx);
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user';
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
const userCollapsible = isUserBubble && userLineCount > 5;
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[itemKey]);
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
const currentDayKey = new Date(item.ts).toDateString(); const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
const isDeleting = Boolean(item.id && deletingMessageIdMap[item.id]);
return ( return (
<div <DashboardConversationMessageRow
key={itemKey} key={itemKey}
data-chat-message-id={item.id ? String(item.id) : undefined} item={item}
> itemKey={itemKey}
{showDateDivider ? ( isZh={isZh}
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}> labels={labels}
<span>{formatConversationDate(item.ts, isZh)}</span> showDateDivider={showDateDivider}
</div> expandedProgress={Boolean(expandedProgressByKey[itemKey])}
) : null} expandedUser={Boolean(expandedUserByKey[itemKey])}
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}> isDeleting={Boolean(item.id && deletingMessageIdMap[item.id])}
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}> isFeedbackSaving={Boolean(item.id && feedbackSavingByMessageId[item.id])}
{item.role !== 'user' && ( markdownComponents={markdownComponents}
<div className="ops-avatar bot" title="Nanobot"> workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
<img src={nanobotLogo} alt="Nanobot" /> onToggleProgressExpand={onToggleProgressExpand}
</div> onToggleUserExpand={onToggleUserExpand}
)} onEditUserPrompt={onEditUserPrompt}
{item.role === 'user' ? ( onCopyUserPrompt={onCopyUserPrompt}
<div className="ops-chat-hover-actions ops-chat-hover-actions-user"> onDeleteConversationMessage={onDeleteConversationMessage}
<LucentIconButton onOpenWorkspacePath={onOpenWorkspacePath}
className="ops-chat-inline-action" onSubmitAssistantFeedback={onSubmitAssistantFeedback}
onClick={() => onEditUserPrompt(item.text)} onQuoteAssistantReply={onQuoteAssistantReply}
tooltip={labels.editPrompt} onCopyAssistantReply={onCopyAssistantReply}
aria-label={labels.editPrompt} />
>
<Pencil size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyUserPrompt(item.text)}
tooltip={labels.copyPrompt}
aria-label={labels.copyPrompt}
>
<Copy size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onDeleteConversationMessage(item)}
disabled={isDeleting}
tooltip={labels.deleteMessage}
aria-label={labels.deleteMessage}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
) : null}
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
<div className="ops-chat-meta">
<span>{item.role === 'user' ? labels.you : 'Nanobot'}</span>
<div className="ops-chat-meta-right">
<span className="mono">{formatClock(item.ts)}</span>
{collapsible ? (
<LucentIconButton
className="ops-chat-expand-icon-btn"
onClick={() => {
if (isProgressBubble) {
onToggleProgressExpand(itemKey);
return;
}
onToggleUserExpand(itemKey);
}}
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
) : null}
</div>
</div>
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
{item.text ? (
item.role === 'user' ? (
<>
{item.quoted_reply ? (
<div className="ops-user-quoted-reply">
<div className="ops-user-quoted-label">{labels.quotedReplyLabel}</div>
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
</div>
) : null}
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
</>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
>
{decorateWorkspacePathsForMarkdown(displayText)}
</ReactMarkdown>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href="#"
onClick={(event) => {
event.preventDefault();
void onOpenWorkspacePath(filePath);
}}
title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})}
</div>
) : null}
{item.role === 'assistant' && !isProgressBubble ? (
<div className="ops-chat-reply-actions">
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={labels.goodReply}
aria-label={labels.goodReply}
>
<ThumbsUp size={13} />
</LucentIconButton>
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={labels.badReply}
aria-label={labels.badReply}
>
<ThumbsDown size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onQuoteAssistantReply(item)}
tooltip={labels.quoteReply}
aria-label={labels.quoteReply}
>
<Reply size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyAssistantReply(item.text)}
tooltip={labels.copyReply}
aria-label={labels.copyReply}
>
<Copy size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onDeleteConversationMessage(item)}
disabled={isDeleting}
tooltip={labels.deleteMessage}
aria-label={labels.deleteMessage}
>
<Trash2 size={13} />
</LucentIconButton>
</div>
) : null}
</div>
</div>
</div>
{item.role === 'user' && (
<div className="ops-avatar user" title={labels.user}>
<UserRound size={18} />
</div>
)}
</div>
</div>
); );
})} })}
</> </>