fix(web): address UI polish review feedback

This commit is contained in:
imccyu
2026-08-04 16:25:09 +08:00
parent b9f4e46462
commit e9d76dae64
23 changed files with 174 additions and 116 deletions
@@ -1,12 +1,12 @@
// Shared IconActions chrome for user, steering, and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useId, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip,
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { formatMessageClock } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -42,11 +42,27 @@ export function MessageIconActions({
// Same success chrome as CodeBlock: a short check swap after the write,
// gated so re-clicks during the window neither re-copy nor stack timers.
const [copied, setCopied] = useState(false)
const copyPending = useRef(false)
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyEpoch = useRef(0)
useEffect(() => () => {
copyEpoch.current += 1
copyPending.current = false
if (copyTimer.current !== null) clearTimeout(copyTimer.current)
}, [])
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(text).then(() => {
if (copied || copyPending.current) return
const epoch = copyEpoch.current
copyPending.current = true
void writeClipboard(text).then((ok) => {
if (epoch !== copyEpoch.current) return
copyPending.current = false
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
copyTimer.current = window.setTimeout(() => {
copyTimer.current = null
setCopied(false)
}, 1000)
})
}, [copied, text])
const clockEl = time === undefined ? null : (
@@ -6,45 +6,6 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
/** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
*/
export async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* oxlint-enable typescript/no-deprecated */
el.remove()
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
@@ -11,17 +11,18 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
column (--dsh-composer-card-max-width = chat content + 16px is a cap, not a
fixed size — layout rule: the box shrinks with the center column keeping
its clearance). Hero variant = the same card centered in the empty state;
the transition between the two is a position move of one component. */
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size — layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the
empty state; the transition between the two is a position move of one
component. */
.root {
display: flex;
flex-direction: column;
align-items: center;
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
the sides narrowed with the chat+16 width axis); the bottom gradient mask
the sides narrow with the shared width axis); the bottom gradient mask
is owned by the chat scroller. No top pad: the composer stack's gap owns
the space above; error/status strips still carry their own margin. */
padding: 0 var(--dsh-composer-side-clearance) 8px;