Skip to content

Commit 6ea4e8b

Browse files
committed
improvement(chat): unify expandable inline tool activity
1 parent a9fdff9 commit 6ea4e8b

17 files changed

Lines changed: 744 additions & 291 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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({
@@ -16,7 +17,7 @@ export function HeroToolCallItem({
1617
? SlackIcon
1718
: toolCallId === 'hero-read-table'
1819
? Table
19-
: undefined
20+
: getToolIcon(toolName)
2021
return (
2122
<ActivityStatus
2223
label={getToolStatusDisplayTitle(displayTitle, status, toolName)}

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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use client'
2+
3+
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
4+
import { cn } 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 [hasOverflow, setHasOverflow] = useState(false)
25+
26+
useEffect(() => {
27+
if (unbounded) {
28+
stickToBottomRef.current = true
29+
return
30+
}
31+
const el = ref.current
32+
if (!el) return
33+
// Upward user input detaches auto-stick; a downward scroll reaching the
34+
// bottom re-attaches it (a small upward flick can't re-stick itself).
35+
const handleWheel = (e: WheelEvent) => {
36+
if (e.deltaY < 0) stickToBottomRef.current = false
37+
}
38+
const handleScroll = () => {
39+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
40+
if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) {
41+
stickToBottomRef.current = true
42+
}
43+
prevScrollTopRef.current = el.scrollTop
44+
}
45+
el.addEventListener('wheel', handleWheel, { passive: true })
46+
el.addEventListener('scroll', handleScroll, { passive: true })
47+
return () => {
48+
el.removeEventListener('wheel', handleWheel)
49+
el.removeEventListener('scroll', handleScroll)
50+
}
51+
}, [unbounded])
52+
53+
useLayoutEffect(() => {
54+
const el = ref.current
55+
if (rafRef.current !== null) {
56+
window.cancelAnimationFrame(rafRef.current)
57+
rafRef.current = null
58+
}
59+
if (unbounded) {
60+
setHasOverflow(false)
61+
return
62+
}
63+
if (el) {
64+
const next = el.scrollHeight > el.clientHeight
65+
setHasOverflow((prev) => (prev === next ? prev : next))
66+
}
67+
if (!isStreaming) return
68+
const tick = () => {
69+
const node = ref.current
70+
if (!node || !stickToBottomRef.current) {
71+
rafRef.current = null
72+
return
73+
}
74+
const target = node.scrollHeight - node.clientHeight
75+
const gap = target - node.scrollTop
76+
if (gap < 1) {
77+
rafRef.current = null
78+
return
79+
}
80+
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
81+
rafRef.current = window.requestAnimationFrame(tick)
82+
}
83+
rafRef.current = window.requestAnimationFrame(tick)
84+
return () => {
85+
if (rafRef.current !== null) {
86+
window.cancelAnimationFrame(rafRef.current)
87+
rafRef.current = null
88+
}
89+
}
90+
})
91+
92+
return (
93+
<div className='relative'>
94+
<div
95+
ref={ref}
96+
className={cn(
97+
'pr-2',
98+
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
99+
hasOverflow && 'py-1'
100+
)}
101+
>
102+
{children}
103+
</div>
104+
{!unbounded && hasOverflow && (
105+
<>
106+
<div className='pointer-events-none absolute top-0 right-2 left-0 h-3 bg-linear-to-b from-[var(--bg)] to-transparent' />
107+
<div className='pointer-events-none absolute right-2 bottom-0 left-0 h-3 bg-linear-to-t from-[var(--bg)] to-transparent' />
108+
</>
109+
)}
110+
</div>
111+
)
112+
}

0 commit comments

Comments
 (0)