mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-25 17:04:42 +00:00
* style(web-shell): polish chat UI * fix(web-shell): address chat polish review feedback --------- Co-authored-by: ytahdn <ytahdn@gmail.com>
139 lines
3.7 KiB
TypeScript
139 lines
3.7 KiB
TypeScript
import { useCallback, useState, type ReactNode } from 'react';
|
|
import styles from './MessageTimestamp.module.css';
|
|
|
|
interface MessageTimestampProps {
|
|
/** Wall-clock epoch ms of the message; omitted for synthetic messages. */
|
|
timestamp?: number;
|
|
children: ReactNode;
|
|
/** When true, show the timestamp permanently at bottom-right instead of hover tooltip. */
|
|
chatMode?: boolean;
|
|
copyText?: string;
|
|
copyTitle?: string;
|
|
}
|
|
|
|
/**
|
|
* Wraps a rendered history message and reveals its wall-clock time as a
|
|
* CSS-only tooltip on hover. When the message carries no timestamp the
|
|
* children render unchanged, so no empty wrapper is introduced.
|
|
*/
|
|
export function MessageTimestamp({
|
|
timestamp,
|
|
children,
|
|
chatMode = false,
|
|
copyText,
|
|
copyTitle = 'Copy',
|
|
}: MessageTimestampProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const handleCopy = useCallback(() => {
|
|
if (!copyText) return;
|
|
void navigator.clipboard
|
|
?.writeText(copyText)
|
|
.then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
})
|
|
.catch(() => {});
|
|
}, [copyText]);
|
|
if (timestamp === undefined && !copyText) {
|
|
return <>{children}</>;
|
|
}
|
|
const copyButton = copyText ? (
|
|
<button
|
|
type="button"
|
|
className={styles.copyButton}
|
|
title={copyTitle}
|
|
aria-label={copyTitle}
|
|
onClick={handleCopy}
|
|
>
|
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
</button>
|
|
) : null;
|
|
if (timestamp === undefined) {
|
|
return (
|
|
<div className={chatMode ? styles.chatRow : styles.row}>
|
|
{children}
|
|
{copyButton}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={chatMode ? styles.chatRow : styles.row}>
|
|
{children}
|
|
{chatMode ? (
|
|
<span className={styles.chatActions}>
|
|
<span className={styles.chatTip} aria-hidden="true">
|
|
{formatTimestamp(timestamp)}
|
|
</span>
|
|
{copyButton}
|
|
</span>
|
|
) : (
|
|
<span className={styles.tip} aria-hidden="true">
|
|
{formatTimestamp(timestamp)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CopyIcon() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d="M5.2 4.4V3.2c0-.7.5-1.2 1.2-1.2h5.4c.7 0 1.2.5 1.2 1.2v5.4c0 .7-.5 1.2-1.2 1.2h-1.2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.3"
|
|
/>
|
|
<rect
|
|
x="3"
|
|
y="5.2"
|
|
width="7.8"
|
|
height="7.8"
|
|
rx="1.2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.3"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CheckIcon() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d="m3.5 8.3 3 3L12.8 5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.6"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Local-time clock, dropping the date only for same-day timestamps:
|
|
* - same day → `HH:mm:ss`
|
|
* - earlier → `yyyy-MM-dd HH:mm:ss`
|
|
*
|
|
* Fixed order and zero-padded (unlike toLocaleString) so stacked timestamps
|
|
* align. `now` is injectable so the branch logic is unit-testable without
|
|
* depending on the wall clock.
|
|
*/
|
|
export function formatTimestamp(ts: number, now: Date = new Date()): string {
|
|
const d = new Date(ts);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
const sameDay =
|
|
d.getFullYear() === now.getFullYear() &&
|
|
d.getMonth() === now.getMonth() &&
|
|
d.getDate() === now.getDate();
|
|
if (sameDay) {
|
|
return hms;
|
|
}
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
|
|
}
|