feat(ui-chat): add text shimmer to running rows
This commit is contained in:
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 && (
|
||||
|
||||
Reference in new issue
Block a user