Skip to content

Commit 1674e83

Browse files
authored
improvement(chat): unify expandable inline tool activity (#7750)
* improvement(chat): unify expandable inline tool activity * fix(chat): share activity disclosure and status lifecycle * improvement(chat): keep activity summaries concise
1 parent 23cc971 commit 1674e83

20 files changed

Lines changed: 944 additions & 346 deletions

File tree

apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { SlackIcon } from '@/components/icons'
33
import { ActivityStatus } from '@/components/ui/activity-status'
44
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
55
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
6+
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
67

78
/** Demo fixtures have known brands, so the landing page never loads the block registry. */
89
export function HeroToolCallItem({
910
toolCallId,
11+
renderStatus,
1012
toolName,
1113
displayTitle,
1214
status,
@@ -16,12 +18,13 @@ export function HeroToolCallItem({
1618
? SlackIcon
1719
: toolCallId === 'hero-read-table'
1820
? Table
19-
: undefined
20-
return (
21+
: getToolIcon(toolName)
22+
const activity = (
2123
<ActivityStatus
2224
label={getToolStatusDisplayTitle(displayTitle, status, toolName)}
2325
isActive={status === 'executing'}
2426
icon={Icon && <Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
2527
/>
2628
)
29+
return renderStatus ? renderStatus(activity) : activity
2730
}

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useMemo } from 'react'
44
import { Chip, ChipLink } from '@sim/emcn'
55
import { useQueryStates } from 'nuqs'
6-
import { ShimmerText } from '@/components/ui/shimmer-text'
6+
import { ActivityStatus } from '@/components/ui/activity-status'
77
import type {
88
WorkspaceKnowledgeSearchResult,
99
WorkspaceSearchFilters,
@@ -190,9 +190,9 @@ export function KnowledgeSearchResults({
190190
}
191191
if (isPending || (isFetching && !results)) {
192192
return (
193-
<p role='status' className='px-2 py-2 text-[var(--text-muted)] text-caption'>
194-
<ShimmerText className='[--shimmer-rest:var(--text-muted)]'>Searching…</ShimmerText>
195-
</p>
193+
<div className='px-2 py-2'>
194+
<ActivityStatus label='Searching…' isActive />
195+
</div>
196196
)
197197
}
198198

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
3+
import { type ReactNode, useId } from 'react'
4+
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
5+
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'
6+
7+
interface ActivityDisclosureProps {
8+
header: ReactNode
9+
children: ReactNode
10+
expanded: boolean
11+
onToggle: () => void
12+
isStreaming: boolean
13+
unbounded?: boolean
14+
}
15+
16+
/** Shared disclosure chrome; callers own expansion and blocking-interaction decisions. */
17+
export function ActivityDisclosure({
18+
header,
19+
children,
20+
expanded,
21+
onToggle,
22+
isStreaming,
23+
unbounded = false,
24+
}: ActivityDisclosureProps) {
25+
const contentId = useId()
26+
const headerId = useId()
27+
28+
return (
29+
<div className='flex min-w-0 flex-col gap-1.5'>
30+
<button
31+
type='button'
32+
aria-expanded={expanded}
33+
aria-controls={contentId}
34+
aria-labelledby={headerId}
35+
onClick={onToggle}
36+
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
37+
>
38+
<span id={headerId} className='flex min-w-0'>
39+
{header}
40+
</span>
41+
<ChevronDown
42+
aria-hidden
43+
className={cn(
44+
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
45+
!expanded &&
46+
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
47+
)}
48+
/>
49+
</button>
50+
<Expandable expanded={expanded}>
51+
<ExpandableContent id={contentId}>
52+
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
53+
{children}
54+
</ActivityViewport>
55+
</ExpandableContent>
56+
</Expandable>
57+
</div>
58+
)
59+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use client'
2+
3+
import { type ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
4+
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
5+
6+
interface ActivityViewportProps {
7+
children: ReactNode
8+
isStreaming: boolean
9+
/** A nested blocking interaction must not be clipped by this ancestor's log viewport. */
10+
unbounded?: boolean
11+
}
12+
13+
const BOTTOM_STICK_THRESHOLD_PX = 8
14+
15+
export function ActivityViewport({
16+
children,
17+
isStreaming,
18+
unbounded = false,
19+
}: ActivityViewportProps) {
20+
const ref = useRef<HTMLDivElement>(null)
21+
const rafRef = useRef<number | null>(null)
22+
const stickToBottomRef = useRef(true)
23+
const prevScrollTopRef = useRef(0)
24+
const edges = useScrollEdges(ref, { enabled: !unbounded })
25+
26+
useEffect(() => {
27+
if (unbounded) {
28+
stickToBottomRef.current = true
29+
return
30+
}
31+
const el = ref.current
32+
if (!el) return
33+
/** Upward input detaches auto-stick; reaching the bottom while scrolling down resumes it. */
34+
const handleWheel = (e: WheelEvent) => {
35+
if (e.deltaY < 0) stickToBottomRef.current = false
36+
}
37+
const handleScroll = () => {
38+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
39+
if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) {
40+
stickToBottomRef.current = true
41+
}
42+
prevScrollTopRef.current = el.scrollTop
43+
}
44+
el.addEventListener('wheel', handleWheel, { passive: true })
45+
el.addEventListener('scroll', handleScroll, { passive: true })
46+
return () => {
47+
el.removeEventListener('wheel', handleWheel)
48+
el.removeEventListener('scroll', handleScroll)
49+
}
50+
}, [unbounded])
51+
52+
useLayoutEffect(() => {
53+
if (rafRef.current !== null) {
54+
window.cancelAnimationFrame(rafRef.current)
55+
rafRef.current = null
56+
}
57+
if (unbounded || !isStreaming) return
58+
const tick = () => {
59+
const node = ref.current
60+
if (!node || !stickToBottomRef.current) {
61+
rafRef.current = null
62+
return
63+
}
64+
const target = node.scrollHeight - node.clientHeight
65+
const gap = target - node.scrollTop
66+
if (gap < 1) {
67+
rafRef.current = null
68+
return
69+
}
70+
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
71+
rafRef.current = window.requestAnimationFrame(tick)
72+
}
73+
rafRef.current = window.requestAnimationFrame(tick)
74+
return () => {
75+
if (rafRef.current !== null) {
76+
window.cancelAnimationFrame(rafRef.current)
77+
rafRef.current = null
78+
}
79+
}
80+
})
81+
82+
return (
83+
<div
84+
ref={ref}
85+
className={cn(
86+
'pr-2',
87+
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
88+
scrollFadeClass,
89+
(edges.top || edges.bottom) && 'py-1'
90+
)}
91+
{...scrollFadeAttributes(edges)}
92+
>
93+
{children}
94+
</div>
95+
)
96+
}

0 commit comments

Comments
 (0)