From 007241d2a8e242c09ecf9a8a0434f071f9323a82 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 20 Sep 2026 22:08:38 +0800 Subject: [PATCH] feat(ui-chat): add text shimmer to running rows --- .../client/chat/GenericCommandCard.module.css | 32 ----------------- .../src/client/chat/GenericCommandCard.tsx | 8 +++-- .../ui-primitives/src/DisclosureRow.tsx | 6 +++- .../ui-primitives/src/TextShimmer.module.css | 27 ++++++++++++++ .../client/ui-primitives/src/TextShimmer.tsx | 23 ++++++++++++ packages/client/ui-primitives/src/index.ts | 2 ++ .../client/tool/components/ToolRow.module.css | 36 ++----------------- .../src/client/tool/components/ToolRow.tsx | 10 +++--- .../tool/toolviews/bash-sample.module.css | 25 ------------- .../src/client/tool/toolviews/bash-sample.tsx | 7 ++-- 10 files changed, 76 insertions(+), 100 deletions(-) create mode 100644 packages/client/ui-primitives/src/TextShimmer.module.css create mode 100644 packages/client/ui-primitives/src/TextShimmer.tsx diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index eddba8e53d..a4bb6ef0cc 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -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; - } -} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 2864836301..0374637e17 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -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={} title={title} + running={running} open={open} expandable={body !== null} expandOnRowClick @@ -50,7 +52,9 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa collapsedContent={( <> - {summary} + + {summary} + )} > diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index c2a59ce0c0..a4fa7a2124 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -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} )} - {title} + {title} {(keepContentWhenOpen || !open) && collapsedContent} {open && children} diff --git a/packages/client/ui-primitives/src/TextShimmer.module.css b/packages/client/ui-primitives/src/TextShimmer.module.css new file mode 100644 index 0000000000..bdc2de9a44 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.module.css @@ -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; + } +} diff --git a/packages/client/ui-primitives/src/TextShimmer.tsx b/packages/client/ui-primitives/src/TextShimmer.tsx new file mode 100644 index 0000000000..8249626435 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.tsx @@ -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 {children} +}) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 25ba54ea24..551ced88ea 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -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' diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 73efca2964..3bf04ae4ac 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -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; diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 3799370527..874beb9252 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -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} + {summaryText} ) : ( - {summaryText} + {summaryText} )} {suffix !== null && ( - {suffix} + {suffix} )} )} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index e45e761ab3..be9b547ce6 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -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; diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 7c52b380e1..37b1d355d0 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -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 }: > {leading} {status !== null && {status}} - {t(model.titleKey)} + {t(model.titleKey)} - {settlementLine ?? normalSummary} + {settlementLine ?? normalSummary} {open && (