feat(ui-chat): add text shimmer to running rows

This commit is contained in:
imccyu committed 2026-09-20 22:45:29 +08:00
1 parent e24805a536
commit 007241d2a8
10 files changed
+76 -100

No files matched your search

@@ -3,32 +3,6 @@
flex-direction: column;
}
.row {
position: relative;
overflow: hidden;
}
.root[data-state='running'] .row::after {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-command-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-command-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
@@ -85,9 +59,3 @@
font: var(--dsw-font-markdown-code-block-small);
white-space: pre-wrap;
}
@media (prefers-reduced-motion: reduce) {
.root[data-state='running'] .row::after {
animation: none;
}
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow, IconApiOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
import a11yCss from './accessibility.module.css'
import css from './GenericCommandCard.module.css'
@@ -29,6 +29,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
// command name.
const title = node.name ?? t('command.title')
const state = stateOf(node.outcome)
const running = state === 'running'
const body = text !== undefined && text.includes('\n') ? text : null
const open = expanded && body !== null
return (
@@ -42,6 +43,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
chevronClassName={css.chevron}
icon={<IconApiOutlineRegular size={14} />}
title={title}
running={running}
open={open}
expandable={body !== null}
expandOnRowClick
@@ -50,7 +52,9 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>{summary}</span>
<span className={css.summary} data-error={state === 'error' || undefined}>
<TextShimmer active={running}>{summary}</TextShimmer>
</span>
</>
)}
>
@@ -1,6 +1,7 @@
import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx'
import { TextShimmer } from './TextShimmer.tsx'
import css from './DisclosureRow.module.css'
/** Shared 24px disclosure chrome for compact flow rows. */
@@ -10,6 +11,8 @@ export interface DisclosureRowProps {
open: boolean
expandable: boolean
onToggle: () => void
/** Animate the title while its owning operation is running. */
running?: boolean | undefined
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
@@ -37,6 +40,7 @@ export const DisclosureRow = memo(function DisclosureRow({
open,
expandable,
onToggle,
running = false,
expandOnRowClick = false,
previewChevron = expandable,
keepContentWhenOpen = false,
@@ -96,7 +100,7 @@ export const DisclosureRow = memo(function DisclosureRow({
{leading}
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
<TextShimmer className={clsx(css.title, titleClassName)} active={running}>{title}</TextShimmer>
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
@@ -0,0 +1,27 @@
.root[data-text-shimmer] {
background-image: linear-gradient(
90deg,
currentColor calc(50% - var(--dsh-text-shimmer-spread)),
color-mix(in oklab, currentColor 50%, transparent),
currentColor calc(50% + var(--dsh-text-shimmer-spread))
);
background-position: 100% center;
background-repeat: no-repeat;
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: dsh-text-shimmer 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite;
}
@keyframes dsh-text-shimmer {
66.6667%, 100% { background-position: 0% center; }
}
@media (prefers-reduced-motion: reduce) {
.root[data-text-shimmer] {
background-image: none;
-webkit-text-fill-color: currentColor;
animation: none;
}
}
@@ -0,0 +1,23 @@
/** Text-only activity animation with a stable span across lifecycle changes. */
import { memo, useMemo, type CSSProperties } from 'react'
import clsx from 'clsx'
import css from './TextShimmer.module.css'
/** Text and activity supplied by the owning row. */
export interface TextShimmerProps {
children: string
active: boolean
className?: string | undefined
}
/**
* Render text with an optional moving highlight; inactive text keeps the same node.
* @param props - localized text, running state, and owner styling.
* @returns the retained text span.
*/
export const TextShimmer = memo(function TextShimmer({ children, active, className }: TextShimmerProps) {
const style = useMemo(() => ({
'--dsh-text-shimmer-spread': `${children.length * 8}px`,
} as CSSProperties), [children.length])
return <span className={clsx(css.root, className)} style={style} data-text-shimmer={active || undefined}>{children}</span>
})
@@ -6,6 +6,8 @@ export { StateDot } from './StateDot.tsx'
export type { StateDotState } from './StateDot.tsx'
export { DisclosureRow } from './DisclosureRow.tsx'
export type { DisclosureRowProps } from './DisclosureRow.tsx'
export { TextShimmer } from './TextShimmer.tsx'
export type { TextShimmerProps } from './TextShimmer.tsx'
export { Button } from './Button.tsx'
export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
@@ -11,37 +11,6 @@
flex-direction: column;
}
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
}
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
theme background at 60% — glides over the row content from off-left to
off-right, washing glyphs and icon toward the background as it passes.
ease-out with a 10% end hold gives each pass a beat before the next. */
.root[data-state='running'] .row::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-tool-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
@@ -371,8 +340,9 @@
border: 0.5px solid var(--dsw-alias-border-l1);
}
/* Visually hidden run-state label for assistive technology: the running sweep
and settlement summary tones are color-only. */
/* Visually hidden run-state label for assistive technology: the business icon
and animated text treatment do not name the state, so this text carries the
running/failed/interrupted state to a screen reader. */
.visuallyHidden {
position: absolute;
width: 1px;
@@ -2,7 +2,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock,
TerminalBlock, WebBlock,
TerminalBlock, TextShimmer, WebBlock,
diffTotals,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -156,6 +156,7 @@ export function ToolRow({
[card, inputRaw, open, variant],
)
const status = stateStatus(state, t)
const running = state === 'running'
const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary)
// A failure keeps its first result line when available and otherwise turns
// the ordinary summary red. An interruption turns the tool-owned summary
@@ -202,6 +203,7 @@ export function ToolRow({
chevronClassName={css.chevron}
icon={icon}
title={title}
running={running}
open={open}
expandable={expandable}
expandOnRowClick
@@ -219,7 +221,7 @@ export function ToolRow({
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
{summaryText}
<TextShimmer active={running}>{summaryText}</TextShimmer>
</button>
) : (
<span
@@ -229,11 +231,11 @@ export function ToolRow({
state === 'stopped' && css.stoppedSummary,
)}
>
{summaryText}
<TextShimmer active={running}>{summaryText}</TextShimmer>
</span>
)}
{suffix !== null && (
<span className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)}>{suffix}</span>
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
)}
</>
)}
@@ -85,37 +85,12 @@
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: calc(24px + var(--dsh-content-font-delta, 0px));
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-bash-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import clsx from 'clsx'
import {
IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular,
TerminalBlock,
TerminalBlock, TextShimmer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '../../contract/slots.ts'
@@ -63,6 +63,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
const settlementLine = state === 'error'
? model.errorSummary ?? normalSummary
: state === 'stopped' ? t('bash.stopped') : null
const running = state === 'running'
const toggleExpand = () => {
setExpanded(v => !v)
}
@@ -98,14 +99,14 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{t(model.titleKey)}</span>
<TextShimmer className={css.title} active={running}>{t(model.titleKey)}</TextShimmer>
<span className={css.sep} aria-hidden />
<span className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}>
{settlementLine ?? normalSummary}
<TextShimmer active={running}>{settlementLine ?? normalSummary}</TextShimmer>
</span>
</div>
{open && (