Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { SlackIcon } from '@/components/icons'
import { ActivityStatus } from '@/components/ui/activity-status'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'

/** Demo fixtures have known brands, so the landing page never loads the block registry. */
export function HeroToolCallItem({
toolCallId,
renderStatus,
toolName,
displayTitle,
status,
Expand All @@ -16,12 +18,13 @@ export function HeroToolCallItem({
? SlackIcon
: toolCallId === 'hero-read-table'
? Table
: undefined
return (
: getToolIcon(toolName)
const activity = (
<ActivityStatus
label={getToolStatusDisplayTitle(displayTitle, status, toolName)}
isActive={status === 'executing'}
icon={Icon && <Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
/>
)
return renderStatus ? renderStatus(activity) : activity
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useMemo } from 'react'
import { Chip, ChipLink } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import { ShimmerText } from '@/components/ui/shimmer-text'
import { ActivityStatus } from '@/components/ui/activity-status'
import type {
WorkspaceKnowledgeSearchResult,
WorkspaceSearchFilters,
Expand Down Expand Up @@ -190,9 +190,9 @@ export function KnowledgeSearchResults({
}
if (isPending || (isFetching && !results)) {
return (
<p role='status' className='px-2 py-2 text-[var(--text-muted)] text-caption'>
<ShimmerText className='[--shimmer-rest:var(--text-muted)]'>Searching…</ShimmerText>
</p>
<div className='px-2 py-2'>
<ActivityStatus label='Searching…' isActive />
</div>
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import { type ReactNode, useId } from 'react'
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'

interface ActivityDisclosureProps {
header: ReactNode
children: ReactNode
expanded: boolean
onToggle: () => void
isStreaming: boolean
unbounded?: boolean
}

/** Shared disclosure chrome; callers own expansion and blocking-interaction decisions. */
export function ActivityDisclosure({
header,
children,
expanded,
onToggle,
isStreaming,
unbounded = false,
}: ActivityDisclosureProps) {
const contentId = useId()
const headerId = useId()

return (
<div className='flex min-w-0 flex-col gap-1.5'>
<button
type='button'
aria-expanded={expanded}
aria-controls={contentId}
aria-labelledby={headerId}
onClick={onToggle}
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
>
<span id={headerId} className='flex min-w-0'>
{header}
</span>
<ChevronDown
aria-hidden
className={cn(
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
!expanded &&
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
)}
/>
</button>
<Expandable expanded={expanded}>
<ExpandableContent id={contentId}>
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
{children}
</ActivityViewport>
</ExpandableContent>
</Expandable>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client'

import { type ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'

interface ActivityViewportProps {
children: ReactNode
isStreaming: boolean
/** A nested blocking interaction must not be clipped by this ancestor's log viewport. */
unbounded?: boolean
}

const BOTTOM_STICK_THRESHOLD_PX = 8

export function ActivityViewport({
children,
isStreaming,
unbounded = false,
}: ActivityViewportProps) {
const ref = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const stickToBottomRef = useRef(true)
const prevScrollTopRef = useRef(0)
const edges = useScrollEdges(ref, { enabled: !unbounded })

useEffect(() => {
if (unbounded) {
stickToBottomRef.current = true
return
}
const el = ref.current
if (!el) return
/** Upward input detaches auto-stick; reaching the bottom while scrolling down resumes it. */
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) stickToBottomRef.current = false
}
const handleScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
if (distance < BOTTOM_STICK_THRESHOLD_PX && el.scrollTop > prevScrollTopRef.current) {
stickToBottomRef.current = true
}
prevScrollTopRef.current = el.scrollTop
}
el.addEventListener('wheel', handleWheel, { passive: true })
el.addEventListener('scroll', handleScroll, { passive: true })
return () => {
el.removeEventListener('wheel', handleWheel)
el.removeEventListener('scroll', handleScroll)
}
}, [unbounded])

useLayoutEffect(() => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (unbounded || !isStreaming) return
const tick = () => {
const node = ref.current
if (!node || !stickToBottomRef.current) {
rafRef.current = null
return
}
const target = node.scrollHeight - node.clientHeight
const gap = target - node.scrollTop
if (gap < 1) {
rafRef.current = null
return
}
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
rafRef.current = window.requestAnimationFrame(tick)
}
rafRef.current = window.requestAnimationFrame(tick)
return () => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
})

return (
<div
ref={ref}
className={cn(
'pr-2',
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
scrollFadeClass,
(edges.top || edges.bottom) && 'py-1'
)}
{...scrollFadeAttributes(edges)}
>
{children}
</div>
)
}
Loading
Loading