Merge branch 'feat/web-app-2-0' into develop

This commit is contained in:
Louistiti
2026-08-20 22:27:28 +08:00
37 changed files with 1024 additions and 552 deletions
@@ -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>
)
}
+1
View File
@@ -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'
+12 -4
View File
@@ -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
+34 -10
View File
@@ -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="Leons 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="Leons 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="Leons 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="Leons 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="Leons 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: ''
+51 -29
View File
@@ -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
+26 -15
View File
@@ -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>
)
}
+82 -25
View File
@@ -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
}
}
+30 -5
View File
@@ -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>
)
}
+25
View File
@@ -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
+1
View File
@@ -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}
+9 -1
View File
@@ -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