diff --git a/frontend/src/modules/dashboard/components/DashboardChatPanel.css b/frontend/src/modules/dashboard/components/DashboardChatPanel.css index c7c3c0b..fe9d025 100644 --- a/frontend/src/modules/dashboard/components/DashboardChatPanel.css +++ b/frontend/src/modules/dashboard/components/DashboardChatPanel.css @@ -216,11 +216,10 @@ display: grid; gap: 8px; 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; - background: - 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.22); + background: color-mix(in oklab, #eef1f4 82%, var(--panel) 18%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16); } .ops-staged-submission-item { @@ -229,9 +228,9 @@ align-items: center; gap: 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; - background: color-mix(in oklab, var(--panel) 84%, white 16%); + background: color-mix(in oklab, #f5f7f9 88%, var(--panel) 12%); } .ops-staged-submission-index { @@ -242,9 +241,9 @@ align-items: center; justify-content: center; font-size: 11px; - color: var(--brand); - background: color-mix(in oklab, var(--panel) 64%, white 36%); - border: 1px solid color-mix(in oklab, var(--line) 70%, transparent); + color: color-mix(in oklab, var(--text) 72%, var(--muted) 28%); + background: color-mix(in oklab, #ffffff 72%, #e5e9ee 28%); + border: 1px solid color-mix(in oklab, #c7ced6 46%, transparent); } .ops-staged-submission-body { @@ -272,12 +271,12 @@ .ops-staged-submission-pill { display: inline-flex; 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; padding: 2px 8px; font-size: 11px; 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 { @@ -290,9 +289,9 @@ width: 28px; height: 28px; 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; - background: color-mix(in oklab, var(--panel) 88%, white 12%); + background: color-mix(in oklab, #fbfcfd 82%, #e6ebf0 18%); color: var(--icon-muted); display: inline-flex; align-items: center; @@ -303,8 +302,8 @@ .ops-staged-submission-icon-btn:hover:not(:disabled) { color: var(--icon); - background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%); - border-color: color-mix(in oklab, var(--brand) 38%, var(--line) 62%); + background: color-mix(in oklab, #edf1f5 78%, #d8dde4 22%); + border-color: color-mix(in oklab, #97a3af 34%, var(--line) 66%); transform: translateY(-1px); } diff --git a/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx b/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx index 520ea2a..e49b577 100644 --- a/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChatPanel.tsx @@ -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 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 nanobotLogo from '../../../assets/nanobot-logo.png'; @@ -121,6 +121,145 @@ interface DashboardChatPanelProps { onSubmitAction: () => Promise | void; } +interface DashboardChatTranscriptProps { + conversation: ChatMessage[]; + isZh: boolean; + labels: DashboardChatPanelLabels; + chatScrollRef: RefObject; + onChatScroll: () => void; + expandedProgressByKey: Record; + expandedUserByKey: Record; + deletingMessageIdMap: Record; + feedbackSavingByMessageId: Record; + markdownComponents: Components; + workspaceDownloadExtensionSet: ReadonlySet; + onToggleProgressExpand: (key: string) => void; + onToggleUserExpand: (key: string) => void; + onEditUserPrompt: (text: string) => void; + onCopyUserPrompt: (text: string) => Promise | void; + onDeleteConversationMessage: (message: ChatMessage) => Promise | void; + onOpenWorkspacePath: (path: string) => Promise | void; + onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise | void; + onQuoteAssistantReply: (message: ChatMessage) => void; + onCopyAssistantReply: (text: string) => Promise | 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 ( +
+ {conversation.length === 0 ? ( +
+ {labels.noConversation} +
+ ) : ( + + )} + + {isThinking ? ( +
+
+
+ Nanobot +
+
+
+ + + +
+
{labels.thinking}
+
+
+
+ ) : null} + +
+
+ ); +}, (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({ conversation, isZh, @@ -195,68 +334,29 @@ export function DashboardChatPanel({ const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); return (
-
- {conversation.length === 0 ? ( -
- {labels.noConversation} -
- ) : ( - - )} - - {isThinking ? ( -
-
-
- Nanobot -
-
-
- - - -
-
{labels.thinking}
-
-
-
- ) : null} - -
-
+
{stagedSubmissions.length > 0 ? ( diff --git a/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx b/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx index 70671cc..98c77d3 100644 --- a/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx +++ b/frontend/src/modules/dashboard/components/DashboardConversationMessages.tsx @@ -1,4 +1,5 @@ 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 rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; @@ -50,6 +51,29 @@ interface DashboardConversationMessagesProps { onCopyAssistantReply: (text: string) => Promise | 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; + onToggleProgressExpand: (key: string) => void; + onToggleUserExpand: (key: string) => void; + onEditUserPrompt: (text: string) => void; + onCopyUserPrompt: (text: string) => Promise | void; + onDeleteConversationMessage: (message: ChatMessage) => Promise | void; + onOpenWorkspacePath: (path: string) => Promise | void; + onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise | void; + onQuoteAssistantReply: (message: ChatMessage) => void; + onCopyAssistantReply: (text: string) => Promise | void; +} + function shouldCollapseProgress(text: string) { const normalized = String(text || '').trim(); if (!normalized) return false; @@ -65,6 +89,261 @@ function getConversationItemKey(item: ChatMessage, idx: number) { 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 ( +
+ {showDateDivider ? ( +
+ {formatConversationDate(item.ts, isZh)} +
+ ) : null} +
+
+ {item.role !== 'user' && ( +
+ Nanobot +
+ )} + {item.role === 'user' ? ( +
+ onEditUserPrompt(item.text)} + tooltip={labels.editPrompt} + aria-label={labels.editPrompt} + > + + + void onCopyUserPrompt(item.text)} + tooltip={labels.copyPrompt} + aria-label={labels.copyPrompt} + > + + + void onDeleteConversationMessage(item)} + disabled={isDeleting} + tooltip={labels.deleteMessage} + aria-label={labels.deleteMessage} + > + + +
+ ) : null} + +
+
+ {item.role === 'user' ? labels.you : 'Nanobot'} +
+ {formatClock(item.ts)} + {collapsible ? ( + { + if (isProgressBubble) { + onToggleProgressExpand(itemKey); + return; + } + onToggleUserExpand(itemKey); + }} + tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} + aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} + > + {expanded ? : } + + ) : null} +
+
+
+ {item.text ? ( + item.role === 'user' ? ( + <> + {item.quoted_reply ? ( +
+
{labels.quotedReplyLabel}
+
{normalizeAssistantMessageText(item.quoted_reply)}
+
+ ) : null} +
{normalizeUserMessageText(displayText)}
+ + ) : ( + + {decorateWorkspacePathsForMarkdown(displayText)} + + ) + ) : null} + {(item.attachments || []).length > 0 ? ( +
+ {(item.attachments || []).map((rawPath) => { + const filePath = normalizeDashboardAttachmentPath(rawPath); + const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet); + const filename = filePath.split('/').pop() || filePath; + return ( + { + event.preventDefault(); + void onOpenWorkspacePath(filePath); + }} + title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable} + > + {fileAction === 'download' ? ( + + ) : fileAction === 'preview' ? ( + + ) : ( + + )} + {filename} + + ); + })} +
+ ) : null} + {item.role === 'assistant' && !isProgressBubble ? ( +
+ void onSubmitAssistantFeedback(item, 'up')} + disabled={isFeedbackSaving} + tooltip={labels.goodReply} + aria-label={labels.goodReply} + > + + + void onSubmitAssistantFeedback(item, 'down')} + disabled={isFeedbackSaving} + tooltip={labels.badReply} + aria-label={labels.badReply} + > + + + onQuoteAssistantReply(item)} + tooltip={labels.quoteReply} + aria-label={labels.quoteReply} + > + + + void onCopyAssistantReply(item.text)} + tooltip={labels.copyReply} + aria-label={labels.copyReply} + > + + + void onDeleteConversationMessage(item)} + disabled={isDeleting} + tooltip={labels.deleteMessage} + aria-label={labels.deleteMessage} + > + + +
+ ) : null} +
+
+
+ {item.role === 'user' && ( +
+ +
+ )} +
+
+ ); +}, (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({ conversation, isZh, @@ -89,202 +368,34 @@ export function DashboardConversationMessages({ <> {conversation.map((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 prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : ''; const showDateDivider = idx === 0 || currentDayKey !== prevDayKey; - const isDeleting = Boolean(item.id && deletingMessageIdMap[item.id]); return ( -
- {showDateDivider ? ( -
- {formatConversationDate(item.ts, isZh)} -
- ) : null} -
-
- {item.role !== 'user' && ( -
- Nanobot -
- )} - {item.role === 'user' ? ( -
- onEditUserPrompt(item.text)} - tooltip={labels.editPrompt} - aria-label={labels.editPrompt} - > - - - void onCopyUserPrompt(item.text)} - tooltip={labels.copyPrompt} - aria-label={labels.copyPrompt} - > - - - void onDeleteConversationMessage(item)} - disabled={isDeleting} - tooltip={labels.deleteMessage} - aria-label={labels.deleteMessage} - > - - -
- ) : null} - -
-
- {item.role === 'user' ? labels.you : 'Nanobot'} -
- {formatClock(item.ts)} - {collapsible ? ( - { - if (isProgressBubble) { - onToggleProgressExpand(itemKey); - return; - } - onToggleUserExpand(itemKey); - }} - tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} - aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')} - > - {expanded ? : } - - ) : null} -
-
-
- {item.text ? ( - item.role === 'user' ? ( - <> - {item.quoted_reply ? ( -
-
{labels.quotedReplyLabel}
-
{normalizeAssistantMessageText(item.quoted_reply)}
-
- ) : null} -
{normalizeUserMessageText(displayText)}
- - ) : ( - - {decorateWorkspacePathsForMarkdown(displayText)} - - ) - ) : null} - {(item.attachments || []).length > 0 ? ( -
- {(item.attachments || []).map((rawPath) => { - const filePath = normalizeDashboardAttachmentPath(rawPath); - const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet); - const filename = filePath.split('/').pop() || filePath; - return ( - { - event.preventDefault(); - void onOpenWorkspacePath(filePath); - }} - title={fileAction === 'download' ? labels.download : fileAction === 'preview' ? labels.previewTitle : labels.fileNotPreviewable} - > - {fileAction === 'download' ? ( - - ) : fileAction === 'preview' ? ( - - ) : ( - - )} - {filename} - - ); - })} -
- ) : null} - {item.role === 'assistant' && !isProgressBubble ? ( -
- void onSubmitAssistantFeedback(item, 'up')} - disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} - tooltip={labels.goodReply} - aria-label={labels.goodReply} - > - - - void onSubmitAssistantFeedback(item, 'down')} - disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])} - tooltip={labels.badReply} - aria-label={labels.badReply} - > - - - onQuoteAssistantReply(item)} - tooltip={labels.quoteReply} - aria-label={labels.quoteReply} - > - - - void onCopyAssistantReply(item.text)} - tooltip={labels.copyReply} - aria-label={labels.copyReply} - > - - - void onDeleteConversationMessage(item)} - disabled={isDeleting} - tooltip={labels.deleteMessage} - aria-label={labels.deleteMessage} - > - - -
- ) : null} -
-
-
- {item.role === 'user' && ( -
- -
- )} -
-
+ item={item} + itemKey={itemKey} + isZh={isZh} + labels={labels} + showDateDivider={showDateDivider} + expandedProgress={Boolean(expandedProgressByKey[itemKey])} + expandedUser={Boolean(expandedUserByKey[itemKey])} + isDeleting={Boolean(item.id && deletingMessageIdMap[item.id])} + isFeedbackSaving={Boolean(item.id && feedbackSavingByMessageId[item.id])} + markdownComponents={markdownComponents} + workspaceDownloadExtensionSet={workspaceDownloadExtensionSet} + onToggleProgressExpand={onToggleProgressExpand} + onToggleUserExpand={onToggleUserExpand} + onEditUserPrompt={onEditUserPrompt} + onCopyUserPrompt={onCopyUserPrompt} + onDeleteConversationMessage={onDeleteConversationMessage} + onOpenWorkspacePath={onOpenWorkspacePath} + onSubmitAssistantFeedback={onSubmitAssistantFeedback} + onQuoteAssistantReply={onQuoteAssistantReply} + onCopyAssistantReply={onCopyAssistantReply} + /> ); })}