fix(ui-conversation): share MessageIconActions to clear jscpd clone

User and assistant chrome both rendered copy/branch buttons; one shared
row owns the chrome and keeps clock placement / edit as props.
This commit is contained in:
07akioni
2026-07-29 15:11:06 +08:00
parent 59c0f92cb6
commit c8ea9a5204
6 changed files with 142 additions and 147 deletions
@@ -28,55 +28,17 @@
line-height: 18px;
}
/* Finalized footer: copy / branch / clock (figma 43:32997). */
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
margin-top: 16px;
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
margin-left: -6px;
}
/* Clock after the icon buttons; pl 12 separates it from branch. */
.time {
padding-left: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Hover-capable pointers: hide until the root is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.root:hover .actions,
.root:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
@@ -6,14 +6,12 @@
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
import { memo, useCallback } from 'react'
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconBranchOutline16, IconCopyOutline16, IconThinkOutline14,
JsonBlock, MarkdownText, Tooltip,
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -55,29 +53,6 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
)
}
/** Finalized assistant IconActions (figma 43:32997): copy live; branch stub; clock. */
function AssistantActions({ text, time }: { text: string; time: number }) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<span className={css.time}>{formatMessageClock(time, day)}</span>
</div>
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
}: AssistantMarkdownProps) {
@@ -105,7 +80,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
})}
{interrupted && <span className={css.stopped}></span>}
</div>
{showActions && <AssistantActions text={copyText(blocks)} time={time} />}
{showActions && (
<MessageIconActions
text={copyText(blocks)}
time={time}
clock="end"
className={css.actions}
/>
)}
</div>
)
})
@@ -0,0 +1,53 @@
/* Shared message IconActions row (user + assistant). Parent modules own
hover-reveal selectors and layout offsets via the composed className. */
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
.timeStart {
padding-right: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.timeEnd {
padding-left: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
@@ -0,0 +1,65 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
export interface MessageIconActionsProps {
/** Plain text the copy action writes. */
text: string
/** Unix epoch ms for the clock label. */
time: number
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Parent layout / hover-reveal class composed onto the actions row. */
className?: string | undefined
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
const clockEl = (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, day)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
)}
{clock === 'end' ? clockEl : null}
</div>
)
}
@@ -20,55 +20,14 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Clock before the icon buttons (figma 388:20051); pr 12 separates it from copy. */
.time {
padding-right: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
@@ -4,17 +4,13 @@
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo, useCallback } from 'react'
import { memo } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -64,34 +60,6 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** User-bubble IconActions (figma 388:20051): clock + copy live; branch/edit stubs. */
function UserActions({ text, time }: { text: string; time: number }) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<span className={css.time}>{formatMessageClock(time, day)}</span>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user': {
@@ -102,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} time={node.time} />
<MessageIconActions
text={text}
time={node.time}
clock="start"
edit
className={css.actions}
/>
</div>
)
}