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 && (