diff --git a/src/main/services/ai/ChatRunService.ts b/src/main/services/ai/ChatRunService.ts index 35ba3863..dc751a1c 100644 --- a/src/main/services/ai/ChatRunService.ts +++ b/src/main/services/ai/ChatRunService.ts @@ -270,6 +270,7 @@ export class ChatRunService { role: 'user', content: payload.input, timestamp: createdAt, + contentNodes: payload.contentNodes, selectedReferences: normalizedReferences } await this.saveUserMessageAndTitle(userMessage) @@ -345,6 +346,7 @@ export class ChatRunService { role: 'user', content: payload.input, timestamp: createdAt, + contentNodes: payload.contentNodes, selectedReferences: normalizedReferences, error: false } diff --git a/src/main/services/storage/core/LanceDbCore.ts b/src/main/services/storage/core/LanceDbCore.ts index 26b2aa6b..da834ede 100644 --- a/src/main/services/storage/core/LanceDbCore.ts +++ b/src/main/services/storage/core/LanceDbCore.ts @@ -377,6 +377,7 @@ export class LanceDbCore { new arrow.Field('role', new arrow.Utf8()), new arrow.Field('content', new arrow.Utf8()), new arrow.Field('timestamp', new arrow.Int64()), + new arrow.Field('content_nodes_json', new arrow.Utf8(), true), new arrow.Field('selected_references_json', new arrow.Utf8(), true), new arrow.Field('artifacts_json', new arrow.Utf8(), true), new arrow.Field('error', new arrow.Bool(), true) diff --git a/src/main/services/storage/domains/ChatStore.ts b/src/main/services/storage/domains/ChatStore.ts index 7e9ae150..f0d0f6ac 100644 --- a/src/main/services/storage/domains/ChatStore.ts +++ b/src/main/services/storage/domains/ChatStore.ts @@ -20,6 +20,7 @@ type ChatMessageRow = { role?: unknown content?: unknown timestamp?: unknown + content_nodes_json?: unknown selected_references_json?: unknown artifacts_json?: unknown error?: unknown @@ -51,6 +52,16 @@ const parseSelectedReferences = (value: unknown): ChatMessage['selectedReference } } +const parseContentNodes = (value: unknown): ChatMessage['contentNodes'] => { + if (!value) return undefined + try { + const parsed = JSON.parse(String(value)) + return Array.isArray(parsed) ? parsed : undefined + } catch { + return undefined + } +} + export class ChatStore { public constructor(private readonly core: LanceDbCore) {} @@ -193,6 +204,7 @@ export class ChatStore { role: message.role, content: message.content, timestamp: message.timestamp, + content_nodes_json: message.contentNodes ? JSON.stringify(message.contentNodes) : undefined, selected_references_json: message.selectedReferences ? JSON.stringify(message.selectedReferences) : undefined, @@ -217,6 +229,7 @@ export class ChatStore { role: toChatRole(row.role), content: String(row.content || ''), timestamp: Number(row.timestamp || 0), + contentNodes: parseContentNodes(row.content_nodes_json), selectedReferences: parseSelectedReferences(row.selected_references_json), artifacts: parseArtifacts(row.artifacts_json), error: typeof row.error === 'boolean' ? row.error : undefined diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css index 36a8ca51..8f7aabf5 100644 --- a/src/renderer/src/assets/base.css +++ b/src/renderer/src/assets/base.css @@ -581,7 +581,7 @@ textarea, } .nc-document-scroll { - @apply min-h-0 flex-1 overflow-y-auto px-6 pt-5; + @apply min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-5; } .nc-document { @@ -896,6 +896,8 @@ mark { color: var(--ink); font-size: 14px; line-height: 1.78; + min-width: 0; + overflow-wrap: anywhere; } .markdown-body > *:first-child { @@ -949,6 +951,7 @@ mark { .markdown-body a { color: var(--notion-accent-blue); text-decoration: none; + overflow-wrap: anywhere; transition: background-color var(--ui-transition), color var(--ui-transition), @@ -963,10 +966,13 @@ mark { display: inline-flex; align-items: baseline; gap: 0.35rem; + max-width: 100%; + min-width: 0; vertical-align: middle; color: var(--notion-accent-blue); text-decoration: none; text-underline-offset: 3px; + overflow-wrap: anywhere; } .markdown-body .markdown-reference-icon { @@ -983,6 +989,8 @@ mark { text-decoration: none; text-underline-offset: 3px; vertical-align: middle; + word-break: break-word; + overflow-wrap: anywhere; } .markdown-body .markdown-reference-link:hover, diff --git a/src/renderer/src/components/MarkdownRenderer.tsx b/src/renderer/src/components/MarkdownRenderer.tsx index a2d755e7..03916270 100644 --- a/src/renderer/src/components/MarkdownRenderer.tsx +++ b/src/renderer/src/components/MarkdownRenderer.tsx @@ -65,10 +65,7 @@ const MarkdownRenderer: React.FC = ({ {String(children).replace(/\n$/, '')} ) : ( - + {children} ) @@ -108,7 +105,7 @@ const MarkdownRenderer: React.FC = ({ resourceType: parsed.resourceType })} - {children} + {children} ) } @@ -124,9 +121,7 @@ const MarkdownRenderer: React.FC = ({ ) }, - blockquote: ({ children }) => ( -
{children}
- ), + blockquote: ({ children }) =>
{children}
, table: ({ children }) => (
{children}
diff --git a/src/renderer/src/components/fileRenders/index.css b/src/renderer/src/components/fileRenders/index.css index c2a8ba7b..923b7e73 100644 --- a/src/renderer/src/components/fileRenders/index.css +++ b/src/renderer/src/components/fileRenders/index.css @@ -1,15 +1,37 @@ .vue-office-docx { - overflow-y: unset !important; + overflow-y: unset !important; +} + +.docx-content { + overflow-x: hidden; +} + +.docx-content .docx-wrapper { + box-sizing: border-box !important; + width: 100% !important; + min-width: 0 !important; + overflow-x: hidden !important; + background: #f7f7f5 !important; + padding: 16px !important; + padding-bottom: 0 !important; +} + +.docx-content .docx-wrapper > section.docx, +.docx-content section.docx { + box-sizing: border-box !important; + max-width: 100% !important; + margin-left: auto !important; + margin-right: auto !important; } .docx-content ins { - background: #F4F4F2 !important; - color: #2F3437 !important; - text-decoration: none !important; + background: #f4f4f2 !important; + color: #2f3437 !important; + text-decoration: none !important; } .docx-content del { - background: #F8EBE9 !important; - color: #B3261E !important; - text-decoration: line-through !important; + background: #f8ebe9 !important; + color: #b3261e !important; + text-decoration: line-through !important; } diff --git a/src/renderer/src/components/markdownReference.tsx b/src/renderer/src/components/markdownReference.tsx index 83e16c83..22f5dc89 100644 --- a/src/renderer/src/components/markdownReference.tsx +++ b/src/renderer/src/components/markdownReference.tsx @@ -1,8 +1,5 @@ import React from 'react' -import { - FileTextOutlined, - GlobalOutlined -} from '@ant-design/icons' +import { FileTextOutlined, GlobalOutlined } from '@ant-design/icons' export type ParsedNcuratorRefKind = 'reference' | 'normal' export type ParsedNcuratorRefResourceType = 'file' | 'web' @@ -29,22 +26,27 @@ export const parseRefHref = (href?: string): ParsedRefLink => { } try { - const url = new URL(href) - const kind = url.searchParams.get('kind') - const resourceType = url.searchParams.get('resourceType') - const label = url.searchParams.get('label') || '' - const path = url.searchParams.get('path') || '' - if ( - (kind !== 'reference' && kind !== 'normal') || - (resourceType !== 'file' && resourceType !== 'web') || - !label || - !path - ) { + const params = new URLSearchParams(href.slice('ncurator://ref?'.length)) + const rawKind = params.get('kind') + const rawResourceType = params.get('resourceType') + const path = params.get('path') || '' + if (!path) { return { type: 'normal', href } } + const kind: ParsedNcuratorRefKind = + rawKind === 'reference' || rawKind === 'normal' ? rawKind : 'normal' + const resourceType: ParsedNcuratorRefResourceType = + rawResourceType === 'file' || rawResourceType === 'web' + ? rawResourceType + : /^https?:\/\//i.test(path) + ? 'web' + : 'file' + const normalizedPath = path.replace(/\\/g, '/') + const fallbackLabel = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath || path + const label = params.get('label') || fallbackLabel return { type: 'reference', kind, diff --git a/src/renderer/src/components/workbench/AssistantTurnCard.tsx b/src/renderer/src/components/workbench/AssistantTurnCard.tsx index 533c51e6..3b3e237b 100644 --- a/src/renderer/src/components/workbench/AssistantTurnCard.tsx +++ b/src/renderer/src/components/workbench/AssistantTurnCard.tsx @@ -11,7 +11,6 @@ import { HiOutlinePencilSquare } from 'react-icons/hi2' import MarkdownRenderer from '../MarkdownRenderer' -import { FILE_CHANGE_STATUS_META } from './constants' import type { AssistantArtifactItem, AssistantProcessGroup, @@ -22,10 +21,7 @@ import type { } from './types' import type { ParsedNcuratorRefKind, ParsedNcuratorRefResourceType } from '../markdownReference' import { workbenchSelectors } from '../../testing/selectors' -import { - buildAssistantExecutionSummaryText, - resolveAssistantExecutionStatus -} from './utils' +import { buildAssistantExecutionSummaryText, resolveAssistantExecutionStatus } from './utils' type AssistantTurnCardProps = { messageId: string @@ -238,7 +234,9 @@ const AssistantExecutionGroupView: React.FC<{ groups: AssistantProcessGroup[]; i }) => { const status = resolveAssistantExecutionStatus(groups) const isRunning = status === 'running' - const items = groups.flatMap((group) => group.items).sort((left, right) => left.createdAt - right.createdAt) + const items = groups + .flatMap((group) => group.items) + .sort((left, right) => left.createdAt - right.createdAt) const summaryText = buildAssistantExecutionSummaryText(groups) return (
void -): React.ReactNode => ( - - - -) +): React.ReactNode => { + const testIdSuffix = (file.applyId || file.path).replace(/[^a-zA-Z0-9_-]/g, '-') + return ( + + + + ) +} + +const getCompactSourcePath = (sourcePath?: string): string => { + if (!sourcePath) return '' + const normalized = sourcePath.replace(/\\/g, '/') + const workspaceMarker = '/workspace/' + const workspaceIndex = normalized.lastIndexOf(workspaceMarker) + if (workspaceIndex >= 0) return normalized.slice(workspaceIndex + workspaceMarker.length) + const parts = normalized.split('/').filter(Boolean) + if (parts.length <= 2) return normalized + return parts.slice(-3).join('/') +} const AssistantTurnCard: React.FC = ({ messageId, @@ -310,6 +323,18 @@ const AssistantTurnCard: React.FC = ({ const processThoughts = useMemo(() => turn?.processThoughts || [], [turn?.processThoughts]) const finalAnswerMarkdown = turn?.finalAnswerMarkdown || '' const hasFinalAnswer = finalAnswerMarkdown.trim().length > 0 + const visibleArtifacts = useMemo(() => { + if (!turn) return [] + const applyIds = new Set(turn.applyTargets.map((target) => target.changeId)) + const applyPaths = new Set( + turn.applyTargets + .map((target) => target.sourcePath) + .filter((path): path is string => Boolean(path)) + ) + return turn.artifacts.filter( + (file) => !(file.applyId && applyIds.has(file.applyId)) && !applyPaths.has(file.path) + ) + }, [turn]) const timeline = useMemo( () => buildAssistantTimeline({ @@ -376,7 +401,7 @@ const AssistantTurnCard: React.FC = ({
{turn.applyTargets.map((apply) => { - const statusMeta = apply.status ? FILE_CHANGE_STATUS_META[apply.status] : null + const compactPath = getCompactSourcePath(apply.sourcePath) return ( ) @@ -407,9 +431,9 @@ const AssistantTurnCard: React.FC = ({
) : null} - {turn.artifacts.length > 0 ? ( + {visibleArtifacts.length > 0 ? (
- {turn.artifacts.map((file) => renderArtifactChip(file, onOpenFile))} + {visibleArtifacts.map((file) => renderArtifactChip(file, onOpenFile))}
) : null} diff --git a/src/renderer/src/components/workbench/WorkbenchComposer.tsx b/src/renderer/src/components/workbench/WorkbenchComposer.tsx index d646e5cc..9bae4874 100644 --- a/src/renderer/src/components/workbench/WorkbenchComposer.tsx +++ b/src/renderer/src/components/workbench/WorkbenchComposer.tsx @@ -368,7 +368,7 @@ const WorkbenchComposer: React.FC = ({ renderComposerNodesToEditor(editor, []) ensureEditorHasCaretTarget() setHasContent(false) - onDraftChange({ input: '', selectedReferences: [] }) + onDraftChange({ input: '', contentNodes: [], selectedReferences: [] }) closeMention() }, [closeMention, ensureEditorHasCaretTarget, onDraftChange, resetKey]) diff --git a/src/renderer/src/components/workbench/applyTargetUtils.ts b/src/renderer/src/components/workbench/applyTargetUtils.ts index d226786a..597c33eb 100644 --- a/src/renderer/src/components/workbench/applyTargetUtils.ts +++ b/src/renderer/src/components/workbench/applyTargetUtils.ts @@ -4,6 +4,7 @@ import type { AssistantTurnApplyTarget } from './types' export const buildApplyTarget = (record: AiFileChangeRecord): AssistantTurnApplyTarget => ({ changeId: record.id, title: record.fileName, + sourcePath: record.path, changeCount: record.changeCount, status: record.status }) @@ -29,7 +30,17 @@ export const groupApplyTargetsByRunId = ( return new Map( [...grouped.entries()].map(([runId, runRecords]) => [ runId, - runRecords + Array.from( + runRecords + .slice() + .sort((left, right) => right.updatedAt - left.updatedAt) + .reduce((latestByPath, record) => { + const key = record.path || record.id + if (!latestByPath.has(key)) latestByPath.set(key, record) + return latestByPath + }, new Map()) + .values() + ) .slice() .sort((left, right) => right.updatedAt - left.updatedAt) .map((record) => buildApplyTarget(record)) diff --git a/src/renderer/src/components/workbench/composer/ReferenceIcon.tsx b/src/renderer/src/components/workbench/composer/ReferenceIcon.tsx index d54769bf..2f0f96bf 100644 --- a/src/renderer/src/components/workbench/composer/ReferenceIcon.tsx +++ b/src/renderer/src/components/workbench/composer/ReferenceIcon.tsx @@ -4,17 +4,19 @@ import { getReferenceVisualSpec } from './referenceVisuals' type ReferenceIconProps = { reference: SelectedReference - size?: 'sm' | 'md' + size?: 'sm' | 'md' | 'inline' } const iconSizeClassName = { sm: 'h-3.5 w-3.5', - md: 'h-4 w-4' + md: 'h-4 w-4', + inline: 'h-[1em] w-[1em]' } as const const containerSizeClassName = { sm: 'h-[18px] w-[18px]', - md: 'h-7 w-7' + md: 'h-7 w-7', + inline: 'h-[1.15em] w-[1.15em]' } as const const DocumentGlyph: React.FC<{ diff --git a/src/renderer/src/components/workbench/composer/ReferenceInlineToken.tsx b/src/renderer/src/components/workbench/composer/ReferenceInlineToken.tsx index 88a112d7..d335da2f 100644 --- a/src/renderer/src/components/workbench/composer/ReferenceInlineToken.tsx +++ b/src/renderer/src/components/workbench/composer/ReferenceInlineToken.tsx @@ -18,10 +18,10 @@ export const ReferenceInlineToken: React.FC = ({ return ( - + {reference.name} ) diff --git a/src/renderer/src/components/workbench/composer/types.ts b/src/renderer/src/components/workbench/composer/types.ts index 1b009b03..dfd23b88 100644 --- a/src/renderer/src/components/workbench/composer/types.ts +++ b/src/renderer/src/components/workbench/composer/types.ts @@ -1,4 +1,4 @@ -import type { SelectedReference } from '../../../../../shared/types' +import type { ChatMessageContentNode, SelectedReference } from '../../../../../shared/types' export type ComposerTextNode = { id: string @@ -16,5 +16,6 @@ export type ComposerNode = ComposerTextNode | ComposerScopeNode export type ComposerDraftValue = { input: string + contentNodes: ChatMessageContentNode[] selectedReferences: SelectedReference[] } diff --git a/src/renderer/src/components/workbench/composer/utils.ts b/src/renderer/src/components/workbench/composer/utils.ts index 41594666..051713a2 100644 --- a/src/renderer/src/components/workbench/composer/utils.ts +++ b/src/renderer/src/components/workbench/composer/utils.ts @@ -1,4 +1,4 @@ -import type { SelectedReference } from '../../../../../shared/types' +import type { ChatMessageContentNode, SelectedReference } from '../../../../../shared/types' import type { ComposerDraftValue, ComposerNode } from './types' import { getReferenceKey, pruneReferencesByWorkspace } from '../../../utils/reference' import { getReferenceVisualSpec } from './referenceVisuals' @@ -35,6 +35,8 @@ export const normalizeComposerNodes = (nodes: ComposerNode[]): ComposerNode[] => export const extractComposerDraftValue = (nodes: ComposerNode[]): ComposerDraftValue => { const scopeMap = new Map() + const seenScopeKeys = new Set() + const contentNodes: ChatMessageContentNode[] = [] const input = nodes .filter((node): node is Extract => node.kind === 'text') .map((node) => node.text) @@ -43,12 +45,35 @@ export const extractComposerDraftValue = (nodes: ComposerNode[]): ComposerDraftV .trim() nodes.forEach((node) => { - if (node.kind !== 'scope') return - scopeMap.set(getScopeKey(node.scope), node.scope) + if (node.kind === 'text') { + const text = node.text.replace(/\u00a0/g, ' ') + if (text) contentNodes.push({ kind: 'text', text }) + return + } + const scopeKey = getScopeKey(node.scope) + if (seenScopeKeys.has(scopeKey)) return + seenScopeKeys.add(scopeKey) + scopeMap.set(scopeKey, node.scope) + contentNodes.push({ kind: 'reference', reference: node.scope }) }) + const firstText = contentNodes.find((node) => node.kind === 'text') + if (firstText?.kind === 'text') firstText.text = firstText.text.replace(/^\s+/, '') + let lastText: ChatMessageContentNode | undefined + for (let index = contentNodes.length - 1; index >= 0; index -= 1) { + if (contentNodes[index].kind === 'text') { + lastText = contentNodes[index] + break + } + } + if (lastText?.kind === 'text') lastText.text = lastText.text.replace(/\s+$/, '') + const normalizedContentNodes = contentNodes.filter( + (node) => node.kind !== 'text' || node.text.length > 0 + ) + return { input, + contentNodes: normalizedContentNodes, selectedReferences: Array.from(scopeMap.values()) } } @@ -90,7 +115,7 @@ export const readComposerNodesFromEditor = ( const appendReferenceIcon = (container: HTMLElement, scope: SelectedReference): void => { const visualSpec = getReferenceVisualSpec(scope) const icon = document.createElement('span') - icon.className = `inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center ${visualSpec.iconClassName}` + icon.className = `inline-flex h-[1.15em] w-[1.15em] shrink-0 items-center justify-center ${visualSpec.iconClassName}` icon.dataset.referenceIconKind = visualSpec.kind const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') @@ -99,7 +124,7 @@ const appendReferenceIcon = (container: HTMLElement, scope: SelectedReference): visualSpec.kind === 'directory' || visualSpec.kind === 'web' ? '0 0 24 24' : '0 0 18 18' ) svg.setAttribute('aria-hidden', 'true') - svg.setAttribute('class', `h-3.5 w-3.5 ${visualSpec.iconClassName}`) + svg.setAttribute('class', `h-[1em] w-[1em] ${visualSpec.iconClassName}`) if (visualSpec.kind === 'directory' || visualSpec.kind === 'web') { svg.setAttribute('fill', 'none') @@ -172,7 +197,7 @@ const appendReferenceIcon = (container: HTMLElement, scope: SelectedReference): export const createScopeTokenElement = (scope: SelectedReference): HTMLElement => { const visualSpec = getReferenceVisualSpec(scope) const token = document.createElement('span') - token.className = `inline-flex max-w-full items-center gap-1 align-middle text-[13px] font-medium leading-5 ${visualSpec.labelClassName}` + token.className = `inline-flex max-w-full items-center gap-1 align-[-0.08em] text-[0.9em] font-medium leading-[inherit] ${visualSpec.labelClassName}` token.contentEditable = 'false' token.dataset.role = 'scope-token' token.dataset.scopeKey = getScopeKey(scope) diff --git a/src/renderer/src/components/workbench/types.ts b/src/renderer/src/components/workbench/types.ts index 515fd4af..c16e9ab5 100644 --- a/src/renderer/src/components/workbench/types.ts +++ b/src/renderer/src/components/workbench/types.ts @@ -166,6 +166,7 @@ export type FileChangeWorkspaceTab = { export type AssistantTurnApplyTarget = { changeId: string title: string + sourcePath?: string changeCount: number status?: AiFileChangeStatus } diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 32c1cdce..7f8177cf 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -21,6 +21,7 @@ import type { AiRunEvent, AiRunRecord, ChatMessage, + ChatMessageContentNode, ChatSession, ChatSessionRunSnapshot, DocxApplyRecord, @@ -158,6 +159,7 @@ const artifactToApplyTarget = ( return { changeId: artifact.applyId, title: artifact.fileName, + sourcePath: artifact.path, changeCount: 0, status: 'applied' } @@ -236,6 +238,7 @@ const reduceAssistantTurnByRunEvent = ( ? mergeApplyTargets(next.applyTargets, { changeId: event.applyId, title: event.fileName, + sourcePath: event.path, changeCount: 0, status: 'applied' }) @@ -335,14 +338,10 @@ const applyRunRecordToTurn = ( format: artifact.capability === 'docx' ? ('docx' as const) : undefined })) ]), - applyTargets: [ - ...turn.applyTargets, - ...artifacts - .map(artifactToApplyTarget) - .filter((target): target is AssistantTurnViewModel['applyTargets'][number] => - Boolean(target) - ) - ], + applyTargets: artifacts + .map(artifactToApplyTarget) + .filter((target): target is AssistantTurnViewModel['applyTargets'][number] => Boolean(target)) + .reduce((items, target) => mergeApplyTargets(items, target), turn.applyTargets), blockers: run.pendingApproval ? [ { @@ -489,6 +488,7 @@ const ChatPage: React.FC = () => { const [loading, setLoading] = useState(false) const [aiRunId, setAiRunId] = useState(null) const [cancellingRun, setCancellingRun] = useState(false) + const [composerContentNodes, setComposerContentNodes] = useState([]) const [selectedReferences, setSelectedReferences] = useState([]) const [workspaces, setWorkspaces] = useState([]) const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(() => @@ -1274,6 +1274,7 @@ const ChatPage: React.FC = () => { setAiRunId(null) setCancellingRun(false) setInput('') + setComposerContentNodes([]) setSelectedReferences([]) setComposerResetKey((value) => value + 1) setPendingApprovalBatch(null) @@ -1638,6 +1639,7 @@ const ChatPage: React.FC = () => { } const userQuery = input.trim() + const submittedContentNodes = composerContentNodes const submittedReferences = selectedReferences const likelyExecutable = /\b(cmd|bash|shell|terminal|command|run)\b/i.test(userQuery) || @@ -1648,6 +1650,7 @@ const ChatPage: React.FC = () => { } setInput('') + setComposerContentNodes([]) setSelectedReferences([]) setComposerResetKey((value) => value + 1) shouldAutoScrollRef.current = true @@ -1663,6 +1666,7 @@ const ChatPage: React.FC = () => { mode: 'ai', sessionId: currentSessionId, input: userQuery, + contentNodes: submittedContentNodes.length > 0 ? submittedContentNodes : undefined, selectedReferences: submittedReferences.length > 0 ? submittedReferences : undefined, modelName: selectedModelName, workspace: createWorkspaceBinding(selectedWorkspace) @@ -1708,6 +1712,7 @@ const ChatPage: React.FC = () => { } const userQuery = input.trim() + const submittedContentNodes = composerContentNodes const submittedReferences = selectedReferences const hasStrictScope = selectedReferences.length > 0 || Boolean(selectedWorkspace) if (hasStrictScope && kbScopeDocumentIds.length === 0) { @@ -1716,6 +1721,7 @@ const ChatPage: React.FC = () => { } setInput('') + setComposerContentNodes([]) setSelectedReferences([]) setComposerResetKey((value) => value + 1) shouldAutoScrollRef.current = true @@ -1731,6 +1737,7 @@ const ChatPage: React.FC = () => { mode: 'search', sessionId: currentSessionId, input: userQuery, + contentNodes: submittedContentNodes.length > 0 ? submittedContentNodes : undefined, selectedReferences: submittedReferences.length > 0 ? submittedReferences : undefined, workspace: createWorkspaceBinding(selectedWorkspace), searchDocumentIds: hasStrictScope ? kbScopeDocumentIds : undefined @@ -2011,29 +2018,39 @@ const ChatPage: React.FC = () => { } const renderUserMessage = (messageItem: ChatMessage): React.ReactNode => { - const messageReferences = messageItem.selectedReferences || [] + const contentNodes: ChatMessageContentNode[] = + messageItem.contentNodes && messageItem.contentNodes.length > 0 + ? messageItem.contentNodes + : [ + ...(messageItem.selectedReferences || []).map( + (reference): ChatMessageContentNode => ({ kind: 'reference', reference }) + ), + ...(messageItem.content ? [{ kind: 'text' as const, text: messageItem.content }] : []) + ] return (
-
-
- {messageReferences.length > 0 ? ( -
- {messageReferences.map((reference) => ( +
+
+
+ {contentNodes.map((node, index) => + node.kind === 'reference' ? ( - ))} -
- ) : null} - {messageItem.content} + ) : ( + {node.text} + ) + )} +
@@ -2075,8 +2092,13 @@ const ChatPage: React.FC = () => { } const handleComposerDraftChange = useCallback( - (draft: { input: string; selectedReferences: SelectedReference[] }) => { + (draft: { + input: string + contentNodes: ChatMessageContentNode[] + selectedReferences: SelectedReference[] + }) => { setInput(draft.input) + setComposerContentNodes(draft.contentNodes) setSelectedReferences(draft.selectedReferences) }, [] diff --git a/src/renderer/src/testing/selectors.ts b/src/renderer/src/testing/selectors.ts index 159c699f..8797d73f 100644 --- a/src/renderer/src/testing/selectors.ts +++ b/src/renderer/src/testing/selectors.ts @@ -130,6 +130,7 @@ export const workbenchSelectors = { assistantProcessItemPrefix: 'workbench-assistant-process-item', assistantFinalAnswerPrefix: 'workbench-assistant-final-answer', assistantTurnApplyPrefix: 'workbench-assistant-turn-apply', + assistantTurnArtifactPrefix: 'workbench-assistant-turn-artifact', searchResults: 'workbench-search-results', documentView: 'workbench-document-view', markdownViewer: 'workbench-markdown-viewer', diff --git a/src/shared/types.ts b/src/shared/types.ts index b2f291dc..e90945f3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -502,12 +502,23 @@ export type AiLlmUsage = { rawUsage?: JsonObject } +export type ChatMessageContentNode = + | { + kind: 'text' + text: string + } + | { + kind: 'reference' + reference: SelectedReference + } + export interface ChatMessage { id: string session_id: string role: 'user' | 'assistant' | 'system' content: string timestamp: number + contentNodes?: ChatMessageContentNode[] selectedReferences?: SelectedReference[] artifacts?: AiArtifactRef[] error?: boolean @@ -784,6 +795,7 @@ export type ChatSubmitRequest = { mode: ChatSubmitMode sessionId: string input: string + contentNodes?: ChatMessageContentNode[] selectedReferences?: SelectedReference[] modelName?: string workspace?: WorkspaceRuntimeBinding diff --git a/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts b/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts index 354d96af..b41b1daf 100644 --- a/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts +++ b/tests/case/workbench/conversation/scope/reference-token-lifecycle.e2e.test.ts @@ -252,6 +252,11 @@ describe('workbench conversation e2e', () => { await submitWorkbenchCurrentDraft(page) const latestUserMessageReferenceTokens = await getWorkbenchLatestUserMessageReferenceTokenTexts(page) + const latestUserMessageText = ( + (await getWorkbenchLocator(page, workbenchSelectors.userMessageReferences) + .last() + .textContent()) || '' + ).trim() const latestUserMessageReferenceIconKinds = await getWorkbenchLocator( page, workbenchSelectors.userMessageReferences @@ -309,6 +314,7 @@ describe('workbench conversation e2e', () => { composerTokenCloseButtonVisible, tokensBeforeDelete, latestUserMessageReferenceTokens, + latestUserMessageText, latestUserMessageReferenceIconKinds, afterDelete, afterWorkspaceSwitch, @@ -321,7 +327,12 @@ describe('workbench conversation e2e', () => { }, assert: async (result, runtime) => { await runtime.assertThat( - result.afterFile.some((text) => text.includes('sample-edit.docx')) && + result.latestUserMessageText.indexOf('sample-edit.docx') >= 0 && + result.latestUserMessageText.indexOf('工作') > + result.latestUserMessageText.indexOf('sample-edit.docx') && + result.latestUserMessageText.indexOf('请用一句话说明这些资料。') > + result.latestUserMessageText.indexOf('工作') && + result.afterFile.some((text) => text.includes('sample-edit.docx')) && result.afterDirectory.some((text) => text.includes('工作')) && result.afterWeb.some((text) => text.includes('sample-edit.docx')) && result.afterWeb.some((text) => text.includes('工作')) && diff --git a/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts index 203461bd..bd902e90 100644 --- a/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/docx-apply-panel-preview-and-revert.e2e.test.ts @@ -20,86 +20,116 @@ const CASE_ID = 'docx-apply-panel-preview-and-revert' const MARKER = 'cedar-apply-731' describe('workbench result e2e', () => { - it(CASE_ID, async () => { - await runE2ECase({ - caseId: CASE_ID, - domain: 'workbench', - suite: 'result', - groups: ['apply'], - description: '真实 AI 修改真实 docx 后,验证 assistant 修改入口、右侧预览和干净回退。', - fixtures: ['tests/fixtures/workspace'], - launchMode: 'real', - windowMode: 'visible', - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID }) - try { - const targetPath = path.join(setup.workspace.rootPath, 'sample-edit.docx') - const beforeText = await readDocxPlainText(targetPath) - await insertWorkbenchReferenceToken(page, 'sample-edit.docx') - const startedAtMs = Date.now() - await sendWorkbenchMessage( - page, - `请直接修改我刚选中的 sample-edit.docx,在文末新增一段:“Workbench apply marker ${MARKER}”。完成后告诉我已写回。` - ) - const evidence = await waitForWorkbenchAiRunCompletion( - runtime, - startedAtMs, - setup.env.modelName - ) - recordWorkbenchAiEvidence(runtime, evidence, 0.8) - const afterApplyText = await readDocxPlainText(targetPath) - const applyEntry = await waitForApplyEntry(page) - await applyEntry.click() - await getWorkbenchLocator(page, workbenchSelectors.applyPanel).waitFor({ - state: 'visible' - }) - const applyPanelText = - ((await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) || - '').trim() - const docxViewerVisible = await getWorkbenchLocator(page, workbenchSelectors.docxViewer) - .isVisible() - .catch(() => false) - await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click() - await waitForWorkbenchApplyRecordStatus(page, 'reverted') - const afterRevertText = await readDocxPlainText(targetPath) - const revertedPanelText = - ((await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) || - '').trim() + it( + CASE_ID, + async () => { + await runE2ECase({ + caseId: CASE_ID, + domain: 'workbench', + suite: 'result', + groups: ['apply'], + description: '真实 AI 修改真实 docx 后,验证 assistant 修改入口、右侧预览和干净回退。', + fixtures: ['tests/fixtures/workspace'], + launchMode: 'real', + windowMode: 'visible', + runMode: 'single', + execute: async (runtime) => { + const { page } = runtime + const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID }) + try { + const targetPath = path.join(setup.workspace.rootPath, 'sample-edit.docx') + const beforeText = await readDocxPlainText(targetPath) + await insertWorkbenchReferenceToken(page, 'sample-edit.docx') + const startedAtMs = Date.now() + await sendWorkbenchMessage( + page, + `请直接修改我刚选中的 sample-edit.docx,在文末新增一段:“Workbench apply marker ${MARKER}”。完成后告诉我已写回。` + ) + const evidence = await waitForWorkbenchAiRunCompletion( + runtime, + startedAtMs, + setup.env.modelName, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, + { page, blockerPolicy: 'auto_approve' } + ) + recordWorkbenchAiEvidence(runtime, evidence, 0.8) + const afterApplyText = await readDocxPlainText(targetPath) + const applyEntry = await waitForApplyEntry(page) + const applyEntryText = ((await applyEntry.textContent()) || '').trim() + const artifactChipCount = await getWorkbenchLocator(page, workbenchSelectors.page) + .locator(`[data-testid^="${workbenchSelectors.assistantTurnArtifactPrefix}-"]`) + .count() + await applyEntry.click() + await getWorkbenchLocator(page, workbenchSelectors.applyPanel).waitFor({ + state: 'visible' + }) + const applyPanelText = ( + (await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) || '' + ).trim() + const docxViewerVisible = await getWorkbenchLocator(page, workbenchSelectors.docxViewer) + .isVisible() + .catch(() => false) + const docxViewerNoHorizontalOverflow = await getWorkbenchLocator( + page, + workbenchSelectors.docxViewer + ) + .evaluate((element) => { + if (!(element instanceof HTMLElement)) return false + const rootFits = element.scrollWidth <= element.clientWidth + 1 + const renderedPagesFit = Array.from( + element.querySelectorAll('.docx-wrapper, section.docx') + ).every((node) => node.scrollWidth <= node.clientWidth + 1) + return rootFits && renderedPagesFit + }) + .catch(() => false) + await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click() + await waitForWorkbenchApplyRecordStatus(page, 'reverted') + const afterRevertText = await readDocxPlainText(targetPath) + const revertedPanelText = ( + (await getWorkbenchLocator(page, workbenchSelectors.applyPanel).textContent()) || '' + ).trim() - return { - beforeText, - afterApplyText, - afterRevertText, - applyPanelText, - revertedPanelText, - docxViewerVisible, - fileArtifactCount: evidence.events.filter((event) => event.type === 'file_artifact') - .length + return { + beforeText, + afterApplyText, + afterRevertText, + applyEntryText, + artifactChipCount, + applyPanelText, + revertedPanelText, + docxViewerVisible, + docxViewerNoHorizontalOverflow, + fileArtifactCount: evidence.events.filter((event) => event.type === 'file_artifact') + .length + } + } finally { + await setup.workspace.cleanup() } - } finally { - await setup.workspace.cleanup() + }, + assert: async (result, runtime) => { + await runtime.assertThat( + !result.beforeText.includes(MARKER) && + result.afterApplyText.includes(MARKER) && + result.afterRevertText === result.beforeText && + result.applyEntryText.includes('sample-edit.docx') && + !result.applyEntryText.includes('已修改') && + result.artifactChipCount === 0 && + result.applyPanelText.includes('回退本次修改') && + result.revertedPanelText.includes('已回退') && + result.docxViewerVisible && + result.docxViewerNoHorizontalOverflow && + result.fileArtifactCount >= 1, + { + name: 'workbench-docx-apply-preview-revert-real', + message: + 'AI 修改真实 docx 后应展示修改文件入口和右侧 apply 工作区;无后续修改时回退应恢复 checkpoint 并刷新状态,对齐 WB-APPLY-001、WB-APPLY-002、WB-APPLY-003、WB-PANEL-003。', + data: result + } + ) + await assertNoWorkbenchFakeSignals(runtime) } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - !result.beforeText.includes(MARKER) && - result.afterApplyText.includes(MARKER) && - result.afterRevertText === result.beforeText && - result.applyPanelText.includes('回退本次修改') && - result.revertedPanelText.includes('已回退') && - result.docxViewerVisible && - result.fileArtifactCount >= 1, - { - name: 'workbench-docx-apply-preview-revert-real', - message: - 'AI 修改真实 docx 后应展示修改文件入口和右侧 apply 工作区;无后续修改时回退应恢复 checkpoint 并刷新状态,对齐 WB-APPLY-001、WB-APPLY-002、WB-APPLY-003、WB-PANEL-003。', - data: result - } - ) - await assertNoWorkbenchFakeSignals(runtime) - } - }) - }, WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS) + }) + }, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS + ) }) diff --git a/tests/harness/e2e/workbench.ts b/tests/harness/e2e/workbench.ts index 7c962a09..8f885b6e 100644 --- a/tests/harness/e2e/workbench.ts +++ b/tests/harness/e2e/workbench.ts @@ -376,23 +376,28 @@ export const startLocalWorkbenchHttpServer = async ( } } -export const navigateToWorkbench = async (page: Page): Promise => { +export const navigateToWorkbench = async ( + page: Page, + options?: { waitForSessionListText?: boolean } +): Promise => { await page.evaluate(() => { window.location.hash = '#/' }) await getWorkbenchLocator(page, workbenchSelectors.page).waitFor({ state: 'visible' }) await getWorkbenchLocator(page, workbenchSelectors.sessionsList).waitFor({ state: 'visible' }) - await waitForCondition({ - name: 'workbench session list ready', - timeoutMs: WORKBENCH_E2E_TIMEOUT_MS, - check: async () => { - const sessionListText = - (await getWorkbenchLocator(page, workbenchSelectors.sessionsList) - .textContent() - .catch(() => '')) || '' - return sessionListText.trim().length > 0 - } - }) + if (options?.waitForSessionListText !== false) { + await waitForCondition({ + name: 'workbench session list ready', + timeoutMs: WORKBENCH_E2E_TIMEOUT_MS, + check: async () => { + const sessionListText = + (await getWorkbenchLocator(page, workbenchSelectors.sessionsList) + .textContent() + .catch(() => '')) || '' + return sessionListText.trim().length > 0 + } + }) + } await getWorkbenchLocator(page, workbenchSelectors.input).waitFor({ state: 'visible' }) } @@ -414,7 +419,7 @@ export const configureLiveModelThroughSettingsUi = async ( state: 'visible' }) - await navigateToWorkbench(page) + await navigateToWorkbench(page, { waitForSessionListText: false }) await selectWorkbenchModel(page, env.modelName) }