Merge branch 'feat/web-app-2-0' into develop
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
.collapse
|
||||
display: grid
|
||||
min-width: 0
|
||||
transition: grid-template-rows var(--transition-duration-collapse) var(--transition-timing-main), opacity var(--transition-duration-collapse) var(--transition-timing-main), transform var(--transition-duration-collapse) var(--transition-timing-main)
|
||||
|
||||
.collapse-open
|
||||
grid-template-rows: 1fr
|
||||
opacity: 1
|
||||
transform: translate3d(0, 0, 0)
|
||||
|
||||
.collapse-closed
|
||||
grid-template-rows: 0fr
|
||||
opacity: 0
|
||||
transform: translate3d(0, -4px, 0)
|
||||
pointer-events: none
|
||||
|
||||
.collapse-content
|
||||
min-width: 0
|
||||
min-height: 0
|
||||
overflow: hidden
|
||||
|
||||
.collapse-body
|
||||
min-width: 0
|
||||
|
||||
@starting-style
|
||||
.collapse-open
|
||||
grid-template-rows: 0fr
|
||||
opacity: 0
|
||||
transform: translate3d(0, -4px, 0)
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.collapse
|
||||
transition: none
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type TransitionEvent
|
||||
} from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import './collapse.sass'
|
||||
|
||||
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
interface CollapseProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
id?: string
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
/** Reveals content as one sliding block and unmounts it after closing. */
|
||||
export function Collapse({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
isOpen
|
||||
}: CollapseProps) {
|
||||
const [shouldRender, setShouldRender] = useState(isOpen)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldRender(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches) {
|
||||
setShouldRender(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
function handleTransitionEnd(event: TransitionEvent<HTMLDivElement>): void {
|
||||
if (
|
||||
event.currentTarget !== event.target ||
|
||||
event.propertyName !== 'grid-template-rows' ||
|
||||
isOpen
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Removing closed children resets any disclosures nested inside them.
|
||||
setShouldRender(false)
|
||||
}
|
||||
|
||||
if (!shouldRender) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={clsx('collapse', className, {
|
||||
'collapse-open': isOpen,
|
||||
'collapse-closed': !isOpen
|
||||
})}
|
||||
aria-hidden={!isOpen}
|
||||
inert={!isOpen}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div className="collapse-content">
|
||||
<div className="collapse-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Collapse } from './collapse'
|
||||
@@ -1,6 +0,0 @@
|
||||
.dotted-icon
|
||||
display: block
|
||||
flex: 0 0 auto
|
||||
width: 20px
|
||||
height: 20px
|
||||
color: var(--color-text-secondary)
|
||||
@@ -1,252 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import './dotted-icon.sass'
|
||||
|
||||
const DOT_COLUMN_COUNT = 15
|
||||
const DOT_OFFSET_RATIO = .5
|
||||
const MASK_ALPHA_THRESHOLD = 128
|
||||
const MASK_LIGHTNESS_THRESHOLD = 160
|
||||
const ANIMATION_DURATION_MS = 2_800
|
||||
|
||||
interface DottedIconPoint {
|
||||
phase: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface DottedIconProps {
|
||||
active?: boolean
|
||||
ariaLabel: string
|
||||
className?: string
|
||||
maskMode?: 'alpha' | 'light'
|
||||
source: string
|
||||
sourceHeight: number
|
||||
sourceWidth: number
|
||||
}
|
||||
|
||||
function createDots(
|
||||
image: HTMLImageElement,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
maskMode: DottedIconProps['maskMode']
|
||||
): DottedIconPoint[] {
|
||||
const maskCanvas = document.createElement('canvas')
|
||||
maskCanvas.width = sourceWidth
|
||||
maskCanvas.height = sourceHeight
|
||||
const maskContext = maskCanvas.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
if (maskContext === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
maskContext.drawImage(image, 0, 0, sourceWidth, sourceHeight)
|
||||
const pixels = maskContext.getImageData(
|
||||
0,
|
||||
0,
|
||||
sourceWidth,
|
||||
sourceHeight
|
||||
).data
|
||||
const spacing = sourceWidth / DOT_COLUMN_COUNT
|
||||
const offset = spacing * DOT_OFFSET_RATIO
|
||||
const dots: DottedIconPoint[] = []
|
||||
|
||||
// Staggered samples preserve each silhouette while giving the wave enough
|
||||
// individual points to feel fluid at the small UI size.
|
||||
for (let y = offset; y < sourceHeight; y += spacing) {
|
||||
const rowOffset = Math.floor(y / spacing) % 2 === 0 ? 0 : spacing / 2
|
||||
|
||||
for (let x = offset + rowOffset; x < sourceWidth; x += spacing) {
|
||||
const pixelIndex = (
|
||||
(Math.floor(y) * sourceWidth) + Math.floor(x)
|
||||
) * 4
|
||||
const alpha = pixels[pixelIndex + 3] ?? 0
|
||||
const lightness = Math.max(
|
||||
pixels[pixelIndex] ?? 0,
|
||||
pixels[pixelIndex + 1] ?? 0,
|
||||
pixels[pixelIndex + 2] ?? 0
|
||||
)
|
||||
const matchesMask = maskMode === 'light'
|
||||
? alpha >= MASK_ALPHA_THRESHOLD &&
|
||||
lightness >= MASK_LIGHTNESS_THRESHOLD
|
||||
: alpha >= MASK_ALPHA_THRESHOLD
|
||||
|
||||
if (!matchesMask) {
|
||||
continue
|
||||
}
|
||||
|
||||
dots.push({
|
||||
x: x / sourceWidth,
|
||||
y: y / sourceHeight,
|
||||
phase: (x * .31) + (y * .17)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return dots
|
||||
}
|
||||
|
||||
/** Renders an SVG silhouette as the animated dotted activity indicator. */
|
||||
export function DottedIcon({
|
||||
active = true,
|
||||
ariaLabel,
|
||||
className,
|
||||
maskMode = 'alpha',
|
||||
source,
|
||||
sourceHeight,
|
||||
sourceWidth
|
||||
}: DottedIconProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
|
||||
if (canvas === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (context === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvasElement = canvas
|
||||
const drawingContext = context
|
||||
const image = new Image()
|
||||
const reducedMotionQuery = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)'
|
||||
)
|
||||
let animationFrame = 0
|
||||
let isVisible = true
|
||||
let dots: DottedIconPoint[] = []
|
||||
let dotColor = window.getComputedStyle(canvasElement).color
|
||||
|
||||
function resizeCanvas(): void {
|
||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const { width, height } = canvasElement.getBoundingClientRect()
|
||||
canvasElement.width = Math.round(width * pixelRatio)
|
||||
canvasElement.height = Math.round(height * pixelRatio)
|
||||
drawingContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)
|
||||
}
|
||||
|
||||
function draw(timestamp: number): void {
|
||||
const { width, height } = canvasElement.getBoundingClientRect()
|
||||
const animationProgress = active && !reducedMotionQuery.matches
|
||||
? (timestamp % ANIMATION_DURATION_MS) / ANIMATION_DURATION_MS
|
||||
: .42
|
||||
const wavePosition = (animationProgress * 1.4) - .2
|
||||
|
||||
drawingContext.clearRect(0, 0, width, height)
|
||||
drawingContext.fillStyle = dotColor
|
||||
|
||||
for (const dot of dots) {
|
||||
const distanceFromWave = dot.x - wavePosition
|
||||
const waveStrength = Math.exp(
|
||||
-(distanceFromWave * distanceFromWave) * 28
|
||||
)
|
||||
const breathing = .5 +
|
||||
(.5 * Math.sin((animationProgress * Math.PI * 2) + dot.phase))
|
||||
const radius = .52 + (waveStrength * .4) + (breathing * .1)
|
||||
const verticalDrift = active && !reducedMotionQuery.matches
|
||||
? Math.sin((animationProgress * Math.PI * 2) + dot.phase) * .35
|
||||
: 0
|
||||
|
||||
drawingContext.globalAlpha =
|
||||
.42 + (waveStrength * .5) + (breathing * .08)
|
||||
drawingContext.beginPath()
|
||||
drawingContext.arc(
|
||||
dot.x * width,
|
||||
(dot.y * height) + verticalDrift,
|
||||
radius,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
drawingContext.fill()
|
||||
}
|
||||
|
||||
drawingContext.globalAlpha = 1
|
||||
}
|
||||
|
||||
function stopAnimation(): void {
|
||||
window.cancelAnimationFrame(animationFrame)
|
||||
animationFrame = 0
|
||||
}
|
||||
|
||||
function animate(timestamp: number): void {
|
||||
draw(timestamp)
|
||||
animationFrame = window.requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function updateAnimation(): void {
|
||||
stopAnimation()
|
||||
|
||||
if (
|
||||
dots.length === 0 ||
|
||||
!active ||
|
||||
reducedMotionQuery.matches ||
|
||||
!isVisible ||
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
draw(0)
|
||||
return
|
||||
}
|
||||
|
||||
animationFrame = window.requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function handleThemeChange(): void {
|
||||
dotColor = window.getComputedStyle(canvasElement).color
|
||||
draw(performance.now())
|
||||
}
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(([entry]) => {
|
||||
isVisible = entry?.isIntersecting ?? true
|
||||
updateAnimation()
|
||||
})
|
||||
const themeObserver = new MutationObserver(handleThemeChange)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resizeCanvas()
|
||||
draw(performance.now())
|
||||
})
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
dots = createDots(
|
||||
image,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
maskMode
|
||||
)
|
||||
resizeCanvas()
|
||||
updateAnimation()
|
||||
}, { once: true })
|
||||
image.src = source
|
||||
|
||||
intersectionObserver.observe(canvasElement)
|
||||
resizeObserver.observe(canvasElement)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
})
|
||||
reducedMotionQuery.addEventListener('change', updateAnimation)
|
||||
document.addEventListener('visibilitychange', updateAnimation)
|
||||
|
||||
return () => {
|
||||
stopAnimation()
|
||||
intersectionObserver.disconnect()
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
reducedMotionQuery.removeEventListener('change', updateAnimation)
|
||||
document.removeEventListener('visibilitychange', updateAnimation)
|
||||
}
|
||||
}, [active, maskMode, source, sourceHeight, sourceWidth])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={clsx('dotted-icon', className)}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { DottedIcon } from './dotted-icon'
|
||||
|
||||
@@ -34,17 +34,25 @@
|
||||
|
||||
.feed-mask
|
||||
position: fixed
|
||||
bottom: 0
|
||||
left: 0
|
||||
z-index: 2
|
||||
width: 100%
|
||||
height: var(--feed-bottom-clearance)
|
||||
background-color: var(--color-background)
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 46%, #000)
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 46%, #000)
|
||||
pointer-events: none
|
||||
transition: background-color var(--transition-duration-main) var(--transition-timing-main)
|
||||
|
||||
.feed-mask-top
|
||||
top: 0
|
||||
height: var(--feed-top-clearance)
|
||||
mask-image: linear-gradient(to bottom, #000, #000 0%, transparent)
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000, #000 0%, transparent)
|
||||
|
||||
.feed-mask-bottom
|
||||
bottom: 0
|
||||
height: var(--feed-bottom-clearance)
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 46%, #000)
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 46%, #000)
|
||||
|
||||
@media (max-width: 680px)
|
||||
.feed
|
||||
--feed-bottom-clearance: 132px
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FeedAnimationProvider } from '../streaming-text'
|
||||
import './feed.sass'
|
||||
|
||||
const FEED_TURN_ESTIMATED_HEIGHT = 608
|
||||
const FEED_OVERSCAN_COUNT = 4
|
||||
const FEED_OVERSCAN_COUNT = 1
|
||||
const OWNER_MESSAGE_REVEAL_SCROLL_PROGRESS = .33
|
||||
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
const SCROLL_POSITION_TOLERANCE_PX = 1
|
||||
@@ -50,10 +50,15 @@ function supportsScrollEnd(element: HTMLElement): boolean {
|
||||
return 'onscrollend' in element
|
||||
}
|
||||
|
||||
function shouldAdjustFeedScrollPosition(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function Feed({ entries }: FeedProps) {
|
||||
const feedRef = useRef<HTMLElement>(null)
|
||||
const maskRef = useRef<HTMLDivElement>(null)
|
||||
const bottomMaskRef = useRef<HTMLDivElement>(null)
|
||||
const previousTurnCountRef = useRef<number | null>(null)
|
||||
const topMaskRef = useRef<HTMLDivElement>(null)
|
||||
const turns = useMemo(() => groupEntriesByTurn(entries), [entries])
|
||||
const turnCount = turns.length
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -64,25 +69,33 @@ export function Feed({ entries }: FeedProps) {
|
||||
feedRef.current?.closest<HTMLElement>('.app-main') ?? null,
|
||||
getItemKey: (index) => turns[index]?.id ?? index,
|
||||
estimateSize: () => FEED_TURN_ESTIMATED_HEIGHT,
|
||||
overscan: FEED_OVERSCAN_COUNT
|
||||
overscan: FEED_OVERSCAN_COUNT,
|
||||
useAnimationFrameWithResizeObserver: true
|
||||
})
|
||||
|
||||
// Disclosure resizing happens in visible content, so compensating scroll
|
||||
// offsets on every animation frame would make older turns visibly jitter.
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange =
|
||||
shouldAdjustFeedScrollPosition
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const feed = feedRef.current
|
||||
const mask = maskRef.current
|
||||
const bottomMask = bottomMaskRef.current
|
||||
const scrollElement = feed?.closest<HTMLElement>('.app-main')
|
||||
const topMask = topMaskRef.current
|
||||
|
||||
if (
|
||||
feed === null ||
|
||||
mask === null ||
|
||||
bottomMask === null ||
|
||||
scrollElement === undefined ||
|
||||
scrollElement === null
|
||||
scrollElement === null ||
|
||||
topMask === null
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observedFeed = feed
|
||||
const observedMask = mask
|
||||
const observedMasks = [bottomMask, topMask]
|
||||
const observedScrollElement = scrollElement
|
||||
|
||||
// Fixed elements use the viewport as their containing block, so measure
|
||||
@@ -90,8 +103,10 @@ export function Feed({ entries }: FeedProps) {
|
||||
function updateMaskBounds(): void {
|
||||
const feedBounds = observedFeed.getBoundingClientRect()
|
||||
|
||||
observedMask.style.left = `${feedBounds.left}px`
|
||||
observedMask.style.width = `${feedBounds.width}px`
|
||||
for (const mask of observedMasks) {
|
||||
mask.style.left = `${feedBounds.left}px`
|
||||
mask.style.width = `${feedBounds.width}px`
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateMaskBounds)
|
||||
@@ -310,7 +325,16 @@ export function Feed({ entries }: FeedProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div ref={maskRef} className="feed-mask" aria-hidden="true" />
|
||||
<div
|
||||
ref={topMaskRef}
|
||||
className="feed-mask feed-mask-top"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={bottomMaskRef}
|
||||
className="feed-mask feed-mask-bottom"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</section>
|
||||
</FeedAnimationProvider>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
.leon-message
|
||||
--activity-node-gap: var(--space-md)
|
||||
display: flex
|
||||
width: 100%
|
||||
max-width: 100%
|
||||
flex-direction: column
|
||||
gap: var(--space-xl)
|
||||
gap: var(--activity-node-gap)
|
||||
color: var(--color-text)
|
||||
font-size: var(--font-size-lg)
|
||||
line-height: var(--line-height-lg)
|
||||
@@ -17,6 +18,9 @@
|
||||
.leon-message-execution
|
||||
opacity: 1
|
||||
|
||||
.leon-message > .final-answer
|
||||
margin-top: var(--space-md)
|
||||
|
||||
.leon-message-execution-animate
|
||||
opacity: 0
|
||||
animation: leon-message-execution-enter .34s ease-out .72s forwards
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { clsx } from 'clsx'
|
||||
import mapIconSource from 'remixicon/icons/Map/map-2-line.svg?url'
|
||||
import toolsIconSource from 'remixicon/icons/Design/tools-line.svg?url'
|
||||
|
||||
import type { LeonFeedEntry } from '../../data/feed'
|
||||
import { DottedIcon } from '../dotted-icon'
|
||||
import type {
|
||||
FeedPlanActivity,
|
||||
FeedToolsActivity,
|
||||
LeonFeedActivity,
|
||||
LeonFeedEntry
|
||||
} from '../../data/feed'
|
||||
import { FinalAnswer } from '../final-answer'
|
||||
import { ProcessGroup } from '../process-group'
|
||||
import { StreamingText, useAnimateOnce } from '../streaming-text'
|
||||
@@ -17,82 +19,93 @@ interface LeonMessageProps {
|
||||
entry: LeonFeedEntry
|
||||
}
|
||||
|
||||
export function LeonMessage({ entry }: LeonMessageProps) {
|
||||
const shouldAnimateExecution = useAnimateOnce(`${entry.id}:execution`)
|
||||
const plan = entry.plan
|
||||
const toolCalls = entry.toolCalls ?? []
|
||||
const planIsActive = plan?.some((step) =>
|
||||
step.status === 'in_progress' || step.status === 'pending'
|
||||
) ?? false
|
||||
const toolsAreActive = toolCalls.some((toolCall) =>
|
||||
interface ExecutionActivityProps {
|
||||
activity: FeedPlanActivity | FeedToolsActivity
|
||||
}
|
||||
|
||||
function ExecutionActivity({ activity }: ExecutionActivityProps) {
|
||||
const shouldAnimate = useAnimateOnce(`${activity.id}:execution`)
|
||||
|
||||
if (activity.type === 'plan') {
|
||||
const planIsActive = activity.steps.some((step) =>
|
||||
step.status === 'in_progress' || step.status === 'pending'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={clsx('leon-message-execution', {
|
||||
'leon-message-execution-animate': shouldAnimate
|
||||
})}>
|
||||
<ProcessGroup
|
||||
active={planIsActive}
|
||||
activeLabel="Executing plan..."
|
||||
ariaLabel="Leon’s execution plan"
|
||||
completedLabel="Completed plan"
|
||||
indicator={<i className="ri-map-2-line" aria-hidden="true" />}
|
||||
>
|
||||
<TaskList steps={activity.steps} />
|
||||
</ProcessGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toolsAreActive = activity.toolCalls.some((toolCall) =>
|
||||
toolCall.status === 'running'
|
||||
)
|
||||
const toolCount = toolCalls.length
|
||||
const toolCount = activity.toolCalls.length
|
||||
|
||||
return (
|
||||
<div className="leon-message">
|
||||
<div className={clsx('leon-message-execution', {
|
||||
'leon-message-execution-animate': shouldAnimate
|
||||
})}>
|
||||
<ProcessGroup
|
||||
active={toolsAreActive}
|
||||
activeLabel="Using tools..."
|
||||
ariaLabel="Leon’s tool usage"
|
||||
completedLabel={`Used ${toolCount} ${
|
||||
toolCount === 1 ? 'tool' : 'tools'
|
||||
}`}
|
||||
indicator={(
|
||||
<i className="ri-pencil-ruler-2-line" aria-hidden="true" />
|
||||
)}
|
||||
>
|
||||
<ToolCallList toolCalls={activity.toolCalls} />
|
||||
</ProcessGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderActivity(activity: LeonFeedActivity) {
|
||||
if (activity.type === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
animationId={`${entry.id}:thinking`}
|
||||
details={entry.thinking.details}
|
||||
durationMs={entry.thinking.durationMs}
|
||||
isActive={entry.thinking.isActive}
|
||||
key={activity.id}
|
||||
animationId={activity.id}
|
||||
details={activity.details}
|
||||
durationMs={activity.durationMs}
|
||||
isActive={activity.isActive}
|
||||
/>
|
||||
<p className="leon-message-summary">
|
||||
)
|
||||
}
|
||||
|
||||
if (activity.type === 'summary') {
|
||||
return (
|
||||
<p key={activity.id} className="leon-message-summary">
|
||||
<StreamingText
|
||||
animationId={`${entry.id}:summary`}
|
||||
animationId={activity.id}
|
||||
startDelay={420}
|
||||
text={entry.summary}
|
||||
text={activity.content}
|
||||
/>
|
||||
</p>
|
||||
<div className={clsx('leon-message-execution', {
|
||||
'leon-message-execution-animate': shouldAnimateExecution
|
||||
})}>
|
||||
{plan !== undefined ? (
|
||||
<ProcessGroup
|
||||
active={planIsActive}
|
||||
activeLabel="Executing plan..."
|
||||
ariaLabel="Leon’s execution plan"
|
||||
completedLabel="Completed plan"
|
||||
indicator={(
|
||||
<DottedIcon
|
||||
active={planIsActive}
|
||||
ariaLabel={planIsActive
|
||||
? 'Leon is executing a plan'
|
||||
: 'Leon completed the plan'}
|
||||
source={mapIconSource}
|
||||
sourceHeight={24}
|
||||
sourceWidth={24}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<TaskList steps={plan} />
|
||||
</ProcessGroup>
|
||||
) : toolCount > 0 ? (
|
||||
<ProcessGroup
|
||||
active={toolsAreActive}
|
||||
activeLabel="Using tools..."
|
||||
ariaLabel="Leon’s tool usage"
|
||||
completedLabel={`Used ${toolCount} ${
|
||||
toolCount === 1 ? 'tool' : 'tools'
|
||||
}`}
|
||||
indicator={(
|
||||
<DottedIcon
|
||||
active={toolsAreActive}
|
||||
ariaLabel={toolsAreActive
|
||||
? 'Leon is using tools'
|
||||
: `Leon used ${toolCount} ${
|
||||
toolCount === 1 ? 'tool' : 'tools'
|
||||
}`}
|
||||
source={toolsIconSource}
|
||||
sourceHeight={24}
|
||||
sourceWidth={24}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ToolCallList toolCalls={toolCalls} />
|
||||
</ProcessGroup>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ExecutionActivity key={activity.id} activity={activity} />
|
||||
}
|
||||
|
||||
export function LeonMessage({ entry }: LeonMessageProps) {
|
||||
return (
|
||||
<div className="leon-message">
|
||||
{entry.activities.map(renderActivity)}
|
||||
{entry.finalAnswer.trim().length > 0 && (
|
||||
<FinalAnswer animationId={`${entry.id}:final-answer`}>
|
||||
{entry.finalAnswer}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
padding: 8px 16px
|
||||
border: 1px solid transparent
|
||||
border-radius: 24px
|
||||
background: linear-gradient(var(--color-accent), var(--color-accent)) padding-box, linear-gradient(to bottom, #84BDFF 0%, rgba(245, 245, 247, .1) 100%) border-box
|
||||
background: linear-gradient(var(--color-accent), var(--color-accent)) padding-box, var(--gradient-owner-message-border) border-box
|
||||
color: var(--color-accent-contrast)
|
||||
overflow-wrap: anywhere
|
||||
white-space: pre-wrap
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { ProcessGroup } from './process-group'
|
||||
|
||||
export {
|
||||
ProcessGroup,
|
||||
useProcessGroupNestedDisclosureDefault
|
||||
} from './process-group'
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
.process-group
|
||||
--process-group-heading-height: 25px
|
||||
--process-group-indicator-size: 16px
|
||||
--activity-highlight-width: 48px
|
||||
display: flex
|
||||
position: relative
|
||||
min-width: 0
|
||||
flex-direction: column
|
||||
gap: var(--space-sm)
|
||||
gap: 0
|
||||
|
||||
.process-group-heading,
|
||||
.process-group-trigger
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--space-sm)
|
||||
min-width: 0
|
||||
min-height: 25px
|
||||
min-height: var(--process-group-heading-height)
|
||||
padding: 0
|
||||
color: var(--color-text)
|
||||
font-size: var(--font-size-md)
|
||||
@@ -20,8 +23,12 @@
|
||||
.process-group-trigger
|
||||
align-self: flex-start
|
||||
max-width: 100%
|
||||
color: var(--color-text-secondary)
|
||||
transition: color var(--transition-duration-main) var(--transition-timing-main)
|
||||
|
||||
&:hover
|
||||
color: var(--color-text)
|
||||
|
||||
.process-group-chevron
|
||||
opacity: 1
|
||||
|
||||
@@ -34,10 +41,50 @@
|
||||
outline: 1px solid var(--color-accent)
|
||||
outline-offset: var(--space-xs)
|
||||
|
||||
.process-group-active
|
||||
.process-group-trigger
|
||||
color: var(--color-text)
|
||||
|
||||
.process-group-active-content
|
||||
display: block
|
||||
position: relative
|
||||
min-width: 0
|
||||
|
||||
.process-group-heading-layer
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--space-sm)
|
||||
min-width: 0
|
||||
|
||||
.process-group-title
|
||||
min-width: 0
|
||||
overflow-wrap: anywhere
|
||||
|
||||
.process-group-indicator
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
flex: 0 0 auto
|
||||
width: var(--process-group-indicator-size)
|
||||
height: var(--process-group-indicator-size)
|
||||
font-size: var(--font-size-md)
|
||||
line-height: 1
|
||||
|
||||
.process-group-wave
|
||||
position: absolute
|
||||
inset: 0
|
||||
color: var(--color-activity-wave)
|
||||
pointer-events: none
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 50%, transparent)
|
||||
-webkit-mask-position: calc(0px - var(--activity-highlight-width)) 0
|
||||
-webkit-mask-repeat: no-repeat
|
||||
-webkit-mask-size: var(--activity-highlight-width) 100%
|
||||
mask-image: linear-gradient(90deg, transparent, #000 50%, transparent)
|
||||
mask-position: calc(0px - var(--activity-highlight-width)) 0
|
||||
mask-repeat: no-repeat
|
||||
mask-size: var(--activity-highlight-width) 100%
|
||||
animation: process-group-activity-wave var(--activity-animation-duration) linear infinite
|
||||
|
||||
.process-group-chevron
|
||||
position: relative
|
||||
top: 1px
|
||||
@@ -53,6 +100,26 @@
|
||||
.process-group-content
|
||||
min-width: 0
|
||||
|
||||
> .collapse-content > .collapse-body
|
||||
padding-top: var(--activity-node-gap)
|
||||
|
||||
@keyframes process-group-activity-wave
|
||||
0%
|
||||
-webkit-mask-position: calc(0px - var(--activity-highlight-width)) 0
|
||||
mask-position: calc(0px - var(--activity-highlight-width)) 0
|
||||
|
||||
84%
|
||||
-webkit-mask-position: calc(100% + var(--activity-highlight-width)) 0
|
||||
mask-position: calc(100% + var(--activity-highlight-width)) 0
|
||||
|
||||
100%
|
||||
-webkit-mask-position: calc(100% + var(--activity-highlight-width)) 0
|
||||
mask-position: calc(100% + var(--activity-highlight-width)) 0
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.process-group-trigger,
|
||||
.process-group-chevron
|
||||
transition: none
|
||||
|
||||
.process-group-wave
|
||||
display: none
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { Collapse } from '../collapse'
|
||||
|
||||
import './process-group.sass'
|
||||
|
||||
interface ProcessGroupProps {
|
||||
active: boolean
|
||||
activeLabel: string
|
||||
animateWhileActive?: boolean
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
@@ -13,10 +24,18 @@ interface ProcessGroupProps {
|
||||
indicator: ReactNode
|
||||
}
|
||||
|
||||
const NestedDisclosureDefaultContext = createContext(true)
|
||||
|
||||
/** Returns whether nested disclosures should open on their next mount. */
|
||||
export function useProcessGroupNestedDisclosureDefault(): boolean {
|
||||
return useContext(NestedDisclosureDefaultContext)
|
||||
}
|
||||
|
||||
/** Groups one progressive agent activity behind a shared status disclosure. */
|
||||
export function ProcessGroup({
|
||||
active,
|
||||
activeLabel,
|
||||
animateWhileActive = false,
|
||||
ariaLabel,
|
||||
children,
|
||||
className,
|
||||
@@ -26,6 +45,7 @@ export function ProcessGroup({
|
||||
const contentId = useId()
|
||||
const previousActiveRef = useRef(active)
|
||||
const [isExpanded, setIsExpanded] = useState(active)
|
||||
const [expandNestedByDefault, setExpandNestedByDefault] = useState(active)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousActiveRef.current === active) {
|
||||
@@ -34,49 +54,75 @@ export function ProcessGroup({
|
||||
|
||||
// Active work stays visible; the completed snapshot starts collapsed.
|
||||
setIsExpanded(active)
|
||||
setExpandNestedByDefault(active)
|
||||
previousActiveRef.current = active
|
||||
}, [active])
|
||||
|
||||
const headingContent = (
|
||||
<>
|
||||
{indicator}
|
||||
<span className="process-group-title">
|
||||
{active ? activeLabel : completedLabel}
|
||||
function handleToggle(): void {
|
||||
if (isExpanded) {
|
||||
// Reopening a collapsed group should not reopen its entire disclosure tree.
|
||||
setExpandNestedByDefault(false)
|
||||
}
|
||||
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
const headingLayer = (
|
||||
label: string,
|
||||
className?: string,
|
||||
decorative = false
|
||||
) => (
|
||||
<span
|
||||
className={clsx('process-group-heading-layer', className)}
|
||||
aria-hidden={decorative || undefined}
|
||||
>
|
||||
<span className="process-group-indicator" aria-hidden="true">
|
||||
{indicator}
|
||||
</span>
|
||||
</>
|
||||
<span className="process-group-title">{label}</span>
|
||||
{!decorative && (
|
||||
<i
|
||||
className="process-group-chevron ri-arrow-right-s-line"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const label = active ? activeLabel : completedLabel
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx('process-group', className, {
|
||||
'process-group-active': active
|
||||
})}
|
||||
aria-label={ariaLabel}
|
||||
<NestedDisclosureDefaultContext.Provider
|
||||
value={expandNestedByDefault}
|
||||
>
|
||||
{active ? (
|
||||
<div className="process-group-heading">
|
||||
{headingContent}
|
||||
</div>
|
||||
) : (
|
||||
<section
|
||||
className={clsx('process-group', className, {
|
||||
'process-group-active': active
|
||||
})}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="process-group-trigger"
|
||||
aria-controls={contentId}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((isOpen) => !isOpen)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{headingContent}
|
||||
<i
|
||||
className="process-group-chevron ri-arrow-right-s-line"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{active && animateWhileActive ? (
|
||||
<span className="process-group-active-content">
|
||||
{headingLayer(label)}
|
||||
{headingLayer(label, 'process-group-wave', true)}
|
||||
</span>
|
||||
) : headingLayer(label)}
|
||||
</button>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div id={contentId} className="process-group-content">
|
||||
<Collapse
|
||||
id={contentId}
|
||||
className="process-group-content"
|
||||
isOpen={isExpanded}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Collapse>
|
||||
</section>
|
||||
</NestedDisclosureDefaultContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
.task-list-item
|
||||
--task-list-item-marker-size: 20px
|
||||
--task-list-item-marker-size: 16px
|
||||
--task-list-item-marker-offset: calc((var(--line-height-md) - var(--task-list-item-marker-size)) / 2)
|
||||
--task-list-item-title-color: var(--color-text-secondary)
|
||||
position: relative
|
||||
display: flex
|
||||
gap: var(--space-sm)
|
||||
color: var(--color-text-secondary)
|
||||
|
||||
.task-list-item-animate
|
||||
opacity: 0
|
||||
animation: task-list-item-enter .28s ease-out forwards
|
||||
|
||||
.task-list-item:nth-child(2)
|
||||
animation-delay: 80ms
|
||||
|
||||
.task-list-item:nth-child(3)
|
||||
animation-delay: 160ms
|
||||
|
||||
.task-list-item-marker
|
||||
position: relative
|
||||
z-index: 1
|
||||
@@ -25,18 +16,16 @@
|
||||
justify-content: center
|
||||
width: var(--task-list-item-marker-size)
|
||||
height: var(--task-list-item-marker-size)
|
||||
margin-top: var(--task-list-item-marker-offset)
|
||||
border-radius: var(--border-radius-pill)
|
||||
background-color: var(--color-surface)
|
||||
color: var(--color-text-tertiary)
|
||||
font-size: 14px
|
||||
font-size: 12px
|
||||
pointer-events: none
|
||||
|
||||
.task-list-item-completed .task-list-item-marker
|
||||
background-color: var(--color-accent-soft)
|
||||
color: var(--color-accent)
|
||||
|
||||
.task-list-item-completed .task-list-item-marker,
|
||||
.task-list-item-in_progress .task-list-item-marker
|
||||
background-color: transparent
|
||||
background-color: var(--color-accent-soft)
|
||||
color: var(--color-accent)
|
||||
|
||||
.task-list-item-in_progress
|
||||
@@ -57,7 +46,18 @@
|
||||
flex: 1 1 auto
|
||||
min-width: 0
|
||||
flex-direction: column
|
||||
gap: var(--space-xl)
|
||||
gap: 0
|
||||
|
||||
.task-list-item-tool-calls
|
||||
width: calc(100% + var(--task-list-item-marker-size) + var(--space-sm))
|
||||
margin-left: calc((var(--task-list-item-marker-size) + var(--space-sm)) * -1)
|
||||
|
||||
> .collapse-content > .collapse-body
|
||||
padding-top: var(--activity-node-gap)
|
||||
|
||||
.tool-call-list-nested
|
||||
width: 100%
|
||||
margin-left: 0
|
||||
|
||||
.task-list-item-trigger,
|
||||
.task-list-item-heading
|
||||
@@ -107,20 +107,7 @@
|
||||
transform-origin: center
|
||||
transition: color var(--transition-duration-main) var(--transition-timing-main), opacity var(--transition-duration-main) var(--transition-timing-main), transform var(--transition-duration-main) var(--transition-timing-main)
|
||||
|
||||
@keyframes task-list-item-enter
|
||||
from
|
||||
opacity: 0
|
||||
transform: translate3d(0, 5px, 0)
|
||||
|
||||
to
|
||||
opacity: 1
|
||||
transform: translate3d(0, 0, 0)
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.task-list-item-animate
|
||||
opacity: 1
|
||||
animation: none
|
||||
|
||||
.task-list-item-label,
|
||||
.task-list-item-chevron
|
||||
transition: none
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import type { FeedPlanStep } from '../../data/feed'
|
||||
import { Loader } from '../loader'
|
||||
import { useAnimateOnce } from '../streaming-text'
|
||||
import { Collapse } from '../collapse'
|
||||
import { useProcessGroupNestedDisclosureDefault } from '../process-group'
|
||||
import { ToolCallList } from '../tool-call-list'
|
||||
|
||||
import './task-list-item.sass'
|
||||
@@ -14,15 +14,34 @@ interface TaskListItemProps {
|
||||
|
||||
export function TaskListItem({ step }: TaskListItemProps) {
|
||||
const hasToolCalls = step.toolCalls.length > 0
|
||||
const [isExpanded, setIsExpanded] = useState(hasToolCalls)
|
||||
const shouldAnimate = useAnimateOnce(`${step.id}:plan-step`)
|
||||
const expandByDefault = useProcessGroupNestedDisclosureDefault()
|
||||
const previousStatusRef = useRef(step.status)
|
||||
const toolCallsId = useId()
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
step.status === 'in_progress' && expandByDefault
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (previousStatusRef.current === step.status) {
|
||||
return
|
||||
}
|
||||
|
||||
previousStatusRef.current = step.status
|
||||
|
||||
// Move the disclosure with execution progress without overriding manual
|
||||
// toggles while a step remains in the same state.
|
||||
if (step.status === 'completed') {
|
||||
setIsExpanded(false)
|
||||
} else if (step.status === 'in_progress') {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [step.status])
|
||||
|
||||
const marker = step.status === 'completed'
|
||||
? <i className="task-list-item-marker-icon ri-check-line" aria-hidden="true" />
|
||||
: step.status === 'error'
|
||||
? <i className="task-list-item-marker-icon ri-close-line" aria-hidden="true" />
|
||||
: step.status === 'in_progress'
|
||||
? <Loader />
|
||||
: null
|
||||
: null
|
||||
const label = (
|
||||
<>
|
||||
<span className="task-list-item-label">{step.label}</span>
|
||||
@@ -38,8 +57,7 @@ export function TaskListItem({ step }: TaskListItemProps) {
|
||||
return (
|
||||
<li className={clsx(
|
||||
'task-list-item',
|
||||
`task-list-item-${step.status}`,
|
||||
{ 'task-list-item-animate': shouldAnimate }
|
||||
`task-list-item-${step.status}`
|
||||
)}>
|
||||
<span className="task-list-item-marker">{marker}</span>
|
||||
<div className="task-list-item-content">
|
||||
@@ -47,6 +65,7 @@ export function TaskListItem({ step }: TaskListItemProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="task-list-item-trigger"
|
||||
aria-controls={toolCallsId}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((isOpen) => !isOpen)}
|
||||
>
|
||||
@@ -55,8 +74,14 @@ export function TaskListItem({ step }: TaskListItemProps) {
|
||||
) : (
|
||||
<div className="task-list-item-heading">{label}</div>
|
||||
)}
|
||||
{hasToolCalls && isExpanded && (
|
||||
<ToolCallList toolCalls={step.toolCalls} nested />
|
||||
{hasToolCalls && (
|
||||
<Collapse
|
||||
id={toolCallsId}
|
||||
className="task-list-item-tool-calls"
|
||||
isOpen={isExpanded}
|
||||
>
|
||||
<ToolCallList toolCalls={step.toolCalls} nested />
|
||||
</Collapse>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.task-list
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: var(--space-xl)
|
||||
gap: var(--activity-node-gap)
|
||||
margin: 0
|
||||
padding: 0
|
||||
list-style: none
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ThinkingBrain } from './thinking-brain'
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.thinking-brain
|
||||
display: block
|
||||
flex: 0 0 auto
|
||||
width: 24px
|
||||
height: 25px
|
||||
color: var(--color-text-secondary)
|
||||
@@ -1,27 +0,0 @@
|
||||
import { DottedIcon } from '../dotted-icon'
|
||||
|
||||
import './thinking-brain.sass'
|
||||
|
||||
const BRAIN_MASK_SOURCE = '/img/logo-for-dark-bg.svg'
|
||||
const BRAIN_SOURCE_WIDTH = 44
|
||||
const BRAIN_SOURCE_HEIGHT = 46
|
||||
|
||||
interface ThinkingBrainProps {
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export function ThinkingBrain({ active = true }: ThinkingBrainProps) {
|
||||
return (
|
||||
<DottedIcon
|
||||
active={active}
|
||||
ariaLabel={active
|
||||
? 'Leon is thinking'
|
||||
: 'Leon thought through this response'}
|
||||
className="thinking-brain"
|
||||
maskMode="light"
|
||||
source={BRAIN_MASK_SOURCE}
|
||||
sourceHeight={BRAIN_SOURCE_HEIGHT}
|
||||
sourceWidth={BRAIN_SOURCE_WIDTH}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { clsx } from 'clsx'
|
||||
|
||||
import { ProcessGroup } from '../process-group'
|
||||
import { StreamingText, useAnimateOnce } from '../streaming-text'
|
||||
import { ThinkingBrain } from '../thinking-brain'
|
||||
import { ThoughtIcon } from '../thought-icon'
|
||||
|
||||
import './thinking-message.sass'
|
||||
|
||||
@@ -16,7 +16,6 @@ interface ThinkingMessageProps {
|
||||
const MILLISECONDS_PER_SECOND = 1_000
|
||||
const MILLISECONDS_PER_MINUTE = 60_000
|
||||
const MILLISECONDS_PER_HOUR = 3_600_000
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
if (durationMs < MILLISECONDS_PER_MINUTE) {
|
||||
const seconds = Math.max(
|
||||
@@ -57,9 +56,10 @@ export function ThinkingMessage({
|
||||
<ProcessGroup
|
||||
active={isActive}
|
||||
activeLabel="Thinking..."
|
||||
animateWhileActive
|
||||
ariaLabel="Leon’s thinking"
|
||||
completedLabel={`Thought for ${formatDuration(durationMs)}`}
|
||||
indicator={<ThinkingBrain active={isActive} />}
|
||||
indicator={<ThoughtIcon />}
|
||||
>
|
||||
{isActive ? (
|
||||
<StreamingText
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ThoughtIcon } from './thought-icon'
|
||||
@@ -0,0 +1,6 @@
|
||||
.thought-icon
|
||||
display: block
|
||||
width: 16px
|
||||
height: 16px
|
||||
flex: 0 0 auto
|
||||
overflow: visible
|
||||
@@ -0,0 +1,35 @@
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import './thought-icon.sass'
|
||||
|
||||
interface ThoughtIconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Displays the cloud-shaped thought indicator missing from RemixIcon. */
|
||||
export function ThoughtIcon({ className }: ThoughtIconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx('thought-icon', className)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.1 15.9c-2.83 0-4.9-1.95-4.9-4.3 0-2 1.45-3.7 3.5-4.25a4 4 0 0 1-.22-1.2c0-2.4 2.1-4.35 4.67-4.35 1.2 0 2.3.42 3.15 1.1a4.95 4.95 0 0 1 3.45-1.3c2.83 0 5.13 2.14 5.13 4.78 0 1.2-.46 2.28-1.22 3.12 1.46.75 2.44 2.18 2.44 3.81 0 2.4-2.08 4.34-4.65 4.34-.82 0-1.59-.2-2.25-.57a5.78 5.78 0 0 1-4.22 1.77c-2.28 0-4.21-1.23-4.88-2.95Z"
|
||||
transform="translate(3 .5) scale(.86)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.05"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="5.4"
|
||||
cy="19.7"
|
||||
r="1.35"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
.tool-call-list
|
||||
--tool-call-marker-size: 20px
|
||||
--tool-call-marker-size: 16px
|
||||
--tool-call-marker-offset: calc((var(--line-height-md) - var(--tool-call-marker-size)) / 2)
|
||||
--timeline-connector-gap: 4px
|
||||
--tool-call-list-gap: var(--space-xl)
|
||||
--tool-call-list-gap: var(--activity-node-gap)
|
||||
position: relative
|
||||
display: flex
|
||||
flex-direction: column
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
&::before
|
||||
position: absolute
|
||||
top: calc(var(--task-list-item-marker-size) + var(--timeline-connector-gap) - var(--line-height-md) - var(--tool-call-list-gap))
|
||||
top: calc(var(--task-list-item-marker-offset) + var(--task-list-item-marker-size) + var(--timeline-connector-gap) - var(--line-height-md) - var(--tool-call-list-gap))
|
||||
left: calc(var(--tool-call-marker-size) / 2)
|
||||
width: 1px
|
||||
height: calc(var(--line-height-md) + var(--tool-call-list-gap) + var(--tool-call-marker-offset) - var(--task-list-item-marker-size) - var(--timeline-connector-gap) - var(--timeline-connector-gap))
|
||||
height: calc(var(--line-height-md) + var(--tool-call-list-gap) + var(--tool-call-marker-offset) - var(--task-list-item-marker-offset) - var(--task-list-item-marker-size) - var(--timeline-connector-gap) - var(--timeline-connector-gap))
|
||||
background-color: var(--color-timeline-border)
|
||||
content: ''
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.tool-call
|
||||
--tool-call-highlight-width: 48px
|
||||
--tool-call-title-color: var(--color-text-secondary)
|
||||
position: relative
|
||||
min-width: 0
|
||||
@@ -12,16 +13,6 @@
|
||||
background-color: var(--color-timeline-border)
|
||||
content: ''
|
||||
|
||||
.tool-call-animate
|
||||
opacity: 0
|
||||
animation: tool-call-enter .24s ease-out forwards
|
||||
|
||||
.tool-call:nth-child(2)
|
||||
animation-delay: 60ms
|
||||
|
||||
.tool-call:nth-child(3)
|
||||
animation-delay: 120ms
|
||||
|
||||
.tool-call-trigger
|
||||
display: flex
|
||||
align-items: center
|
||||
@@ -35,8 +26,7 @@
|
||||
text-align: left
|
||||
|
||||
&:hover
|
||||
.tool-call-title,
|
||||
.tool-call-tool-icon,
|
||||
.tool-call-heading-layer:not(.tool-call-wave),
|
||||
.tool-call-chevron
|
||||
color: var(--color-text)
|
||||
|
||||
@@ -52,6 +42,22 @@
|
||||
outline: 1px solid var(--color-accent)
|
||||
outline-offset: var(--space-xs)
|
||||
|
||||
.tool-call-running-content
|
||||
isolation: isolate
|
||||
display: block
|
||||
position: relative
|
||||
min-width: 0
|
||||
flex: 1 1 auto
|
||||
|
||||
.tool-call-heading-layer
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: var(--space-sm)
|
||||
min-width: 0
|
||||
flex: 1 1 auto
|
||||
color: var(--tool-call-title-color)
|
||||
transition: color var(--transition-duration-main) var(--transition-timing-main)
|
||||
|
||||
.tool-call-tool-icon
|
||||
position: relative
|
||||
z-index: 1
|
||||
@@ -61,10 +67,9 @@
|
||||
justify-content: center
|
||||
width: var(--tool-call-marker-size)
|
||||
height: var(--tool-call-marker-size)
|
||||
color: var(--tool-call-title-color)
|
||||
color: currentColor
|
||||
font-size: var(--font-size-md)
|
||||
line-height: 1
|
||||
transition: color var(--transition-duration-main) var(--transition-timing-main)
|
||||
|
||||
.tool-call-title
|
||||
flex: 1 1 auto
|
||||
@@ -72,12 +77,27 @@
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
color: var(--tool-call-title-color)
|
||||
transition: color var(--transition-duration-main) var(--transition-timing-main)
|
||||
color: currentColor
|
||||
|
||||
.tool-call-running
|
||||
--tool-call-title-color: var(--color-text)
|
||||
|
||||
.tool-call-wave
|
||||
position: absolute
|
||||
inset: 0
|
||||
z-index: 2
|
||||
color: var(--color-activity-wave)
|
||||
pointer-events: none
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 50%, transparent)
|
||||
-webkit-mask-position: calc(0px - var(--tool-call-highlight-width)) 0
|
||||
-webkit-mask-repeat: no-repeat
|
||||
-webkit-mask-size: var(--tool-call-highlight-width) 100%
|
||||
mask-image: linear-gradient(90deg, transparent, #000 50%, transparent)
|
||||
mask-position: calc(0px - var(--tool-call-highlight-width)) 0
|
||||
mask-repeat: no-repeat
|
||||
mask-size: var(--tool-call-highlight-width) 100%
|
||||
animation: tool-call-title-wave var(--activity-animation-duration) linear infinite
|
||||
|
||||
.tool-call-chevron
|
||||
position: relative
|
||||
top: 1px
|
||||
@@ -111,14 +131,18 @@
|
||||
font-size: var(--font-size-xs)
|
||||
line-height: var(--line-height-xs)
|
||||
|
||||
@keyframes tool-call-enter
|
||||
from
|
||||
opacity: 0
|
||||
transform: translate3d(0, 4px, 0)
|
||||
@keyframes tool-call-title-wave
|
||||
0%
|
||||
-webkit-mask-position: calc(0px - var(--tool-call-highlight-width)) 0
|
||||
mask-position: calc(0px - var(--tool-call-highlight-width)) 0
|
||||
|
||||
to
|
||||
opacity: 1
|
||||
transform: translate3d(0, 0, 0)
|
||||
84%
|
||||
-webkit-mask-position: calc(100% + var(--tool-call-highlight-width)) 0
|
||||
mask-position: calc(100% + var(--tool-call-highlight-width)) 0
|
||||
|
||||
100%
|
||||
-webkit-mask-position: calc(100% + var(--tool-call-highlight-width)) 0
|
||||
mask-position: calc(100% + var(--tool-call-highlight-width)) 0
|
||||
|
||||
@media (max-width: 680px)
|
||||
.tool-call-details
|
||||
@@ -130,11 +154,9 @@
|
||||
border-left: 0
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.tool-call-animate
|
||||
opacity: 1
|
||||
animation: none
|
||||
|
||||
.tool-call-title,
|
||||
.tool-call-tool-icon,
|
||||
.tool-call-heading-layer,
|
||||
.tool-call-chevron
|
||||
transition: none
|
||||
|
||||
.tool-call-wave
|
||||
display: none
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useId, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import type { FeedToolCall } from '../../data/feed'
|
||||
import { Collapse } from '../collapse'
|
||||
import { JsonView } from '../json-view'
|
||||
import { useAnimateOnce } from '../streaming-text'
|
||||
|
||||
import './tool-call.sass'
|
||||
|
||||
@@ -43,19 +43,29 @@ function formatFunctionName(functionName: string): string {
|
||||
export function ToolCall({ toolCall }: ToolCallProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const detailsId = useId()
|
||||
const shouldAnimate = useAnimateOnce(`${toolCall.id}:tool-call`)
|
||||
const technicalTitle = [
|
||||
toolCall.toolkitName,
|
||||
toolCall.toolName,
|
||||
formatFunctionName(toolCall.functionName)
|
||||
].join(' • ')
|
||||
const headingLayer = (
|
||||
className?: string,
|
||||
decorative = false
|
||||
) => (
|
||||
<span
|
||||
className={clsx('tool-call-heading-layer', className)}
|
||||
aria-hidden={decorative || undefined}
|
||||
>
|
||||
<i
|
||||
className={`tool-call-tool-icon ri-${toolCall.toolIconName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tool-call-title">{toolCall.toolCallTitle}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={clsx(
|
||||
'tool-call',
|
||||
`tool-call-${toolCall.status}`,
|
||||
{ 'tool-call-animate': shouldAnimate }
|
||||
)}>
|
||||
<section className={clsx('tool-call', `tool-call-${toolCall.status}`)}>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-trigger"
|
||||
@@ -63,18 +73,19 @@ export function ToolCall({ toolCall }: ToolCallProps) {
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setIsExpanded((isOpen) => !isOpen)}
|
||||
>
|
||||
<i
|
||||
className={`tool-call-tool-icon ri-${toolCall.toolIconName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tool-call-title">{toolCall.toolCallTitle}</span>
|
||||
{toolCall.status === 'running' ? (
|
||||
<span className="tool-call-running-content">
|
||||
{headingLayer()}
|
||||
{headingLayer('tool-call-wave', true)}
|
||||
</span>
|
||||
) : headingLayer()}
|
||||
<i
|
||||
className="tool-call-chevron ri-arrow-right-s-line"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div id={detailsId} className="tool-call-details">
|
||||
<Collapse id={detailsId} isOpen={isExpanded}>
|
||||
<div className="tool-call-details">
|
||||
<p className="tool-call-details-title">{technicalTitle}</p>
|
||||
<JsonView label="Input" value={toolCall.input} />
|
||||
<JsonView
|
||||
@@ -82,7 +93,7 @@ export function ToolCall({ toolCall }: ToolCallProps) {
|
||||
value={toolCall.output ?? { status: toolCall.status }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Collapse>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type {
|
||||
FeedEntry,
|
||||
FeedPlanActivity,
|
||||
FeedPlanStep,
|
||||
FeedPlanStepStatus,
|
||||
FeedSummaryActivity,
|
||||
FeedThinkingActivity,
|
||||
FeedToolCall,
|
||||
FeedToolCallStatus,
|
||||
FeedToolsActivity,
|
||||
JsonValue,
|
||||
LeonFeedEntry
|
||||
} from './feed'
|
||||
@@ -37,6 +41,11 @@ const THINKING_DETAILS = [
|
||||
'I will use the available system and search tools, then verify the result before finishing.'
|
||||
]
|
||||
const THINKING_DURATION_MS = 8_400
|
||||
const FOLLOW_UP_THINKING_DETAILS = [
|
||||
'The first results narrow the issue to the resolved shell configuration.',
|
||||
'I need to verify the remaining value before finalizing the change.'
|
||||
]
|
||||
const FOLLOW_UP_THINKING_DURATION_MS = 3_600
|
||||
|
||||
const FINAL_ANSWER = [
|
||||
'Fixed it.',
|
||||
@@ -197,6 +206,44 @@ function createPlan(isCompleted: boolean): FeedPlanStep[] {
|
||||
]
|
||||
}
|
||||
|
||||
function createThinkingActivity(
|
||||
details: string[],
|
||||
durationMs: number,
|
||||
isActive: boolean
|
||||
): FeedThinkingActivity {
|
||||
return {
|
||||
id: window.crypto.randomUUID(),
|
||||
type: 'thinking',
|
||||
details,
|
||||
durationMs,
|
||||
isActive
|
||||
}
|
||||
}
|
||||
|
||||
function createSummaryActivity(content: string): FeedSummaryActivity {
|
||||
return {
|
||||
id: window.crypto.randomUUID(),
|
||||
type: 'summary',
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
function createPlanActivity(steps: FeedPlanStep[]): FeedPlanActivity {
|
||||
return {
|
||||
id: window.crypto.randomUUID(),
|
||||
type: 'plan',
|
||||
steps
|
||||
}
|
||||
}
|
||||
|
||||
function createToolsActivity(toolCalls: FeedToolCall[]): FeedToolsActivity {
|
||||
return {
|
||||
id: window.crypto.randomUUID(),
|
||||
type: 'tools',
|
||||
toolCalls
|
||||
}
|
||||
}
|
||||
|
||||
function createLeonEntry(scenario: MockFeedScenario): LeonFeedEntry {
|
||||
const isInProgress = scenario.startsWith('in-progress')
|
||||
const hasPlan = scenario.endsWith('plan') && !scenario.endsWith('no-plan')
|
||||
@@ -208,37 +255,47 @@ function createLeonEntry(scenario: MockFeedScenario): LeonFeedEntry {
|
||||
return {
|
||||
id: window.crypto.randomUUID(),
|
||||
role: 'leon',
|
||||
thinking: {
|
||||
details: THINKING_DETAILS,
|
||||
durationMs: THINKING_DURATION_MS,
|
||||
isActive: isInProgress
|
||||
},
|
||||
summary,
|
||||
finalAnswer: isInProgress ? '' : FINAL_ANSWER,
|
||||
plan: createPlan(!isInProgress)
|
||||
activities: [
|
||||
createThinkingActivity(
|
||||
THINKING_DETAILS,
|
||||
THINKING_DURATION_MS,
|
||||
false
|
||||
),
|
||||
createSummaryActivity(summary),
|
||||
createPlanActivity(createPlan(!isInProgress)),
|
||||
createThinkingActivity(
|
||||
FOLLOW_UP_THINKING_DETAILS,
|
||||
FOLLOW_UP_THINKING_DURATION_MS,
|
||||
isInProgress
|
||||
)
|
||||
],
|
||||
finalAnswer: isInProgress ? '' : FINAL_ANSWER
|
||||
}
|
||||
}
|
||||
|
||||
const firstToolCalls = [createFileReadToolCall(), createSearchToolCall()]
|
||||
const finalToolCall = createShellToolCall(
|
||||
isInProgress ? 'running' : 'success',
|
||||
isInProgress
|
||||
? 'Inspect resolved Ghostty configuration'
|
||||
: 'Verify Ghostty configuration'
|
||||
)
|
||||
|
||||
return {
|
||||
id: window.crypto.randomUUID(),
|
||||
role: 'leon',
|
||||
thinking: {
|
||||
details: THINKING_DETAILS,
|
||||
durationMs: THINKING_DURATION_MS,
|
||||
isActive: isInProgress
|
||||
},
|
||||
summary,
|
||||
finalAnswer: isInProgress ? '' : FINAL_ANSWER,
|
||||
toolCalls: isInProgress
|
||||
? [
|
||||
createFileReadToolCall(),
|
||||
createShellToolCall('running', 'Inspect resolved Ghostty configuration')
|
||||
]
|
||||
: [
|
||||
createFileReadToolCall(),
|
||||
createSearchToolCall(),
|
||||
createShellToolCall('success', 'Verify Ghostty configuration')
|
||||
]
|
||||
activities: [
|
||||
createThinkingActivity(THINKING_DETAILS, THINKING_DURATION_MS, false),
|
||||
createSummaryActivity(summary),
|
||||
createToolsActivity(firstToolCalls),
|
||||
createThinkingActivity(
|
||||
FOLLOW_UP_THINKING_DETAILS,
|
||||
FOLLOW_UP_THINKING_DURATION_MS,
|
||||
false
|
||||
),
|
||||
createToolsActivity([finalToolCall])
|
||||
],
|
||||
finalAnswer: isInProgress ? '' : FINAL_ANSWER
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,40 @@ export interface FeedPlanStep {
|
||||
toolCalls: FeedToolCall[]
|
||||
}
|
||||
|
||||
export interface FeedThinking {
|
||||
export interface FeedThinkingActivity {
|
||||
id: string
|
||||
type: 'thinking'
|
||||
details: string[]
|
||||
durationMs: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface FeedSummaryActivity {
|
||||
id: string
|
||||
type: 'summary'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface FeedPlanActivity {
|
||||
id: string
|
||||
type: 'plan'
|
||||
steps: FeedPlanStep[]
|
||||
}
|
||||
|
||||
export interface FeedToolsActivity {
|
||||
id: string
|
||||
type: 'tools'
|
||||
toolCalls: FeedToolCall[]
|
||||
}
|
||||
|
||||
// Preserve agent-loop chronology by representing each phase as its own node.
|
||||
// In particular, later reasoning must create another thinking activity.
|
||||
export type LeonFeedActivity =
|
||||
| FeedThinkingActivity
|
||||
| FeedSummaryActivity
|
||||
| FeedPlanActivity
|
||||
| FeedToolsActivity
|
||||
|
||||
export interface OwnerFeedEntry {
|
||||
id: string
|
||||
role: 'owner'
|
||||
@@ -48,11 +76,8 @@ export interface OwnerFeedEntry {
|
||||
export interface LeonFeedEntry {
|
||||
id: string
|
||||
role: 'leon'
|
||||
thinking: FeedThinking
|
||||
summary: string
|
||||
activities: LeonFeedActivity[]
|
||||
finalAnswer: string
|
||||
plan?: FeedPlanStep[]
|
||||
toolCalls?: FeedToolCall[]
|
||||
}
|
||||
|
||||
export type FeedEntry = OwnerFeedEntry | LeonFeedEntry
|
||||
|
||||
@@ -108,13 +108,66 @@
|
||||
padding-right: calc(var(--space-sm) - 1px)
|
||||
|
||||
.session-list-item-title
|
||||
position: relative
|
||||
display: block
|
||||
flex: 1
|
||||
min-width: 0
|
||||
overflow: hidden
|
||||
font-size: var(--font-size-md)
|
||||
line-height: var(--line-height-md)
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
|
||||
.session-list-item-title-resting
|
||||
display: block
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
|
||||
.session-list-item-title-text
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
display: inline-block
|
||||
min-width: max-content
|
||||
opacity: 0
|
||||
transform: translateX(0)
|
||||
|
||||
.session-list-item-title-mask
|
||||
position: absolute
|
||||
z-index: 1
|
||||
top: 0
|
||||
bottom: 0
|
||||
width: var(--space-md)
|
||||
background-color: var(--color-sidebar-background)
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
transition: background-color var(--transition-duration-main) var(--transition-timing-main)
|
||||
|
||||
.session-list-item-link:active .session-list-item-title-mask
|
||||
background-color: var(--color-sidebar-background-active)
|
||||
|
||||
.session-list-item-active .session-list-item-title-mask
|
||||
background-color: var(--color-sidebar-background-hover)
|
||||
|
||||
.session-list-item-title-mask-left
|
||||
left: 0
|
||||
mask-image: linear-gradient(90deg, #000, transparent)
|
||||
-webkit-mask-image: linear-gradient(90deg, #000, transparent)
|
||||
|
||||
.session-list-item-title-mask-right
|
||||
right: 0
|
||||
mask-image: linear-gradient(90deg, transparent, #000)
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000)
|
||||
|
||||
.session-list-item-title-scrolling
|
||||
.session-list-item-title-resting
|
||||
opacity: 0
|
||||
|
||||
.session-list-item-title-text
|
||||
opacity: 1
|
||||
|
||||
.session-list-item-title-mask
|
||||
background-color: var(--color-sidebar-background-hover)
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.session-list-item .session-list-item-link
|
||||
transition: none
|
||||
@@ -124,3 +177,6 @@
|
||||
|
||||
.session-list-item .session-list-item-pinned-icon
|
||||
transition: none
|
||||
|
||||
.session-list-item .session-list-item-title-mask
|
||||
transition: none
|
||||
|
||||
@@ -15,6 +15,15 @@ import { Input } from '../../../components/input'
|
||||
|
||||
import './session-list-item.sass'
|
||||
|
||||
const TITLE_SCROLL_SPEED_PX_PER_SECOND = 24
|
||||
const TITLE_SCROLL_INITIAL_DELAY_MS = 500
|
||||
const TITLE_SCROLL_END_PAUSE_MS = 2_000
|
||||
const TITLE_SCROLL_START_PAUSE_MS = 2_000
|
||||
const TITLE_SCROLL_RETURN_DURATION_MS = 320
|
||||
const TITLE_MASK_FADE_DURATION_MS = 120
|
||||
const MINIMUM_TITLE_OVERFLOW_PX = 1
|
||||
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
interface SessionListItemProps {
|
||||
id: string
|
||||
isPinned: boolean
|
||||
@@ -33,6 +42,12 @@ export function SessionListItem({
|
||||
style
|
||||
}: SessionListItemProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const titleAnimationFrameRef = useRef<number | null>(null)
|
||||
const titleAnimationsRef = useRef<Animation[]>([])
|
||||
const titleLeftMaskRef = useRef<HTMLSpanElement>(null)
|
||||
const titleRightMaskRef = useRef<HTMLSpanElement>(null)
|
||||
const titleTextRef = useRef<HTMLSpanElement>(null)
|
||||
const titleViewportRef = useRef<HTMLSpanElement>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [draftTitle, setDraftTitle] = useState(title)
|
||||
@@ -56,6 +71,155 @@ export function SessionListItem({
|
||||
inputRef.current?.select()
|
||||
}, [editing, title])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (titleAnimationFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(titleAnimationFrameRef.current)
|
||||
}
|
||||
|
||||
titleAnimationsRef.current.forEach((animation) => animation.cancel())
|
||||
titleAnimationsRef.current = []
|
||||
titleViewportRef.current?.classList.remove(
|
||||
'session-list-item-title-scrolling'
|
||||
)
|
||||
}, [title])
|
||||
|
||||
function stopTitleScroll(): void {
|
||||
if (titleAnimationFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(titleAnimationFrameRef.current)
|
||||
titleAnimationFrameRef.current = null
|
||||
}
|
||||
|
||||
titleAnimationsRef.current.forEach((animation) => animation.cancel())
|
||||
titleAnimationsRef.current = []
|
||||
titleViewportRef.current?.classList.remove(
|
||||
'session-list-item-title-scrolling'
|
||||
)
|
||||
}
|
||||
|
||||
function startTitleScroll(): void {
|
||||
stopTitleScroll()
|
||||
|
||||
if (window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches) {
|
||||
return
|
||||
}
|
||||
|
||||
// Measure on the next frame after hover actions have claimed their space.
|
||||
titleAnimationFrameRef.current = window.requestAnimationFrame(() => {
|
||||
titleAnimationFrameRef.current = null
|
||||
|
||||
const titleText = titleTextRef.current
|
||||
const titleViewport = titleViewportRef.current
|
||||
const titleLeftMask = titleLeftMaskRef.current
|
||||
const titleRightMask = titleRightMaskRef.current
|
||||
|
||||
if (
|
||||
titleText === null ||
|
||||
titleViewport === null ||
|
||||
titleLeftMask === null ||
|
||||
titleRightMask === null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const overflowDistance = titleText.scrollWidth - titleViewport.clientWidth
|
||||
|
||||
if (overflowDistance <= MINIMUM_TITLE_OVERFLOW_PX) {
|
||||
return
|
||||
}
|
||||
|
||||
titleViewport.classList.add('session-list-item-title-scrolling')
|
||||
|
||||
const scrollDuration = (
|
||||
overflowDistance / TITLE_SCROLL_SPEED_PX_PER_SECOND
|
||||
) * 1_000
|
||||
const totalDuration =
|
||||
scrollDuration +
|
||||
TITLE_SCROLL_END_PAUSE_MS +
|
||||
TITLE_SCROLL_RETURN_DURATION_MS +
|
||||
TITLE_SCROLL_START_PAUSE_MS
|
||||
const scrollEndOffset = scrollDuration / totalDuration
|
||||
const endPauseOffset = (
|
||||
scrollDuration + TITLE_SCROLL_END_PAUSE_MS
|
||||
) / totalDuration
|
||||
const returnEndOffset = (
|
||||
scrollDuration +
|
||||
TITLE_SCROLL_END_PAUSE_MS +
|
||||
TITLE_SCROLL_RETURN_DURATION_MS
|
||||
) / totalDuration
|
||||
const outboundMaskFadeOffset = Math.min(
|
||||
TITLE_MASK_FADE_DURATION_MS,
|
||||
scrollDuration
|
||||
) / totalDuration
|
||||
const returnMaskFadeOffset = Math.min(
|
||||
TITLE_MASK_FADE_DURATION_MS,
|
||||
TITLE_SCROLL_RETURN_DURATION_MS
|
||||
) / totalDuration
|
||||
const animationOptions: KeyframeAnimationOptions = {
|
||||
delay: TITLE_SCROLL_INITIAL_DELAY_MS,
|
||||
duration: totalDuration,
|
||||
easing: 'linear',
|
||||
fill: 'backwards',
|
||||
iterations: Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
const textAnimation = titleText.animate([
|
||||
{
|
||||
transform: 'translateX(0)',
|
||||
offset: 0
|
||||
},
|
||||
{
|
||||
transform: `translateX(-${overflowDistance}px)`,
|
||||
offset: scrollEndOffset
|
||||
},
|
||||
{
|
||||
transform: `translateX(-${overflowDistance}px)`,
|
||||
offset: endPauseOffset,
|
||||
easing: 'ease-in-out'
|
||||
},
|
||||
{
|
||||
transform: 'translateX(0)',
|
||||
offset: returnEndOffset
|
||||
},
|
||||
{
|
||||
transform: 'translateX(0)',
|
||||
offset: 1
|
||||
}
|
||||
], animationOptions)
|
||||
|
||||
const leftMaskAnimation = titleLeftMask.animate([
|
||||
{ opacity: 0, offset: 0 },
|
||||
{ opacity: 1, offset: outboundMaskFadeOffset },
|
||||
{ opacity: 1, offset: endPauseOffset },
|
||||
{
|
||||
opacity: 1,
|
||||
offset: returnEndOffset - returnMaskFadeOffset
|
||||
},
|
||||
{ opacity: 0, offset: returnEndOffset },
|
||||
{ opacity: 0, offset: 1 }
|
||||
], animationOptions)
|
||||
const rightMaskAnimation = titleRightMask.animate([
|
||||
{ opacity: 1, offset: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
offset: scrollEndOffset - outboundMaskFadeOffset
|
||||
},
|
||||
{ opacity: 0, offset: scrollEndOffset },
|
||||
{ opacity: 0, offset: endPauseOffset },
|
||||
{
|
||||
opacity: 1,
|
||||
offset: endPauseOffset + returnMaskFadeOffset
|
||||
},
|
||||
{ opacity: 1, offset: 1 }
|
||||
], animationOptions)
|
||||
|
||||
titleAnimationsRef.current = [
|
||||
textAnimation,
|
||||
leftMaskAnimation,
|
||||
rightMaskAnimation
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function startEditing(): void {
|
||||
setDraftTitle(title)
|
||||
setEditing(true)
|
||||
@@ -101,6 +265,8 @@ export function SessionListItem({
|
||||
'session-list-item-pinned': isPinned
|
||||
})}
|
||||
style={style}
|
||||
onMouseEnter={startTitleScroll}
|
||||
onMouseLeave={stopTitleScroll}
|
||||
>
|
||||
{editing ? (
|
||||
<Input
|
||||
@@ -126,8 +292,40 @@ export function SessionListItem({
|
||||
event.preventDefault()
|
||||
startEditing()
|
||||
}}
|
||||
onFocus={startTitleScroll}
|
||||
onBlur={stopTitleScroll}
|
||||
>
|
||||
<span className="session-list-item-title">{title}</span>
|
||||
<span
|
||||
ref={titleViewportRef}
|
||||
className="session-list-item-title"
|
||||
>
|
||||
<span className="session-list-item-title-resting">
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
ref={titleTextRef}
|
||||
className="session-list-item-title-text"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
ref={titleLeftMaskRef}
|
||||
className={clsx(
|
||||
'session-list-item-title-mask',
|
||||
'session-list-item-title-mask-left'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
ref={titleRightMaskRef}
|
||||
className={clsx(
|
||||
'session-list-item-title-mask',
|
||||
'session-list-item-title-mask-right'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
{isPinned && (
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
background-color: var(--color-sidebar-background)
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 74%, #000)
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 74%, #000)
|
||||
opacity: 1
|
||||
pointer-events: none
|
||||
transition: background-color var(--transition-duration-sidebar) var(--transition-timing-sidebar)
|
||||
|
||||
&.session-list-mask-hidden
|
||||
opacity: 0
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.session-list
|
||||
transition: none
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type RefObject
|
||||
} from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { sessionIndex, type ConversationSession } from '../../../data/sessions'
|
||||
import { useToast } from '../../../components/toast'
|
||||
@@ -15,6 +16,7 @@ import './session-list.sass'
|
||||
|
||||
const SESSION_ITEM_ESTIMATED_HEIGHT = 41
|
||||
const SESSION_LIST_OVERSCAN = 5
|
||||
const SCROLL_END_TOLERANCE_PX = 1
|
||||
|
||||
interface SessionListProps {
|
||||
collapsed?: boolean
|
||||
@@ -39,6 +41,7 @@ export function SessionList({
|
||||
}: SessionListProps) {
|
||||
const { showToast } = useToast()
|
||||
const virtualListRef = useRef<HTMLUListElement>(null)
|
||||
const [scrollAtEnd, setScrollAtEnd] = useState(false)
|
||||
const [scrollMargin, setScrollMargin] = useState(0)
|
||||
const [sessions, setSessions] = useState<ConversationSession[]>(
|
||||
() => sessionIndex.sessions
|
||||
@@ -100,21 +103,46 @@ export function SessionList({
|
||||
return undefined
|
||||
}
|
||||
|
||||
// The sidebar scroll container starts above this list, so the virtual rows
|
||||
// need the list offset to keep their transforms local to the list element.
|
||||
function updateScrollMargin(): void {
|
||||
setScrollMargin(virtualList?.offsetTop ?? 0)
|
||||
const scrollElement = virtualList.closest<HTMLDivElement>(
|
||||
'.sidebar-scroll-area'
|
||||
)
|
||||
|
||||
if (scrollElement === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
updateScrollMargin()
|
||||
const observedVirtualList = virtualList
|
||||
const observedScrollElement = scrollElement
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateScrollMargin)
|
||||
resizeObserver.observe(virtualList)
|
||||
function updateScrollEndState(): void {
|
||||
setScrollAtEnd(
|
||||
observedScrollElement.scrollHeight -
|
||||
observedScrollElement.scrollTop -
|
||||
observedScrollElement.clientHeight <= SCROLL_END_TOLERANCE_PX
|
||||
)
|
||||
}
|
||||
|
||||
// The sidebar scroll container starts above this list, so the virtual rows
|
||||
// need the list offset to keep their transforms local to the list element.
|
||||
function updateLayout(): void {
|
||||
setScrollMargin(observedVirtualList.offsetTop)
|
||||
updateScrollEndState()
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateLayout)
|
||||
resizeObserver.observe(observedVirtualList)
|
||||
resizeObserver.observe(observedScrollElement)
|
||||
observedScrollElement.addEventListener('scroll', updateScrollEndState, {
|
||||
passive: true
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
observedScrollElement.removeEventListener('scroll', updateScrollEndState)
|
||||
}
|
||||
}, [])
|
||||
}, [collapsed])
|
||||
|
||||
if (collapsed) {
|
||||
return null
|
||||
@@ -153,7 +181,12 @@ export function SessionList({
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="session-list-mask" aria-hidden="true" />
|
||||
<div
|
||||
className={clsx('session-list-mask', {
|
||||
'session-list-mask-hidden': scrollAtEnd
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,24 @@
|
||||
&.sidebar-scroll-area-scrolled
|
||||
box-shadow: inset 0 1px 0 var(--color-border-secondary)
|
||||
|
||||
.sidebar-scroll-area-mask
|
||||
opacity: 1
|
||||
|
||||
.sidebar-scroll-area-mask
|
||||
position: sticky
|
||||
top: 0
|
||||
z-index: 2
|
||||
display: block
|
||||
width: 100%
|
||||
height: 54px
|
||||
margin-bottom: -54px
|
||||
background-color: var(--color-sidebar-background)
|
||||
mask-image: linear-gradient(to bottom, #000, #000 26%, transparent)
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000, #000 26%, transparent)
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
transition: background-color var(--transition-duration-sidebar) var(--transition-timing-sidebar)
|
||||
|
||||
&:hover
|
||||
scrollbar-color: var(--color-scrollbar-thumb) transparent
|
||||
|
||||
@@ -122,6 +140,9 @@
|
||||
&::-webkit-scrollbar
|
||||
display: none
|
||||
|
||||
.sidebar-scroll-area-mask
|
||||
display: none
|
||||
|
||||
.sidebar-logo-slot:hover,
|
||||
.sidebar-logo-slot:focus-within
|
||||
.logo
|
||||
@@ -169,6 +190,9 @@
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
|
||||
.sidebar-scroll-area-mask
|
||||
opacity: 0
|
||||
|
||||
@media (prefers-reduced-motion: reduce)
|
||||
.sidebar,
|
||||
.sidebar .sidebar-header,
|
||||
@@ -176,5 +200,6 @@
|
||||
.sidebar .sidebar-logo-open-button,
|
||||
.sidebar .sidebar-controls,
|
||||
.sidebar .sidebar-scroll-area,
|
||||
.sidebar .sidebar-scroll-area-mask,
|
||||
.sidebar .sidebar-collapsed-unfold-hit
|
||||
transition: none
|
||||
|
||||
@@ -203,6 +203,7 @@ export function Sidebar() {
|
||||
ref={sidebarScrollAreaRef}
|
||||
onScroll={handleSidebarScroll}
|
||||
>
|
||||
<div className="sidebar-scroll-area-mask" aria-hidden="true" />
|
||||
<Menu collapsed={sidebarContentCollapsed} variant="scrollable" />
|
||||
<SessionList
|
||||
collapsed={sidebarContentCollapsed}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
--color-text-secondary: #BEBEBE
|
||||
--color-text-tertiary: rgba(245, 245, 247, .33)
|
||||
--color-text-disabled: rgba(245, 245, 247, .33)
|
||||
--color-activity-wave: #151515
|
||||
--color-input-border: rgba(245, 245, 247, .2)
|
||||
--color-primary-button: var(--color-text)
|
||||
--color-primary-button-hover: #E7E7EA
|
||||
@@ -46,6 +47,8 @@
|
||||
--color-overlay: rgba(0, 0, 0, .48)
|
||||
--color-overlay-soft: rgba(0, 0, 0, .1)
|
||||
--color-sidebar-background: rgba(0, 0, 0, .96)
|
||||
--color-sidebar-background-hover: #191919
|
||||
--color-sidebar-background-active: #202020
|
||||
--color-scrollbar-thumb-muted: rgba(245, 245, 247, .1)
|
||||
--color-scrollbar-thumb: rgba(245, 245, 247, .2)
|
||||
--color-scrollbar-thumb-hover: rgba(245, 245, 247, .33)
|
||||
@@ -54,6 +57,7 @@
|
||||
--gradient-vibe-b: linear-gradient(180deg, rgba(255, 246, 152, .18), rgba(28, 117, 219, .2))
|
||||
--gradient-query: linear-gradient(100deg, rgba(167, 179, 200, .4), rgba(28, 117, 219, .3), rgba(255, 117, 174, .22))
|
||||
--gradient-border: linear-gradient(to bottom, #666, var(--color-surface))
|
||||
--gradient-owner-message-border: linear-gradient(to bottom, #84BDFF 0%, rgba(245, 245, 247, .1) 100%)
|
||||
--shortcut-shadow-contrast: drop-shadow(0 2px 0 #000)
|
||||
|
||||
[data-theme='light']
|
||||
@@ -75,6 +79,7 @@
|
||||
--color-text-secondary: #555
|
||||
--color-text-tertiary: rgba(21, 21, 21, .5)
|
||||
--color-text-disabled: rgba(21, 21, 21, .33)
|
||||
--color-activity-wave: #F5F5F7
|
||||
--color-input-border: rgba(21, 21, 21, .2)
|
||||
--color-primary-button: var(--color-text)
|
||||
--color-primary-button-hover: #242424
|
||||
@@ -98,11 +103,13 @@
|
||||
--color-danger-soft: rgba(216, 15, 15, .12)
|
||||
--color-danger-active: rgba(216, 15, 15, .22)
|
||||
--color-pink: #FF75AE
|
||||
--color-yellow: #FFF698
|
||||
--color-yellow: #9A6B00
|
||||
--color-purple: #AB73FF
|
||||
--color-overlay: rgba(0, 0, 0, .48)
|
||||
--color-overlay-soft: rgba(0, 0, 0, .1)
|
||||
--color-sidebar-background: rgba(255, 255, 255, .96)
|
||||
--color-sidebar-background-hover: #E6E6E6
|
||||
--color-sidebar-background-active: #DEDEDE
|
||||
--color-scrollbar-thumb-muted: rgba(0, 0, 0, .1)
|
||||
--color-scrollbar-thumb: rgba(0, 0, 0, .2)
|
||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, .33)
|
||||
@@ -111,4 +118,5 @@
|
||||
--gradient-vibe-b: linear-gradient(180deg, rgba(255, 246, 152, .28), rgba(28, 117, 219, .14))
|
||||
--gradient-query: linear-gradient(100deg, rgba(28, 117, 219, .26), rgba(255, 117, 174, .16))
|
||||
--gradient-border: linear-gradient(to bottom, #BBB, var(--color-surface))
|
||||
--gradient-owner-message-border: linear-gradient(to bottom, #0050a8 0%, rgba(21, 21, 21, .18) 100%)
|
||||
--shortcut-shadow-contrast: drop-shadow(0 2px 0 #BBB)
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
*/
|
||||
|
||||
--transition-duration-main: .2s
|
||||
--transition-duration-collapse: .14s
|
||||
--transition-duration-sidebar: .55s
|
||||
--transition-timing-main: ease-in-out
|
||||
--transition-timing-sidebar: cubic-bezier(.2, .8, .2, 1)
|
||||
--activity-animation-duration: 2600ms
|
||||
|
||||
Reference in New Issue
Block a user