diff --git a/.claude/skills/walkthrough/SKILL.md b/.claude/skills/walkthrough/SKILL.md new file mode 100644 index 00000000000..07a16d0dd31 --- /dev/null +++ b/.claude/skills/walkthrough/SKILL.md @@ -0,0 +1,60 @@ +--- +name: walkthrough +description: Guidelines and standard format for generating task completion walkthroughs upon finishing a task or user request. +--- + +# Walkthrough Skill + +Generate a structured, clean, and organized **Walkthrough** summary when completing a user task or execution. + +AionUi parses the walkthrough into an interactive **WalkthroughCard** with quick copy, section badges, collapsibles, and syntax-highlighted markdown. + +## When to Use + +Deliver a Walkthrough at the end of a non-trivial execution, such as: + +- Implementing a new feature +- Refactoring or restructuring code +- Fixing complex bugs +- Setting up integrations, models, or configurations + +## Format Specification + +Wrap the walkthrough in `[WALKTHROUGH]...[/WALKTHROUGH]` tags: + +```markdown +[WALKTHROUGH] + +# Walkthrough: + +<Brief overview / summary paragraph explaining what was achieved.> + +## 1. Delivered / What Was Delivered (O que foi Entregue) + +- Summary table or bullet points of files modified/created and components delivered. + +## 2. How It Works (Como Funciona) + +- Technical explanation, architecture, data flow, or mermaid diagram. + +## 3. How to Use & Verify (Como Usar / Como Testar) + +- Step-by-step instructions or commands for the user to run, test, and verify the changes. + +## 4. Things to Watch Out For & Tips (O que Prestar Atenção & Dicas) + +- Important caveats, warnings, edge cases, configuration requirements, or follow-ups. + [/WALKTHROUGH] +``` + +## Section Types Recognized by AionUi + +The parser automatically categorizes headings into semantic sections with custom theme icons: + +| Section Type | Common Headings (English, Portuguese, Chinese) | Icon | +| ---------------- | --------------------------------------------------------------------------------------------------------- | ----------- | +| **`delivered`** | "Delivered", "What was delivered", "Changes Made", "O que foi Entregue", "Alterações", "交付内容" | Green check | +| **`howItWorks`** | "How It Works", "Architecture", "Como Funciona", "Funcionamento", "Arquitetura", "实现原理" | Blue brain | +| **`usage`** | "How to Use", "Usage", "How to Test", "Testing", "Verification", "Como Usar", "Como Testar", "使用与验证" | Orange play | +| **`notes`** | "Notes", "Things to Watch Out For", "Caveats", "Tips", "O que Prestar Atenção", "Dicas", "注意事项" | Red warning | +| **`custom`** | Any other custom section title (e.g. "Next Steps", "PR Status") | Neutral doc | diff --git a/AGENTS.md b/AGENTS.md index 32f4aa9389e..76c88eb6309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,5 +150,6 @@ When opening a PR, fill in the PR body using [.github/pull_request_template.md]( | **i18n** | Internationalization workflow and standards | Adding or changing user-facing text, modifying `locales/` or `packages/desktop/src/common/config/i18n` | | **testing** | Testing workflow and quality standards | Writing tests, changing runtime behavior, fixing bugs, or claiming behavior is verified | | **bump-version** | Version bump workflow: update package.json, checks, branch, PR, tag release | Bumping version, `/bump-version` | +| **walkthrough** | Task completion walkthrough standards and format | Finishing a task or execution, delivering walkthroughs | > Skills are located in `.claude/skills/` and contain project conventions that apply to **all** agents and contributors. diff --git a/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageText.tsx b/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageText.tsx index 6b72dd6a37a..440824d5802 100644 --- a/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageText.tsx +++ b/packages/desktop/src/renderer/pages/conversation/Messages/components/MessageText.tsx @@ -28,6 +28,13 @@ import { stripSkillSuggest, hasSkillSuggest } from '@renderer/utils/chat/skillSu import { isForkEnabled } from '@/common/chat/forkConversation'; import { useForkConversation } from '@/renderer/hooks/chat/useForkConversation'; import ForkBranchIcon from '@renderer/components/base/ForkBranchIcon'; +import WalkthroughCard from './WalkthroughCard'; +import { + parseWalkthrough, + stripWalkthrough, + hasWalkthrough, + cleanWalkthroughForClipboard, +} from './WalkthroughCard/walkthroughParser'; /** * Format a timestamp for message display. @@ -117,6 +124,9 @@ const MessageText: React.FC<{ if (hasSkillSuggest(content)) { content = stripSkillSuggest(content); } + if (hasWalkthrough(content)) { + content = stripWalkthrough(content); + } return content; } return content; @@ -125,6 +135,13 @@ const MessageText: React.FC<{ const { t } = useTranslation(); const [showCopyAlert, setShowCopyAlert] = useState(false); const isUserMessage = message.position === 'right'; + + const walkthrough = useMemo(() => { + if (isUserMessage || !message.content.content || typeof message.content.content !== 'string') { + return null; + } + return parseWalkthrough(message.content.content); + }, [isUserMessage, message.content.content]); // Delivered-but-not-yet-consumed marker for messages sent mid-turn to a // supporting backend (claude/codex). The message already reached the // server (it's rendered); this only answers "has the agent picked it up @@ -184,7 +201,11 @@ const MessageText: React.FC<{ const fileList = files.length ? `Files:\n${files.map((path) => `- ${path}`).join('\n')}\n\n` : ''; // An AI turn split by tool calls / thinking stores several text messages; // the row sits on the last one but must copy the whole reply. - const textToCopy = turnTexts?.length ? buildTurnClipboardText(turnTexts) : fileList + baseText; + const copyContent = + typeof message.content.content === 'string' && hasWalkthrough(message.content.content) + ? cleanWalkthroughForClipboard(message.content.content) + : baseText; + const textToCopy = turnTexts?.length ? buildTurnClipboardText(turnTexts) : fileList + copyContent; copyText(textToCopy) .then(() => { setShowCopyAlert(true); @@ -306,45 +327,48 @@ const MessageText: React.FC<{ )} </div> )} - <div - className={classNames('min-w-0 [&>p:first-child]:mt-0px [&>p:last-child]:mb-0px', { - 'bg-aou-2 p-6px md:p-8px': isUserMessage || cronMeta, - 'bg-3 p-6px md:p-8px': isTeammateMessage, - 'w-full': !(isUserMessage || cronMeta || isTeammateMessage), - })} - style={{ - ...(isUserMessage || cronMeta - ? { borderRadius: '8px 0 8px 8px', color: 'var(--text-primary)' } - : isTeammateMessage - ? { - borderRadius: '0 8px 8px 8px', - ...(teammateColor ? { borderLeft: `3px solid ${teammateColor}` } : {}), - } - : undefined), - }} - > - {/* JSON 内容使用折叠组件 Use CollapsibleContent for JSON content */} - {shouldRenderPlainText ? ( - <div className='whitespace-pre-wrap [overflow-wrap:anywhere]' data-testid='message-text-content'> - {renderedText} - </div> - ) : json ? ( - <CollapsibleContent maxHeight={200} defaultCollapsed={true}> + {Boolean(renderedText && renderedText.trim()) && ( + <div + className={classNames('min-w-0 [&>p:first-child]:mt-0px [&>p:last-child]:mb-0px', { + 'bg-aou-2 p-6px md:p-8px': isUserMessage || cronMeta, + 'bg-3 p-6px md:p-8px': isTeammateMessage, + 'w-full': !(isUserMessage || cronMeta || isTeammateMessage), + })} + style={{ + ...(isUserMessage || cronMeta + ? { borderRadius: '8px 0 8px 8px', color: 'var(--text-primary)' } + : isTeammateMessage + ? { + borderRadius: '0 8px 8px 8px', + ...(teammateColor ? { borderLeft: `3px solid ${teammateColor}` } : {}), + } + : undefined), + }} + > + {/* JSON 内容使用折叠组件 Use CollapsibleContent for JSON content */} + {shouldRenderPlainText ? ( + <div className='whitespace-pre-wrap [overflow-wrap:anywhere]' data-testid='message-text-content'> + {renderedText} + </div> + ) : json ? ( + <CollapsibleContent maxHeight={200} defaultCollapsed={true}> + <div data-testid='message-text-content'> + <MarkdownView + codeStyle={CODE_STYLE} + onLocalFileLink={handleLocalFileLink} + >{`\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``}</MarkdownView> + </div> + </CollapsibleContent> + ) : ( <div data-testid='message-text-content'> - <MarkdownView - codeStyle={CODE_STYLE} - onLocalFileLink={handleLocalFileLink} - >{`\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\``}</MarkdownView> + <MarkdownView codeStyle={CODE_STYLE} onLocalFileLink={handleLocalFileLink}> + {data} + </MarkdownView> </div> - </CollapsibleContent> - ) : ( - <div data-testid='message-text-content'> - <MarkdownView codeStyle={CODE_STYLE} onLocalFileLink={handleLocalFileLink}> - {data} - </MarkdownView> - </div> - )} - </div> + )} + </div> + )} + {walkthrough && <WalkthroughCard walkthrough={walkthrough} onLocalFileLink={handleLocalFileLink} />} {isPendingDelivery && ( <div className='text-12px text-t-secondary mt-4px select-none' data-testid='message-status-badge'> {t('messages.delivery.pending', { defaultValue: 'Unread' })} diff --git a/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/WalkthroughCard.module.css b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/WalkthroughCard.module.css new file mode 100644 index 00000000000..ede42fd141a --- /dev/null +++ b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/WalkthroughCard.module.css @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +.card { + width: 100%; + margin-top: 10px; + margin-bottom: 8px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-primary-6) 28%, var(--color-border-2)); + border-radius: 12px; + background: var(--color-bg-2); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.card:hover { + border-color: color-mix(in srgb, var(--color-primary-6) 45%, var(--color-border-2)); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06); +} + +.header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid transparent; + background: var(--color-fill-1); + cursor: pointer; + user-select: none; + transition: background-color 0.16s ease; +} + +.headerExpanded { + border-bottom-color: var(--color-border-2); +} + +.headerLeft { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.iconBox { + display: flex; + width: 26px; + height: 26px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--color-primary-6); + background: var(--color-primary-light-1); +} + +.title { + display: block; + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--color-text-1); + font-size: 13px; + font-weight: 600; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.badge { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + padding: 1px 7px; + border-radius: 999px; + color: var(--color-primary-6); + background: var(--color-primary-light-1); + font-size: 11px; + font-weight: 600; + line-height: 1.4; +} + +.headerRight { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + margin-inline-start: 12px; +} + +.counterBadge { + color: var(--color-text-3); + font-size: 12px; +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; +} + +.summaryBox { + padding: 8px 12px; + border: 1px solid var(--color-border-2); + border-radius: 8px; + color: var(--color-text-2); + background: var(--color-fill-1); + font-size: 13px; + line-height: 1.5; +} + +.section { + overflow: hidden; + border: 1px solid var(--color-border-2); + border-radius: 8px; + background: var(--color-bg-1); + transition: border-color 0.16s ease; +} + +.section:hover { + border-color: color-mix(in srgb, var(--color-primary-6) 30%, var(--color-border-2)); +} + +.sectionHeader { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--color-fill-1); + cursor: pointer; + user-select: none; + transition: background-color 0.16s ease; +} + +.sectionHeader:hover { + background: var(--color-fill-2); +} + +.sectionHeaderLeft { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.sectionTitle { + color: var(--color-text-1); + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.sectionContent { + padding: 10px 12px; + font-size: 13px; + line-height: 1.6; +} + +.actionButton:global(.arco-btn) { + width: 26px; + height: 26px; + padding: 0; + border-radius: 6px; + color: var(--color-text-2); +} + +.actionButton:global(.arco-btn):hover { + color: var(--color-text-1); + background: var(--color-fill-2); +} diff --git a/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/index.tsx b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/index.tsx new file mode 100644 index 00000000000..01081f33308 --- /dev/null +++ b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/index.tsx @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Tooltip, Message } from '@arco-design/web-react'; +import { Attention, Brain, Check, CheckOne, Compass, Copy, Down, FileText, PlayOne, Up } from '@icon-park/react'; +import classNames from 'classnames'; +import MarkdownView from '@renderer/components/Markdown'; +import { copyText } from '@/renderer/utils/ui/clipboard'; +import type { WalkthroughData, WalkthroughSectionType } from './types'; +import styles from './WalkthroughCard.module.css'; + +const CODE_STYLE = { marginTop: 4, marginBlock: 4 }; + +interface WalkthroughCardProps { + walkthrough: WalkthroughData; + onLocalFileLink?: (path: string) => void; +} + +const renderSectionIcon = (type: WalkthroughSectionType) => { + switch (type) { + case 'delivered': + return <CheckOne size={15} theme='filled' fill='var(--color-success-6)' />; + case 'howItWorks': + return <Brain size={15} theme='outline' fill='var(--color-primary-6)' />; + case 'usage': + return <PlayOne size={15} theme='filled' fill='var(--color-warning-6)' />; + case 'notes': + return <Attention size={15} theme='filled' fill='var(--color-danger-6)' />; + default: + return <FileText size={15} theme='outline' fill='var(--color-text-2)' />; + } +}; + +export const WalkthroughCard: React.FC<WalkthroughCardProps> = ({ walkthrough, onLocalFileLink }) => { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(true); + const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({}); + const [copied, setCopied] = useState(false); + + const displayTitle = walkthrough.title || t('messages.walkthrough.title', { defaultValue: 'Execution Walkthrough' }); + + const handleCopy = useCallback( + async (e?: { stopPropagation?: () => void }) => { + e?.stopPropagation?.(); + try { + await copyText(walkthrough.rawContent); + setCopied(true); + Message.success(t('messages.walkthrough.copySuccess', { defaultValue: 'Walkthrough copied to clipboard' })); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + Message.error(t('messages.walkthrough.copyFailed', { defaultValue: 'Failed to copy walkthrough' })); + console.error('[WalkthroughCard] Failed to copy:', err); + } + }, + [t, walkthrough.rawContent] + ); + + const toggleSection = useCallback((sectionId: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })); + }, []); + + return ( + <div className={styles.card} data-testid='walkthrough-card'> + {/* Header */} + <div + className={classNames(styles.header, { [styles.headerExpanded]: isExpanded })} + onClick={() => setIsExpanded(!isExpanded)} + role='button' + tabIndex={0} + aria-expanded={isExpanded} + > + <div className={styles.headerLeft}> + <div className={styles.iconBox}> + <Compass size={16} theme='filled' /> + </div> + <span className={styles.title} title={displayTitle}> + {displayTitle} + </span> + <span className={styles.badge}>{t('messages.walkthrough.badge', { defaultValue: 'Walkthrough' })}</span> + </div> + + <div className={styles.headerRight}> + {!isExpanded && ( + <span className={styles.counterBadge}> + {t('messages.walkthrough.sectionsCount', { + count: walkthrough.sections.length, + defaultValue: `${walkthrough.sections.length} sections`, + })} + </span> + )} + + <Tooltip content={t('messages.walkthrough.copy', { defaultValue: 'Copy Walkthrough' })} position='top'> + <Button + type='text' + size='mini' + className={styles.actionButton} + onClick={(e) => { + void handleCopy(e); + }} + aria-label={t('messages.walkthrough.copy', { defaultValue: 'Copy Walkthrough' })} + > + {copied ? ( + <Check size={14} fill='var(--color-success-6)' /> + ) : ( + <Copy size={14} fill='var(--color-text-2)' /> + )} + </Button> + </Tooltip> + + <Tooltip + content={ + isExpanded + ? t('messages.walkthrough.collapse', { defaultValue: 'Collapse' }) + : t('messages.walkthrough.expand', { defaultValue: 'Expand' }) + } + position='top' + > + <Button + type='text' + size='mini' + className={styles.actionButton} + onClick={(e) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }} + aria-label={isExpanded ? 'Collapse' : 'Expand'} + > + {isExpanded ? <Up size={14} /> : <Down size={14} />} + </Button> + </Tooltip> + </div> + </div> + + {/* Expanded Body */} + {isExpanded && ( + <div className={styles.body}> + {walkthrough.summary && ( + <div className={styles.summaryBox}> + <MarkdownView codeStyle={CODE_STYLE} onLocalFileLink={onLocalFileLink}> + {walkthrough.summary} + </MarkdownView> + </div> + )} + + {walkthrough.sections.map((section) => { + const isSectionCollapsed = Boolean(collapsedSections[section.id]); + return ( + <div key={section.id} className={styles.section} data-testid={`walkthrough-section-${section.type}`}> + <div + className={styles.sectionHeader} + onClick={() => toggleSection(section.id)} + role='button' + tabIndex={0} + aria-expanded={!isSectionCollapsed} + > + <div className={styles.sectionHeaderLeft}> + {renderSectionIcon(section.type)} + <span className={styles.sectionTitle}>{section.title}</span> + </div> + <Button + type='text' + size='mini' + className={styles.actionButton} + onClick={(e) => { + e.stopPropagation(); + toggleSection(section.id); + }} + aria-label={isSectionCollapsed ? 'Expand section' : 'Collapse section'} + > + {isSectionCollapsed ? <Down size={12} /> : <Up size={12} />} + </Button> + </div> + + {!isSectionCollapsed && ( + <div className={styles.sectionContent}> + <MarkdownView codeStyle={CODE_STYLE} onLocalFileLink={onLocalFileLink}> + {section.content} + </MarkdownView> + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); +}; + +export default WalkthroughCard; diff --git a/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/types.ts b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/types.ts new file mode 100644 index 00000000000..56a99ca2bb8 --- /dev/null +++ b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/types.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +export type WalkthroughSectionType = 'delivered' | 'howItWorks' | 'usage' | 'notes' | 'custom'; + +export interface WalkthroughSection { + id: string; + type: WalkthroughSectionType; + title: string; + content: string; +} + +export interface WalkthroughData { + title: string; + summary?: string; + sections: WalkthroughSection[]; + rawContent: string; +} diff --git a/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/walkthroughParser.ts b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/walkthroughParser.ts new file mode 100644 index 00000000000..2ac7f1fb698 --- /dev/null +++ b/packages/desktop/src/renderer/pages/conversation/Messages/components/WalkthroughCard/walkthroughParser.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WalkthroughData, WalkthroughSection, WalkthroughSectionType } from './types'; + +const EXPLICIT_TAG_REGEX = /\[WALKTHROUGH\]([\s\S]*?)(?:\[\/WALKTHROUGH\]|$)/i; + +const IMPLICIT_HEADER_REGEX = /(?:^|\n)(#{1,2}\s+(?:Walkthrough|Resumo da Execução|Execution Walkthrough)[\s\S]*)$/i; + +/** + * Classify a section heading into one of the known semantic types + * based on multilingual keywords (English, Portuguese, Chinese, Spanish, etc.). + */ +export function classifySectionType(heading: string): WalkthroughSectionType { + const normalized = heading + .toLowerCase() + .replace(/^[\d.)\s\-#]+/, '') + .trim(); + + // 1. Delivered / Changes / What was done + if ( + /\bdelivered\b|\bentregue\b|\bentregas?\b|\balterações\b|\balteracoes\b|\bchanges?\b|\bmodifi|\bwhat was done\b|\bo que foi feito\b|\bwhat changed\b|交付|纳品|сделано/i.test( + normalized + ) + ) { + return 'delivered'; + } + + // 2. Notes / Attention / Watch out / Tips / Caveats (checked before howItWorks so "Dicas Técnicas" matches notes) + if ( + /\bnotes?\b|\batenção\b|\batencao\b|\bwatch out\b|\btips?\b|\bdicas?\b|\bcaveats?\b|\bwarnings?\b|\balertas?\b|\bcuidados?\b|\bobservações\b|\bobservacoes\b|\bprestar atenção\b|\bprestar atencao\b|\bimportante\b|\bimportant\b|注意|注意事項|внимание/i.test( + normalized + ) + ) { + return 'notes'; + } + + // 3. How it works / Architecture / Mechanics / Under the hood + if ( + /\bhow it works?\b|\bcomo funciona\b|\barchitect|\barquitetura\b|\btechnical\b|\bdetalhes técnicos\b|\bdetalhes tecnicos\b|\bfuncionamento\b|\bmecanismo\b|\bunder the hood\b|\bpor baixo dos panos\b|原理|运作/i.test( + normalized + ) + ) { + return 'howItWorks'; + } + + // 4. How to use / Usage / Testing / Verification / Steps + if ( + /\bhow to use\b|\bcomo usar\b|\busage\b|\buso\b|\bhow to test\b|\btesting\b|\bcomo testar\b|\bverification\b|\bverificação\b|\bverificacao\b|\bpasso a passo\b|\bstep[- ]by[- ]step\b|\bquick start\b|\bgetting started\b|使用|検証|инструк/i.test( + normalized + ) + ) { + return 'usage'; + } + + return 'custom'; +} + +/** + * Parse a raw markdown block into structured WalkthroughData. + */ +function parseWalkthroughMarkdown(rawContent: string): WalkthroughData | null { + const trimmed = rawContent.trim(); + if (!trimmed) return null; + + const lines = trimmed.split('\n'); + let title = ''; + let contentStartIndex = 0; + + // Check if first line is a main header `# Title` + const firstLine = lines[0]?.trim() ?? ''; + const titleMatch = firstLine.match(/^#\s+(.+)$/); + if (titleMatch) { + title = titleMatch[1].trim(); + // Clean "Walkthrough:" prefix if present + title = title.replace(/^walkthrough\s*[:\-–—]\s*/i, '').trim(); + contentStartIndex = 1; + } + + const remainingText = lines.slice(contentStartIndex).join('\n').trim(); + + // Find all section headers (## or ###) + const sectionHeadingRegex = /^(#{2,3})\s+(.+)$/gm; + const headings: Array<{ index: number; heading: string }> = []; + let match: RegExpExecArray | null; + + while ((match = sectionHeadingRegex.exec(remainingText)) !== null) { + headings.push({ + index: match.index, + heading: match[2].trim(), + }); + } + + let summary: string | undefined; + const sections: WalkthroughSection[] = []; + + if (headings.length === 0) { + // No subheadings — treating remaining text as content + summary = remainingText || undefined; + if (!summary) return null; + sections.push({ + id: 'section-overview', + type: 'custom', + title: title || 'Overview', + content: summary, + }); + } else { + // Summary is everything before the first heading + const firstHeadingIndex = headings[0].index; + if (firstHeadingIndex > 0) { + const potentialSummary = remainingText + .slice(0, firstHeadingIndex) + .replace(/^---\s*$/gm, '') + .trim(); + if (potentialSummary) { + summary = potentialSummary; + } + } + + // Extract each section + for (let i = 0; i < headings.length; i++) { + const current = headings[i]; + const nextIndex = i + 1 < headings.length ? headings[i + 1].index : remainingText.length; + const sectionFullText = remainingText.slice(current.index, nextIndex); + + // Remove the heading line itself to get the section content + const firstNewline = sectionFullText.indexOf('\n'); + const sectionContent = + firstNewline !== -1 + ? sectionFullText + .slice(firstNewline + 1) + .replace(/---+\s*$/m, '') + .trim() + : ''; + + const sectionType = classifySectionType(current.heading); + + sections.push({ + id: `section-${i}-${sectionType}`, + type: sectionType, + title: current.heading, + content: sectionContent, + }); + } + } + + return { + title, + summary, + sections, + rawContent: trimmed, + }; +} + +/** + * Check if the text contains a walkthrough block (either explicit tags or implicit markdown header). + */ +export function hasWalkthrough(text: string): boolean { + if (!text || typeof text !== 'string') return false; + if (EXPLICIT_TAG_REGEX.test(text)) return true; + + const match = text.match(IMPLICIT_HEADER_REGEX); + if (!match) return false; + + // Validate that implicit header actually has at least 2 structured subsections + const data = parseWalkthroughMarkdown(match[1]); + if (!data || data.sections.length < 2) return false; + + const recognizedTypes = data.sections.filter((s) => s.type !== 'custom'); + return recognizedTypes.length >= 2; +} + +/** + * Parse walkthrough from message content. + * Supports explicit [WALKTHROUGH]...[/WALKTHROUGH] and standard markdown walkthrough headings. + */ +export function parseWalkthrough(text: string): WalkthroughData | null { + if (!text || typeof text !== 'string') return null; + + // 1. Try explicit tag match first + const explicitMatch = text.match(EXPLICIT_TAG_REGEX); + if (explicitMatch && explicitMatch[1]?.trim()) { + return parseWalkthroughMarkdown(explicitMatch[1].trim()); + } + + // 2. Fall back to implicit markdown section + const implicitMatch = text.match(IMPLICIT_HEADER_REGEX); + if (implicitMatch && implicitMatch[1]?.trim()) { + const data = parseWalkthroughMarkdown(implicitMatch[1].trim()); + if (data && data.sections.length >= 2) { + const recognized = data.sections.filter((s) => s.type !== 'custom'); + if (recognized.length >= 2) { + return data; + } + } + } + + return null; +} + +/** + * Strip walkthrough blocks from text so the regular message bubble renders cleanly without it. + */ +export function stripWalkthrough(text: string): string { + if (!text || typeof text !== 'string') return text; + + // 1. If explicit tags exist, strip them + if (EXPLICIT_TAG_REGEX.test(text)) { + return text + .replace(EXPLICIT_TAG_REGEX, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + + // 2. If valid implicit walkthrough exists, strip it + const implicitMatch = text.match(IMPLICIT_HEADER_REGEX); + if (implicitMatch && hasWalkthrough(text)) { + return text + .slice(0, implicitMatch.index) + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + + return text; +} + +/** + * Strip explicit [WALKTHROUGH] tags for clipboard copying so it becomes clean readable markdown. + */ +export function cleanWalkthroughForClipboard(text: string): string { + if (!text || typeof text !== 'string') return text; + return text + .replace(/\[\/?WALKTHROUGH\]/gi, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts b/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts index 677ae71324f..8f8072f94f3 100644 --- a/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts +++ b/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts @@ -1262,6 +1262,16 @@ export type I18nKey = | 'messages.slash.hint' | 'messages.slash.title' | 'messages.unknownMessageType' + | 'messages.walkthrough.badge' + | 'messages.walkthrough.collapse' + | 'messages.walkthrough.copy' + | 'messages.walkthrough.copyFailed' + | 'messages.walkthrough.copySuccess' + | 'messages.walkthrough.expand' + | 'messages.walkthrough.sectionsCount' + | 'messages.walkthrough.sectionsCount_one' + | 'messages.walkthrough.sectionsCount_other' + | 'messages.walkthrough.title' | 'pet.confirmBubble' | 'pet.confirmBubbleDescription' | 'pet.desktopOnly' diff --git a/packages/desktop/src/renderer/services/i18n/locales/de-DE/messages.json b/packages/desktop/src/renderer/services/i18n/locales/de-DE/messages.json index 1cf8b4ca9d6..ccd6d63d248 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/de-DE/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/de-DE/messages.json @@ -97,5 +97,17 @@ "requestDetails": "Anfragedetails:", "delivery": { "pending": "Ungelesen" + }, + "walkthrough": { + "title": "Ausführungs-Walkthrough", + "badge": "Walkthrough", + "copy": "Walkthrough kopieren", + "copySuccess": "Walkthrough in Zwischenablage kopiert", + "copyFailed": "Fehler beim Kopieren des Walkthroughs", + "expand": "Erweitern", + "collapse": "Einklappen", + "sectionsCount": "{{count}} Abschnitte", + "sectionsCount_one": "{{count}} Abschnitt", + "sectionsCount_other": "{{count}} Abschnitte" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/messages.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/messages.json index 0de6c7dd419..44ee19abcec 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/en-US/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/messages.json @@ -97,5 +97,17 @@ "requestDetails": "Request details:", "delivery": { "pending": "Unread" + }, + "walkthrough": { + "title": "Execution Walkthrough", + "badge": "Walkthrough", + "copy": "Copy Walkthrough", + "copySuccess": "Walkthrough copied to clipboard", + "copyFailed": "Failed to copy walkthrough", + "expand": "Expand", + "collapse": "Collapse", + "sectionsCount": "{{count}} sections", + "sectionsCount_one": "{{count}} section", + "sectionsCount_other": "{{count}} sections" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/es-ES/messages.json b/packages/desktop/src/renderer/services/i18n/locales/es-ES/messages.json index 178a03a081b..7475da81f3b 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/es-ES/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/es-ES/messages.json @@ -97,5 +97,17 @@ "requestDetails": "Detalles de la solicitud:", "delivery": { "pending": "No leído" + }, + "walkthrough": { + "title": "Resumen de Ejecución (Walkthrough)", + "badge": "Walkthrough", + "copy": "Copiar resumen", + "copySuccess": "Resumen copiado al portapapeles", + "copyFailed": "Error al copiar resumen", + "expand": "Expandir", + "collapse": "Contraer", + "sectionsCount": "{{count}} secciones", + "sectionsCount_one": "{{count}} sección", + "sectionsCount_other": "{{count}} secciones" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/messages.json b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/messages.json index a430562ab26..1e8590a525b 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/messages.json @@ -97,5 +97,17 @@ "requestDetails": "جزئیات درخواست:", "delivery": { "pending": "خوانده‌نشده" + }, + "walkthrough": { + "title": "راهنمای گام‌به‌گام اجرا (Walkthrough)", + "badge": "Walkthrough", + "copy": "کپی خلاصه", + "copySuccess": "خلاصه در کلیپ‌بورد کپی شد", + "copyFailed": "کپی خلاصه انجام نشد", + "expand": "گسترش", + "collapse": "جمع کردن", + "sectionsCount": "{{count}} بخش", + "sectionsCount_one": "{{count}} بخش", + "sectionsCount_other": "{{count}} بخش" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/messages.json b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/messages.json index 576c4ecfd5b..cbb47ae2a2c 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/messages.json @@ -97,5 +97,17 @@ "requestDetails": "Détails de la demande :", "delivery": { "pending": "Non lu" + }, + "walkthrough": { + "title": "Récapitulatif d'exécution (Walkthrough)", + "badge": "Walkthrough", + "copy": "Copier le récapitulatif", + "copySuccess": "Récapitulatif copié dans le presse-papiers", + "copyFailed": "Échec de la copie du récapitulatif", + "expand": "Développer", + "collapse": "Réduire", + "sectionsCount": "{{count}} sections", + "sectionsCount_one": "{{count}} section", + "sectionsCount_other": "{{count}} sections" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/messages.json b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/messages.json index f13a1646017..11dfdeaf521 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/messages.json @@ -97,5 +97,17 @@ "requestDetails": "リクエスト内容:", "delivery": { "pending": "未読" + }, + "walkthrough": { + "title": "実行ウォークスルー", + "badge": "Walkthrough", + "copy": "ウォークスルーをコピー", + "copySuccess": "クリップボードにコピーしました", + "copyFailed": "コピーに失敗しました", + "expand": "展開", + "collapse": "折りたたむ", + "sectionsCount": "{{count}} 件のセクション", + "sectionsCount_one": "{{count}} 件のセクション", + "sectionsCount_other": "{{count}} 件のセクション" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/messages.json b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/messages.json index 9963844e2c0..c78f0725c46 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/messages.json @@ -97,5 +97,17 @@ "requestDetails": "요청 내용:", "delivery": { "pending": "읽지 않음" + }, + "walkthrough": { + "title": "실행 워크스루", + "badge": "Walkthrough", + "copy": "워크스루 복사", + "copySuccess": "클립보드에 복사되었습니다", + "copyFailed": "복사 실패", + "expand": "펼치기", + "collapse": "접기", + "sectionsCount": "{{count}}개 섹션", + "sectionsCount_one": "{{count}}개 섹션", + "sectionsCount_other": "{{count}}개 섹션" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/messages.json b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/messages.json index fdb8f7b254a..c5400ce7022 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/messages.json @@ -97,5 +97,17 @@ "requestDetails": "Detalhes da solicitação:", "delivery": { "pending": "Não lido" + }, + "walkthrough": { + "title": "Walkthrough da Execução", + "badge": "Walkthrough", + "copy": "Copiar Walkthrough", + "copySuccess": "Walkthrough copiado para a área de transferência", + "copyFailed": "Falha ao copiar walkthrough", + "expand": "Expandir", + "collapse": "Recolher", + "sectionsCount": "{{count}} seções", + "sectionsCount_one": "{{count}} seção", + "sectionsCount_other": "{{count}} seções" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/messages.json b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/messages.json index 563002681af..9f8a0ef55e3 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/messages.json @@ -101,5 +101,17 @@ "requestDetails": "Детали запроса:", "delivery": { "pending": "Непрочитано" + }, + "walkthrough": { + "title": "Обзор выполнения (Walkthrough)", + "badge": "Walkthrough", + "copy": "Скопировать обзор", + "copySuccess": "Обзор скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать обзор", + "expand": "Развернуть", + "collapse": "Свернуть", + "sectionsCount": "Разделов: {{count}}", + "sectionsCount_one": "{{count}} раздел", + "sectionsCount_other": "Разделов: {{count}}" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/messages.json b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/messages.json index a42b137f139..caca4fb202e 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/messages.json @@ -97,5 +97,17 @@ "requestDetails": "İstek ayrıntıları:", "delivery": { "pending": "Okunmadı" + }, + "walkthrough": { + "title": "Yürütme Özeti (Walkthrough)", + "badge": "Walkthrough", + "copy": "Özeti Kopyala", + "copySuccess": "Özet panoya kopyalandı", + "copyFailed": "Özet kopyalanamadı", + "expand": "Genişlet", + "collapse": "Daralt", + "sectionsCount": "{{count}} bölüm", + "sectionsCount_one": "{{count}} bölüm", + "sectionsCount_other": "{{count}} bölüm" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/messages.json b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/messages.json index e318c881db1..34d3e015ebc 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/messages.json @@ -103,5 +103,17 @@ "requestDetails": "Деталі запиту:", "delivery": { "pending": "Непрочитано" + }, + "walkthrough": { + "title": "Огляд виконання (Walkthrough)", + "badge": "Walkthrough", + "copy": "Скопіювати огляд", + "copySuccess": "Огляд скопійовано в буфер обміну", + "copyFailed": "Не вдалося скопіювати огляд", + "expand": "Розгорнути", + "collapse": "Згорнути", + "sectionsCount": "Розділів: {{count}}", + "sectionsCount_one": "{{count}} розділ", + "sectionsCount_other": "Розділів: {{count}}" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/messages.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/messages.json index c32cfc7c0b8..a9b76c58eaf 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/messages.json @@ -97,5 +97,17 @@ "requestDetails": "请求内容:", "delivery": { "pending": "未读" + }, + "walkthrough": { + "title": "任务执行总结", + "badge": "Walkthrough", + "copy": "复制总结", + "copySuccess": "已复制总结到剪贴板", + "copyFailed": "复制总结失败", + "expand": "展开", + "collapse": "折叠", + "sectionsCount": "{{count}} 个模块", + "sectionsCount_one": "{{count}} 个模块", + "sectionsCount_other": "{{count}} 个模块" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/messages.json b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/messages.json index f6fdc649884..c3654a56a6b 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/messages.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/messages.json @@ -97,5 +97,17 @@ "requestDetails": "請求內容:", "delivery": { "pending": "未讀" + }, + "walkthrough": { + "title": "任務執行總結", + "badge": "Walkthrough", + "copy": "複製總結", + "copySuccess": "已複製總結至剪貼簿", + "copyFailed": "複製總結失敗", + "expand": "展開", + "collapse": "摺疊", + "sectionsCount": "{{count}} 個模組", + "sectionsCount_one": "{{count}} 個模組", + "sectionsCount_other": "{{count}} 個模組" } } diff --git a/packages/desktop/src/renderer/utils/chat/turnCopy.ts b/packages/desktop/src/renderer/utils/chat/turnCopy.ts index a68f42c7821..f9ea4c7c19a 100644 --- a/packages/desktop/src/renderer/utils/chat/turnCopy.ts +++ b/packages/desktop/src/renderer/utils/chat/turnCopy.ts @@ -6,6 +6,10 @@ import { hasThinkTags, stripThinkTags } from './thinkTagFilter'; import { hasSkillSuggest, stripSkillSuggest } from './skillSuggestParser'; +import { + hasWalkthrough, + cleanWalkthroughForClipboard, +} from '@renderer/pages/conversation/Messages/components/WalkthroughCard/walkthroughParser'; /** * Turn-level copy support. An AI reply can be split into several stored text @@ -98,6 +102,9 @@ export function buildTurnClipboardText(segments: string[]): string { if (hasSkillSuggest(cleaned)) { cleaned = stripSkillSuggest(cleaned); } + if (hasWalkthrough(cleaned)) { + cleaned = cleanWalkthroughForClipboard(cleaned); + } return cleaned.trim(); }) .filter(Boolean) diff --git a/tests/unit/chat/turnCopy.test.ts b/tests/unit/chat/turnCopy.test.ts index aaf1155d597..2354e50b2bc 100644 --- a/tests/unit/chat/turnCopy.test.ts +++ b/tests/unit/chat/turnCopy.test.ts @@ -83,6 +83,18 @@ describe('buildTurnClipboardText', () => { expect(joined).toContain('tail'); }); + it('cleans walkthrough tags while keeping markdown content in turn copy', () => { + const joined = buildTurnClipboardText([ + 'Done!', + '[WALKTHROUGH]\n# Walkthrough: Feature\n## Delivered\n- Item 1\n[/WALKTHROUGH]', + ]); + expect(joined).not.toContain('[WALKTHROUGH]'); + expect(joined).not.toContain('[/WALKTHROUGH]'); + expect(joined).toContain('Done!'); + expect(joined).toContain('# Walkthrough: Feature'); + expect(joined).toContain('## Delivered'); + }); + it('drops segments that clean down to nothing', () => { expect(buildTurnClipboardText(['<think>only draft</think>', 'kept'])).toBe('kept'); }); diff --git a/tests/unit/renderer/conversation/walkthrough/MessageTextWalkthrough.dom.test.tsx b/tests/unit/renderer/conversation/walkthrough/MessageTextWalkthrough.dom.test.tsx new file mode 100644 index 00000000000..7e30726a2b3 --- /dev/null +++ b/tests/unit/renderer/conversation/walkthrough/MessageTextWalkthrough.dom.test.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { Message } from '@arco-design/web-react'; +import type { IMessageText } from '@/common/chat/chatLib'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k: string, opts?: { defaultValue?: string }) => opts?.defaultValue || k, + i18n: { language: 'en' }, + }), +})); + +vi.mock('@renderer/components/Markdown', () => ({ + default: ({ children }: { children: React.ReactNode }) => <div data-testid='markdown-view'>{children}</div>, +})); + +vi.mock('@/renderer/utils/ui/clipboard', () => ({ + copyText: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/renderer/hooks/context/ConversationContext', () => ({ + useConversationContextSafe: () => ({ + conversation_id: 'conv-123', + workspace: '/test/workspace', + }), +})); + +vi.mock('@/renderer/hooks/context/LayoutContext', () => ({ + useLayoutContext: () => ({ + isMobile: false, + }), +})); + +vi.mock('@/renderer/pages/conversation/Preview/hooks/useLocalFilePreview', () => ({ + useLocalFilePreview: () => vi.fn(), +})); + +vi.mock('@/renderer/hooks/chat/useForkConversation', () => ({ + useForkConversation: () => vi.fn(), +})); + +import MessageText from '@/renderer/pages/conversation/Messages/components/MessageText'; + +describe('MessageText Walkthrough integration', () => { + beforeEach(() => { + vi.spyOn(Message, 'info').mockImplementation(() => '' as never); + vi.spyOn(Message, 'warning').mockImplementation(() => '' as never); + vi.spyOn(Message, 'error').mockImplementation(() => '' as never); + vi.spyOn(Message, 'success').mockImplementation(() => '' as never); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders both text bubble and WalkthroughCard when message has text and walkthrough', () => { + const msg: IMessageText = { + id: 'msg-1', + msg_id: 'msg-1', + type: 'text', + position: 'left', + conversation_id: 'conv-123', + created_at: Date.now(), + content: { + content: `Tudo pronto! Aqui estão as alterações: + +[WALKTHROUGH] +# Walkthrough: Sistema de Ditado +## 1. O que foi Entregue +- Modelo de IA Parakeet +## 2. Como Funciona +- Inferência local +[/WALKTHROUGH]`, + }, + }; + + render(<MessageText message={msg} />); + + expect(screen.getByTestId('message-text-content')).toBeInTheDocument(); + expect(screen.getByText('Tudo pronto! Aqui estão as alterações:')).toBeInTheDocument(); + expect(screen.getByTestId('walkthrough-card')).toBeInTheDocument(); + expect(screen.getByText('Sistema de Ditado')).toBeInTheDocument(); + }); + + it('renders only WalkthroughCard without text bubble when message is purely walkthrough', () => { + const msg: IMessageText = { + id: 'msg-2', + msg_id: 'msg-2', + type: 'text', + position: 'left', + conversation_id: 'conv-123', + created_at: Date.now(), + content: { + content: `[WALKTHROUGH] +# Walkthrough: Pure Summary +## Delivered Changes +- Fixed bug +## Verification +- Run tests +[/WALKTHROUGH]`, + }, + }; + + render(<MessageText message={msg} />); + + expect(screen.queryByTestId('message-text-content')).not.toBeInTheDocument(); + expect(screen.getByTestId('walkthrough-card')).toBeInTheDocument(); + expect(screen.getByText('Pure Summary')).toBeInTheDocument(); + }); + + it('renders only text bubble when message does not contain a walkthrough', () => { + const msg: IMessageText = { + id: 'msg-3', + msg_id: 'msg-3', + type: 'text', + position: 'left', + conversation_id: 'conv-123', + created_at: Date.now(), + content: { + content: 'Just a normal assistant response.', + }, + }; + + render(<MessageText message={msg} />); + + expect(screen.getByTestId('message-text-content')).toBeInTheDocument(); + expect(screen.getByText('Just a normal assistant response.')).toBeInTheDocument(); + expect(screen.queryByTestId('walkthrough-card')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/renderer/conversation/walkthrough/WalkthroughCard.dom.test.tsx b/tests/unit/renderer/conversation/walkthrough/WalkthroughCard.dom.test.tsx new file mode 100644 index 00000000000..a39d99d2c73 --- /dev/null +++ b/tests/unit/renderer/conversation/walkthrough/WalkthroughCard.dom.test.tsx @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { Message } from '@arco-design/web-react'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (k: string, opts?: { defaultValue?: string }) => opts?.defaultValue || k, + i18n: { language: 'en' }, + }), +})); + +vi.mock('@renderer/components/Markdown', () => ({ + default: ({ children }: { children: React.ReactNode }) => <div data-testid='markdown-view'>{children}</div>, +})); + +vi.mock('@/renderer/utils/ui/clipboard', () => ({ + copyText: vi.fn().mockResolvedValue(undefined), +})); + +import WalkthroughCard from '@/renderer/pages/conversation/Messages/components/WalkthroughCard'; +import type { WalkthroughData } from '@/renderer/pages/conversation/Messages/components/WalkthroughCard/types'; +import { copyText } from '@/renderer/utils/ui/clipboard'; + +describe('WalkthroughCard', () => { + beforeEach(() => { + vi.spyOn(Message, 'info').mockImplementation(() => '' as never); + vi.spyOn(Message, 'warning').mockImplementation(() => '' as never); + vi.spyOn(Message, 'error').mockImplementation(() => '' as never); + vi.spyOn(Message, 'success').mockImplementation(() => '' as never); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + const sampleData: WalkthroughData = { + title: 'Parakeet TDT Speech-to-Text', + summary: 'Guia da nova funcionalidade de voz local.', + rawContent: '# Raw markdown content', + sections: [ + { + id: 'sec-1', + type: 'delivered', + title: '1. O que foi Entregue', + content: 'Modelo Parakeet TDT integrado.', + }, + { + id: 'sec-2', + type: 'howItWorks', + title: '2. Como Funciona', + content: 'Decodificação PCM via IPC.', + }, + { + id: 'sec-3', + type: 'usage', + title: '3. Como Usar', + content: 'Abra as configurações e selecione o modelo.', + }, + { + id: 'sec-4', + type: 'notes', + title: '4. O que Prestar Atenção', + content: 'O modelo roda 100% offline.', + }, + ], + }; + + it('renders the walkthrough card with title, badge and sections', () => { + render(<WalkthroughCard walkthrough={sampleData} />); + + expect(screen.getByTestId('walkthrough-card')).toBeInTheDocument(); + expect(screen.getByText('Parakeet TDT Speech-to-Text')).toBeInTheDocument(); + expect(screen.getByText('Walkthrough')).toBeInTheDocument(); + expect(screen.getByText('Guia da nova funcionalidade de voz local.')).toBeInTheDocument(); + + expect(screen.getByTestId('walkthrough-section-delivered')).toBeInTheDocument(); + expect(screen.getByTestId('walkthrough-section-howItWorks')).toBeInTheDocument(); + expect(screen.getByTestId('walkthrough-section-usage')).toBeInTheDocument(); + expect(screen.getByTestId('walkthrough-section-notes')).toBeInTheDocument(); + }); + + it('copies raw content when copy button is clicked', async () => { + render(<WalkthroughCard walkthrough={sampleData} />); + + const copyBtn = screen.getByLabelText('Copy Walkthrough'); + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(copyText).toHaveBeenCalledWith('# Raw markdown content'); + expect(Message.success).toHaveBeenCalledWith('Walkthrough copied to clipboard'); + }); + + it('collapses and expands the entire card', () => { + render(<WalkthroughCard walkthrough={sampleData} />); + + const collapseBtn = screen.getByLabelText('Collapse'); + fireEvent.click(collapseBtn); + + // Body sections should no longer be visible + expect(screen.queryByTestId('walkthrough-section-delivered')).not.toBeInTheDocument(); + + // Click to expand again + const expandBtn = screen.getByLabelText('Expand'); + fireEvent.click(expandBtn); + + expect(screen.getByTestId('walkthrough-section-delivered')).toBeInTheDocument(); + }); + + it('toggles collapse on individual sections', () => { + render(<WalkthroughCard walkthrough={sampleData} />); + + const sectionTitle = screen.getByText('1. O que foi Entregue'); + expect(screen.getByText('Modelo Parakeet TDT integrado.')).toBeInTheDocument(); + + // Click section header to collapse it + fireEvent.click(sectionTitle); + expect(screen.queryByText('Modelo Parakeet TDT integrado.')).not.toBeInTheDocument(); + + // Click section header to expand it again + fireEvent.click(sectionTitle); + expect(screen.getByText('Modelo Parakeet TDT integrado.')).toBeInTheDocument(); + }); + + it('renders custom section icon for unrecognized section types', () => { + const dataWithCustom: WalkthroughData = { + title: 'Custom Section Walkthrough', + rawContent: 'test', + sections: [ + { + id: 'sec-custom', + type: 'custom', + title: '5. Pull Request Status', + content: 'PR #4247 is open.', + }, + ], + }; + + render(<WalkthroughCard walkthrough={dataWithCustom} />); + expect(screen.getByTestId('walkthrough-section-custom')).toBeInTheDocument(); + expect(screen.getByText('5. Pull Request Status')).toBeInTheDocument(); + }); + + it('collapses and expands by clicking the header container directly', () => { + render(<WalkthroughCard walkthrough={sampleData} />); + + const header = screen.getByRole('button', { name: /Parakeet TDT Speech-to-Text/i }); + fireEvent.click(header); + + // Body should be hidden + expect(screen.queryByTestId('walkthrough-section-delivered')).not.toBeInTheDocument(); + + // Click again to expand + fireEvent.click(header); + expect(screen.getByTestId('walkthrough-section-delivered')).toBeInTheDocument(); + }); + + it('handles copy error gracefully and shows error message', async () => { + vi.mocked(copyText).mockRejectedValueOnce(new Error('Clipboard denied')); + + render(<WalkthroughCard walkthrough={sampleData} />); + + const copyBtn = screen.getByLabelText('Copy Walkthrough'); + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(Message.error).toHaveBeenCalledWith('Failed to copy walkthrough'); + }); +}); diff --git a/tests/unit/renderer/conversation/walkthrough/walkthroughParser.test.ts b/tests/unit/renderer/conversation/walkthrough/walkthroughParser.test.ts new file mode 100644 index 00000000000..6cb1cd5db16 --- /dev/null +++ b/tests/unit/renderer/conversation/walkthrough/walkthroughParser.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2026 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + parseWalkthrough, + stripWalkthrough, + hasWalkthrough, + classifySectionType, + cleanWalkthroughForClipboard, +} from '@/renderer/pages/conversation/Messages/components/WalkthroughCard/walkthroughParser'; + +describe('walkthroughParser', () => { + const sampleExplicitWalkthrough = `Here is the summary of the work done: + +[WALKTHROUGH] +# Walkthrough: Ditado Local com Parakeet TDT v3 no AionUi + +Guia completo e organizado sobre a nova funcionalidade de **Ditado Local**. + +## 1. O que foi Entregue +- Modelo de IA Parakeet TDT v3 quantizado INT8 +- Integração no Chat com gravação de áudio + +## 2. Como Funciona por Baixo dos Panos +Decodificação de áudio em PCM 16kHz mono enviada via IPC. + +## 3. Como Usar no Dia a Dia +1. Abra as Configurações +2. Selecione Local (Offline - Parakeet TDT) + +## 4. O que Prestar Atenção & Dicas Técnicas +> [!TIP] +> 100% offline e privado. +[/WALKTHROUGH]`; + + describe('classifySectionType', () => { + it('classifies delivered sections across languages', () => { + expect(classifySectionType('1. O que foi Entregue')).toBe('delivered'); + expect(classifySectionType('What was delivered')).toBe('delivered'); + expect(classifySectionType('Changes Made')).toBe('delivered'); + expect(classifySectionType('交付内容')).toBe('delivered'); + }); + + it('classifies how it works sections across languages', () => { + expect(classifySectionType('2. Como Funciona por Baixo dos Panos')).toBe('howItWorks'); + expect(classifySectionType('How It Works')).toBe('howItWorks'); + expect(classifySectionType('Architecture & Mechanics')).toBe('howItWorks'); + expect(classifySectionType('实现原理')).toBe('howItWorks'); + }); + + it('classifies usage and testing sections across languages', () => { + expect(classifySectionType('3. Como Usar no Dia a Dia')).toBe('usage'); + expect(classifySectionType('How to Test')).toBe('usage'); + expect(classifySectionType('Verification Steps')).toBe('usage'); + expect(classifySectionType('使用与验证')).toBe('usage'); + }); + + it('classifies notes, caveats and tips sections across languages', () => { + expect(classifySectionType('4. O que Prestar Atenção & Dicas Técnicas')).toBe('notes'); + expect(classifySectionType('Notes and Caveats')).toBe('notes'); + expect(classifySectionType('Important Tips & Warnings')).toBe('notes'); + expect(classifySectionType('注意事项')).toBe('notes'); + }); + + it('falls back to custom for unrecognized titles', () => { + expect(classifySectionType('5. Next Steps and PR Status')).toBe('custom'); + }); + }); + + describe('hasWalkthrough', () => { + it('returns true when explicit tag is present', () => { + expect(hasWalkthrough(sampleExplicitWalkthrough)).toBe(true); + }); + + it('returns true for streaming/unclosed explicit tag', () => { + expect(hasWalkthrough('Working on it...\n\n[WALKTHROUGH]\n# Partial walkthrough')).toBe(true); + }); + + it('returns true for implicit markdown section with at least 2 structured subheadings', () => { + const markdown = `Tudo pronto! +# Walkthrough: User Authentication +## Delivered Changes +- Auth service +## How to Test +- Run tests`; + expect(hasWalkthrough(markdown)).toBe(true); + }); + + it('returns false when no walkthrough exists', () => { + expect(hasWalkthrough('Just normal chat message')).toBe(false); + expect(hasWalkthrough('')).toBe(false); + }); + + it('returns false for casual mention of the word walkthrough without structure', () => { + expect(hasWalkthrough('Can you give me a walkthrough of how this code works?')).toBe(false); + }); + }); + + describe('parseWalkthrough', () => { + it('parses explicit walkthrough block completely', () => { + const data = parseWalkthrough(sampleExplicitWalkthrough); + expect(data).not.toBeNull(); + expect(data?.title).toBe('Ditado Local com Parakeet TDT v3 no AionUi'); + expect(data?.summary).toContain('Guia completo e organizado'); + expect(data?.sections).toHaveLength(4); + + expect(data?.sections[0].type).toBe('delivered'); + expect(data?.sections[0].title).toBe('1. O que foi Entregue'); + expect(data?.sections[0].content).toContain('Parakeet TDT v3'); + + expect(data?.sections[1].type).toBe('howItWorks'); + expect(data?.sections[1].content).toContain('PCM 16kHz mono'); + + expect(data?.sections[2].type).toBe('usage'); + expect(data?.sections[2].content).toContain('Abra as Configurações'); + + expect(data?.sections[3].type).toBe('notes'); + expect(data?.sections[3].content).toContain('100% offline e privado'); + }); + + it('parses implicit markdown walkthrough', () => { + const implicit = `Task completed! + +# Walkthrough: Refactor State +## Delivered Changes +- Replaced Redux with Zustand +## How It Works +- Lightweight state store +## Verification +- Run bun run test`; + + const data = parseWalkthrough(implicit); + expect(data).not.toBeNull(); + expect(data?.title).toBe('Refactor State'); + expect(data?.sections).toHaveLength(3); + expect(data?.sections[0].type).toBe('delivered'); + expect(data?.sections[1].type).toBe('howItWorks'); + expect(data?.sections[2].type).toBe('usage'); + }); + + it('parses walkthrough without subheadings as single overview section', () => { + const plain = `[WALKTHROUGH] +# Walkthrough: Simple Task +Here is just a plain summary without any subsections. +[/WALKTHROUGH]`; + const data = parseWalkthrough(plain); + expect(data).not.toBeNull(); + expect(data?.title).toBe('Simple Task'); + expect(data?.sections).toHaveLength(1); + expect(data?.sections[0].type).toBe('custom'); + }); + + it('returns null for empty or non-walkthrough content', () => { + expect(parseWalkthrough('')).toBeNull(); + expect(parseWalkthrough('Just an answer')).toBeNull(); + }); + }); + + describe('stripWalkthrough', () => { + it('strips explicit walkthrough from message text', () => { + const stripped = stripWalkthrough(sampleExplicitWalkthrough); + expect(stripped).toBe('Here is the summary of the work done:'); + expect(stripped).not.toContain('[WALKTHROUGH]'); + expect(stripped).not.toContain('Parakeet TDT v3'); + }); + + it('strips implicit walkthrough section from text', () => { + const implicit = `Hello user! +Here are your results. + +# Walkthrough: Feature X +## Delivered +- Done +## How to Test +- Test it`; + + const stripped = stripWalkthrough(implicit); + expect(stripped).toBe('Hello user!\nHere are your results.'); + expect(stripped).not.toContain('Walkthrough: Feature X'); + }); + + it('leaves normal text unchanged when no walkthrough is present', () => { + const text = 'Normal text without any walkthrough.'; + expect(stripWalkthrough(text)).toBe(text); + }); + }); + + describe('cleanWalkthroughForClipboard', () => { + it('removes tags while preserving markdown content', () => { + const cleaned = cleanWalkthroughForClipboard(sampleExplicitWalkthrough); + expect(cleaned).not.toContain('[WALKTHROUGH]'); + expect(cleaned).not.toContain('[/WALKTHROUGH]'); + expect(cleaned).toContain('# Walkthrough: Ditado Local'); + expect(cleaned).toContain('## 1. O que foi Entregue'); + }); + }); +});