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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './timeline-data'
import { activeTimelineIndex, deriveTimelineEntries, sameTimelineEntries, timelinePreview } from './timeline-data'

describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
Expand Down Expand Up @@ -39,6 +39,33 @@ describe('deriveTimelineEntries', () => {
})
})

describe('sameTimelineEntries', () => {
const rail = [
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
]

it('treats an identical derivation as unchanged, so the memo can reuse it', () => {
expect(sameTimelineEntries(rail, [...rail.map(e => ({ ...e }))])).toBe(true)
})

it('detects a changed preview, id, or length', () => {
expect(sameTimelineEntries(rail, [rail[0], { id: 'u2', preview: 'edited' }])).toBe(false)
expect(sameTimelineEntries(rail, [rail[0], { id: 'u9', preview: 'second' }])).toBe(false)
expect(sameTimelineEntries(rail, [rail[0]])).toBe(false)
})

it('is stable when a filtered-out prompt joins the transcript', () => {
const withNoise = deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'u2', role: 'user', text: 'second' },
{ id: 'u3', role: 'user', text: '[IMPORTANT: Background process 7 finished]' }
])

expect(sameTimelineEntries(rail, withNoise)).toBe(true)
})
})

describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/components/assistant-ui/thread/timeline-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]
return entries
}

/** Do two derivations describe the same rail? Lets a rebuild hand back the
* PREVIOUS array so an unchanged transcript costs zero re-renders. */
export function sameTimelineEntries(a: readonly TimelineEntry[], b: readonly TimelineEntry[]): boolean {
if (a === b) {
return true
}

if (a.length !== b.length) {
return false
}

return a.every((entry, index) => entry.id === b[index].id && entry.preview === b[index].preview)
}

/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
Expand Down
166 changes: 166 additions & 0 deletions apps/desktop/src/components/assistant-ui/thread/timeline-idle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

/**
* The timeline must do NO work it can't currently show. Two gates are proven
* here by rendering the real component and counting the work it performs:
*
* - a background (kept-alive but hidden) tab derives nothing and subscribes
* to nothing — the transcript selector is never even called;
* - an unhovered rail builds its ticks but not the popover's rows.
*
* The prompt-id selector is also asserted to be content-blind, which is what
* keeps a streaming assistant reply from re-deriving previews per token.
*/

interface FakeMessage {
content: unknown
id: string
role: string
}

const selectorCalls = vi.fn()
const transcriptReads = vi.fn()
let messages: FakeMessage[] = []

vi.mock('@assistant-ui/react', () => ({
useAui: () => ({
thread: () => ({
getState: () => {
transcriptReads()

return { messages }
}
})
}),
useAuiState: (selector: (state: { thread: { messages: FakeMessage[] } }) => unknown) => {
selectorCalls()

return selector({ thread: { messages } })
}
}))

let paneActive = true

vi.mock('@/components/pane-shell/pane-visibility', () => ({
usePaneVisible: () => paneActive
}))

vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} }))

const { ThreadTimeline } = await import('./timeline')

const userTurn = (id: string, text: string): FakeMessage => ({
content: [{ text, type: 'text' }],
id,
role: 'user'
})

const transcript = (count: number): FakeMessage[] =>
Array.from({ length: count }, (_, i) => userTurn(`u${i}`, `prompt ${i}`))

const renderTimeline = (ui: ReactNode = <ThreadTimeline />) => render(ui)

afterEach(() => {
cleanup()
selectorCalls.mockClear()
transcriptReads.mockClear()
paneActive = true
messages = []
})

describe('ThreadTimeline in a background tab', () => {
it('renders nothing and never reads the transcript', () => {
paneActive = false
messages = transcript(6)

const { container } = renderTimeline()

expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
expect(selectorCalls).not.toHaveBeenCalled()
expect(transcriptReads).not.toHaveBeenCalled()
})

it('renders the rail once its pane becomes the visible tab', () => {
messages = transcript(6)

const { container } = renderTimeline()

expect(container.querySelector('[data-slot="thread-timeline"]')).not.toBeNull()
expect(selectorCalls).toHaveBeenCalled()
})
})

describe('ThreadTimeline popover', () => {
it('builds no rows until the rail is hovered', () => {
messages = transcript(6)

const { container } = renderTimeline()
const popover = container.querySelector('[data-slot="thread-timeline-popover"]')

// The shell renders (it owns the fade transition); its rows do not.
expect(popover).not.toBeNull()
expect(popover?.querySelectorAll('button')).toHaveLength(0)
expect(screen.queryByText('prompt 0')).toBeNull()
})

it('builds the rows on hover and keeps them for the close fade', () => {
messages = transcript(6)

const { container } = renderTimeline()
const rail = container.querySelector<HTMLElement>('[data-slot="thread-timeline"]')!

fireEvent.mouseEnter(rail)

const popover = container.querySelector('[data-slot="thread-timeline-popover"]')
expect(popover?.querySelectorAll('button')).toHaveLength(6)

fireEvent.mouseLeave(rail)

// Still mounted — the popover fades out, it does not pop out of existence.
expect(popover?.querySelectorAll('button')).toHaveLength(6)
})
})

describe('ThreadTimeline below the threshold', () => {
it('renders nothing for a short thread', () => {
messages = transcript(2)

const { container } = renderTimeline()

expect(container.querySelector('[data-slot="thread-timeline"]')).toBeNull()
})
})

describe('ThreadTimeline while a reply streams', () => {
it('does not re-derive the rail as assistant content grows', () => {
messages = [...transcript(6), { content: [{ text: 'th', type: 'text' }], id: 'a1', role: 'assistant' }]

const { rerender } = renderTimeline()
const derivations = transcriptReads.mock.calls.length

// A token lands: the assistant message's content changes, the user prompt
// ids do not — so the memo's change signal is untouched and the previews
// are never rebuilt.
messages = [
...messages.slice(0, -1),
{ content: [{ text: 'thinking…', type: 'text' }], id: 'a1', role: 'assistant' }
]
rerender(<ThreadTimeline />)

expect(transcriptReads.mock.calls.length).toBe(derivations)
})

it('re-derives once a new prompt is sent', () => {
messages = transcript(6)

const { rerender } = renderTimeline()
const derivations = transcriptReads.mock.calls.length

messages = [...messages, userTurn('u6', 'prompt 6')]
rerender(<ThreadTimeline />)

expect(transcriptReads.mock.calls.length).toBeGreaterThan(derivations)
})
})
Loading
Loading