diff --git a/apps/desktop/src/main/__tests__/attachment-frontend-contract.test.ts b/apps/desktop/src/main/__tests__/attachment-frontend-contract.test.ts index b5f3c156c6d..c2f587260a1 100644 --- a/apps/desktop/src/main/__tests__/attachment-frontend-contract.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-frontend-contract.test.ts @@ -141,8 +141,8 @@ describe('attachment frontend contract', () => { // These assertions pin the converged form so the surfaces cannot drift back. describe('attachment visual token governance (#546)', () => { it('image thumbnail placeholder uses maka surface-alpha + radius tokens, not shadcn aliases', async () => { - const chatView = await readRepo('packages/ui/src/chat-view.tsx'); - const m = chatView.match(/maka-user-attachment-thumb-pending[^"]*/); + const chatTurn = await readRepo('packages/ui/src/chat-turn.tsx'); + const m = chatTurn.match(/maka-user-attachment-thumb-pending[^"]*/); assert.ok(m, 'pending thumbnail className not found — component renamed?'); const cls = m[0]; // No raw shadcn surface aliases — the card next to it uses --foreground-alpha-*. diff --git a/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts b/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts index c29fad334f2..4e0ac06da70 100644 --- a/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts @@ -62,7 +62,7 @@ describe('chat status cluster layout contract', () => { // live tail's derived status is `completed`, so a real footer would offer // a clickable regenerate/branch on a still-streaming answer. Reserving the // box keeps the live→settled swap height-neutral on the one node. - const src = await readRepo('packages/ui/src/chat-view.tsx'); + const src = `${await readRepo('packages/ui/src/chat-view.tsx')}\n${await readRepo('packages/ui/src/chat-turn.tsx')}`; assert.doesNotMatch( src, /maka-turn-streaming/, diff --git a/apps/desktop/src/main/__tests__/disclosure-collapsible-contract.test.ts b/apps/desktop/src/main/__tests__/disclosure-collapsible-contract.test.ts index feac2498d6a..db7c0c57996 100644 --- a/apps/desktop/src/main/__tests__/disclosure-collapsible-contract.test.ts +++ b/apps/desktop/src/main/__tests__/disclosure-collapsible-contract.test.ts @@ -27,7 +27,7 @@ import { describe, it } from 'node:test'; import { REPO_ROOT } from './css-test-helpers.js'; const MIGRATED_FILES = [ - 'packages/ui/src/chat-view.tsx', + 'packages/ui/src/chat-turn.tsx', 'packages/ui/src/permission-dialog.tsx', 'packages/ui/src/tool-activity.tsx', ]; diff --git a/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts b/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts index 7f223d163bf..a143e44dcd1 100644 --- a/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts +++ b/apps/desktop/src/main/__tests__/search-modal-lifecycle-contract.test.ts @@ -42,6 +42,7 @@ import { renderSessionListPanel } from './session-list-render-helpers.js'; const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); const COMPONENTS_PATH = resolve(REPO_ROOT, 'packages', 'ui', 'src', 'chat-view.tsx'); +const CHAT_TURN_PATH = resolve(REPO_ROOT, 'packages', 'ui', 'src', 'chat-turn.tsx'); const SEARCH_MODAL_PATH = resolve(REPO_ROOT, 'packages', 'ui', 'src', 'search-modal.tsx'); const COMMAND_PALETTE_CONTENT_PATH = join(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer', 'command-palette-content-search.ts'); @@ -162,6 +163,7 @@ describe('SearchModal lifecycle contract (PR-SIDEBAR-IA-0 Phase 3 P0 fixup)', () it('search result navigation consumes target.turnId instead of only switching sessions', async () => { const searchModalSource = await readFile(SEARCH_MODAL_PATH, 'utf8'); const components = await readFile(COMPONENTS_PATH, 'utf8'); + const chatTurn = await readFile(CHAT_TURN_PATH, 'utf8'); const main = await readRendererShellCombinedSource(); const contentSearch = await readFile(COMMAND_PALETTE_CONTENT_PATH, 'utf8'); @@ -216,12 +218,12 @@ describe('SearchModal lifecycle contract (PR-SIDEBAR-IA-0 Phase 3 P0 fixup)', () 'ChatView must move keyboard focus to the matched turn after search navigation', ); assert.match( - components, + chatTurn, /data-search-highlight=\{props\.searchHighlighted\s*\?\s*'true'\s*:\s*undefined\}/, 'ChatView must visually mark the matched search turn', ); assert.match( - components, + chatTurn, /tabIndex=\{props\.searchHighlighted \? -1 : undefined\}/, 'Highlighted search turns must be programmatically focusable while the search target is active', ); diff --git a/apps/desktop/src/main/__tests__/streamdown-markdown-contract.test.ts b/apps/desktop/src/main/__tests__/streamdown-markdown-contract.test.ts index a29587f10fa..c4de1e5fa8d 100644 --- a/apps/desktop/src/main/__tests__/streamdown-markdown-contract.test.ts +++ b/apps/desktop/src/main/__tests__/streamdown-markdown-contract.test.ts @@ -13,7 +13,7 @@ it('uses Streamdown streaming mode for live answers without a second fade owner' const [body, wrapper, chat] = await Promise.all([ uiSource('markdown-body.tsx'), uiSource('markdown.tsx'), - uiSource('chat-view.tsx'), + uiSource('chat-turn.tsx'), ]); assert.match(body, /import \{[^}]*Streamdown[^}]*\} from 'streamdown';/); diff --git a/apps/desktop/src/main/__tests__/streaming-timeline-render-contract.test.ts b/apps/desktop/src/main/__tests__/streaming-timeline-render-contract.test.ts index ade663cf839..35005c46b91 100644 --- a/apps/desktop/src/main/__tests__/streaming-timeline-render-contract.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-timeline-render-contract.test.ts @@ -5,10 +5,14 @@ import { resolve } from 'node:path'; it('renders live thinking and text from timeline items instead of a trailing live content path', async () => { const source = await readFile( - resolve(import.meta.dirname, '../../../../../packages/ui/src/chat-view.tsx'), + resolve(import.meta.dirname, '../../../../../packages/ui/src/chat-turn.tsx'), 'utf8', ); + const chatView = await readFile( + resolve(import.meta.dirname, '../../../../../packages/ui/src/chat-view.tsx'), + 'utf8', + ); assert.match( source, /item\.kind === 'thinking'[\s\S]*? { }); it('gates the inline footer copy action instead of silently firing raw clipboard writes', async () => { - const componentsPath = resolve(process.cwd(), '..', '..', 'packages', 'ui', 'src', 'chat-view.tsx'); + const componentsPath = resolve(process.cwd(), '..', '..', 'packages', 'ui', 'src', 'chat-turn.tsx'); const src = await readFile(componentsPath, 'utf8'); const footerBlock = src.match(/function TurnFooterActions[\s\S]*?const STATUS_FOOTER_ICON/)?.[0] ?? ''; @@ -529,14 +530,12 @@ describe('tool error copy feedback contract', () => { describe('chat markdown copy feedback contract', () => { it('gates assistant message copy without redacting the raw message markdown', async () => { - const componentsPath = resolve(process.cwd(), '..', '..', 'packages', 'ui', 'src', 'chat-view.tsx'); + const componentsPath = resolve(process.cwd(), '..', '..', 'packages', 'ui', 'src', 'chat-turn.tsx'); const src = await readFile(componentsPath, 'utf8'); // PR-UI-LIB-EXTRACT-6 (round 7/10) moved the markdown layer // out of components.tsx; PR-UI-LIB-EXTRACT-8 (round 9/10) // then moved `EmptyChatHero` itself into `chat-empty-hero.tsx`. - // The next-named-thing anchor after `MessageCopyButton` is now - // `ChatHeaderAlertBadge`. - const block = src.match(/function MessageCopyButton[\s\S]*?function ChatHeaderAlertBadge/)?.[0] ?? ''; + const block = src.match(/function MessageCopyButton[\s\S]*?export const TurnView/)?.[0] ?? ''; assert.match(block, /useClipboardCopyFeedback\(1400, \{ redact: false \}\)/, 'Message copy should preserve raw assistant markdown.'); assert.match(block, /await copyFeedback\.copy\('message', props\.text\)/, 'Message copy should route through the guarded helper.'); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx new file mode 100644 index 00000000000..758b314d3e0 --- /dev/null +++ b/packages/ui/src/chat-turn.tsx @@ -0,0 +1,970 @@ +import { Fragment, memo, useEffect, useRef, useState, type ReactNode } from 'react'; +import { AlertOctagon, Ban, Brain, Check, ChevronRight, Copy, GitBranch, Info, Loader2, RefreshCcw, Timer } from './icons.js'; +import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; +import { Markdown } from './markdown.js'; +import { formatAbsoluteTimestamp, formatClockTime, turnAbortMarkerLabel } from './chat-display-helpers.js'; +import { prepareSmoothStreamText, useSmoothStreamContent } from './smooth-stream.js'; +import { tokenizeFade, useStreamFade, type StreamFade } from './stream-fade.js'; +import { DialogContent, DialogRoot } from './ui.js'; +import type { AttachmentRef } from '@maka/core'; +import type { TurnTimelineItem, TurnViewModel } from './materialize.js'; +import { Button as UiButton } from './ui.js'; +import { AttachmentFileCard } from './attachment-file-card.js'; +import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './primitives/collapsible.js'; +import { Bubble, Marker, markerVariants, Message, TextShimmer } from './primitives/chat.js'; +import { Tooltip, TooltipTrigger, TooltipContent } from './primitives/tooltip.js'; +import { ToolTrow } from './tool-activity.js'; + + +/** + * Renders an individual chat message body. + * + * - `user` messages stay verbatim (whitespace + line breaks preserved); the + * user's literal input shouldn't be reinterpreted as markdown. + * - `assistant` / `system` (and anything else) flow through the markdown + * renderer so code fences, lists, tables, and links display natively. + * + * Assistant messages get a hover Copy button that yanks the raw markdown + * source to the clipboard. + * + * Memoized because chat scroll re-renders the whole list on every streaming + * delta; this keeps already-final bubbles from re-parsing markdown. + */ +function AttachmentImage(props: { attachment: AttachmentRef }) { + const [src, setSrc] = useState(undefined); + const [lightboxOpen, setLightboxOpen] = useState(false); + useEffect(() => { + if (props.attachment.ref.kind !== 'session_file') return; + const reader = (window as unknown as { + maka?: { + attachments?: { + readBytes?: ( + sessionId: string, + relativePath: string, + ) => Promise<{ ok: true; base64: string; mimeType: string } | { ok: false }>; + }; + }; + }).maka?.attachments?.readBytes; + if (!reader) return; + let cancelled = false; + reader(props.attachment.ref.sessionId, props.attachment.ref.relativePath) + .then((result) => { + if (cancelled || !result.ok) return; + setSrc(`data:${result.mimeType};base64,${result.base64}`); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [props.attachment]); + if (!src) { + return ( + + ); + } + return ( + <> + + + + {props.attachment.name} + + + + ); +} + +const MessageBody = memo(function MessageBody(props: { role: string; text: string; ts?: number; attachments?: readonly AttachmentRef[] }) { + if (props.role === 'user') { + // User turn: the message sits in a tinted, width-capped block aligned to + // the right (so the right-anchor reads even for long messages), with an + // absolute HH:mm time + a copy affordance in a meta row beneath it. #642: + // the whole meta row is hover-gated on the user bubble (`group/usermsg`) — + // hidden at rest, revealed on hover / focus-within, matching the assistant + // footer's hover reveal. Copy reuses MessageCopyButton in `footerStyle`, so + // it's the same quiet ghost action as the assistant turn footer's copy + // (same primitive + `markerVariants('footer-action')`). + return ( + <> + + {props.text} + {props.attachments && props.attachments.length > 0 ? ( +
+ {props.attachments.map((attachment, index) => ( + attachment.kind === 'image' ? ( + + ) : ( + + ) + ))} +
+ ) : null} +
+ {/* #642: the whole meta row — absolute HH:mm time + copy — hides by + default and appears when the user bubble is hovered or keyboard + focus lands inside (keys off `group/usermsg` on the user Message). + Absolute wall-clock time (not relative "N 小时前"); the full date + stays on the time's `title` and the bubble's own `title`. */} +
+ {props.ts !== undefined && ( + + )} + +
+ + ); + } + // Assistant / system body: open prose, no bubble. Per-turn meta (model · + // duration · cost) lives in the footer's info tooltip; copy + the other + // actions live in the turn footer. + return ( + + + + ); +}); + +function MessageCopyButton(props: { text: string; label?: string; footerStyle?: boolean }) { + const copyFeedback = useClipboardCopyFeedback(1400, { redact: false }); + const copyPhase = copyFeedback.phaseFor('message'); + const copyPending = copyPhase === 'pending'; + const copied = copyPhase === 'copied'; + + async function copy() { + await copyFeedback.copy('message', props.text); + } + + // `footerStyle` renders this copy as the SAME quiet ghost action the + // assistant turn footer uses (`markerVariants('footer-action')` on a + // UiButton variant="quiet" size="nav" — the bare size, with icon + "复制"). + // The user-message copy and the assistant copy then read as one button by + // construction — same primitive, same class, same icon metrics — instead + // of a look-alike bespoke treatment. + const footer = props.footerStyle === true; + const iconSize = footer ? 12 : 14; + + const baseLabel = props.label ?? (footer ? '复制' : '复制消息'); + const actionLabel = copyPhase === 'pending' + ? '复制中' + : copyPhase === 'copied' + ? '已复制' + : copyPhase === 'failed' + ? '复制失败' + : baseLabel; + const icon = copied + ?