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
4 changes: 4 additions & 0 deletions wework/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@ recipe closely:
Short threads therefore fill the viewport while long and virtualized threads
grow naturally. Keep bottom following stable across delayed virtual
measurements, but stop following immediately after an explicit user scroll.
- When guidance or another runtime event inserts, removes, or reorders messages
inside a virtualized thread, remeasure mounted rows from the first changed
index. The virtual container must include every rendered row so no message can
appear below or behind the sticky composer.

The Composer is not a green brand block, a thick outlined form, or a card with
an exaggerated shadow.
Expand Down
14 changes: 14 additions & 0 deletions wework/e2e/desktop/modules/conversation-navigation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,20 @@ async function verifyForegroundGuidanceScroll({ composerSelector, control, retur
scroller,
})}`
)
const composerMetrics = JSON.parse(
await control.command(
'getElementMetrics',
`${ACTIVE_WORKBENCH_SELECTOR} [data-testid="desktop-floating-composer-card"]`
)
).at(-1)
assert.ok(composerMetrics, 'The conversation composer was not rendered after guidance')
assert.ok(
guidanceMessage.bottom <= composerMetrics.top + 2,
`The newly applied guidance message rendered below the composer: ${JSON.stringify({
guidanceMessage,
composer: composerMetrics,
})}`
)
await captureVerificationScreenshot(control, 'guidance-scroll-01-message-visible.png')

control.releaseGuidanceScrollCompletion()
Expand Down
28 changes: 28 additions & 0 deletions wework/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export const MessageList = memo(function MessageList({
const { t } = useTranslation('common')
const listRef = useRef<HTMLDivElement>(null)
const layoutWidthUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previousVisibleMessageIdsRef = useRef<string[]>([])
const [isTextSelectionActive, setIsTextSelectionActive] = useState(false)
const [textSelection, setTextSelection] = useState<MessageTextSelection | null>(null)
const [layoutWidth, setLayoutWidth] = useState(0)
Expand Down Expand Up @@ -338,6 +339,33 @@ export const MessageList = memo(function MessageList({
onVirtualLayoutChange?.()
}, [onVirtualLayoutChange, virtualMessages, virtualTotalSize])

useLayoutEffect(() => {
const previousIds = previousVisibleMessageIdsRef.current
const nextIds = visibleMessages.map(message => message.id)
previousVisibleMessageIdsRef.current = nextIds
if (!virtualMessages || previousIds.length === 0) return

const firstDifferentIndex = nextIds.findIndex((id, index) => previousIds[index] !== id)
if (firstDifferentIndex < 0 && previousIds.length === nextIds.length) return
const changedIndex =
firstDifferentIndex >= 0 ? firstDifferentIndex : Math.min(previousIds.length, nextIds.length)

const virtualItemsByIndex = new Map(
messageVirtualizer.getVirtualItems().map(item => [item.index, item])
)
listRef.current?.querySelectorAll<HTMLElement>('[data-index]').forEach(row => {
const index = Number(row.dataset.index)
if (!Number.isInteger(index) || index < changedIndex) return

const virtualItem = virtualItemsByIndex.get(index)
if (!virtualItem) return
const measuredSize = Math.ceil(row.getBoundingClientRect().height)
if (virtualItem.size !== measuredSize) {
messageVirtualizer.resizeItem(index, measuredSize)
}
})
}, [messageVirtualizer, virtualMessages, visibleMessages])

useLayoutEffect(() => {
if (
!virtualMessages ||
Expand Down
45 changes: 45 additions & 0 deletions wework/src/components/chat/MessageList.virtualization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ vi.mock('@tanstack/react-virtual', () => ({
index,
key: options.getItemKey(index),
start: index * 120,
size: 100,
end: index * 120 + 100,
})),
measureElement: measureElementMock,
resizeItem: resizeItemMock,
Expand Down Expand Up @@ -198,6 +200,49 @@ describe('MessageList Tauri virtualization', () => {
)
})

test('remeasures mounted rows after guidance changes the message sequence', () => {
const messages = buildMessages(3, 'guidance-layout')
const guidanceMessage = {
...buildMessages(1, 'mid-turn-guidance')[0],
id: 'guidance-message',
runtimeGuidance: true,
}
const props = {
messages: [messages[0], guidanceMessage, messages[2]],
scrollElementRef: { current: createScrollElement(200) },
}
const view = render(<MessageList {...props} />)
const guidanceRow = screen.getByText('mid-turn-guidance message 0').closest('[data-index]')
expect(guidanceRow).not.toBeNull()
vi.spyOn(guidanceRow!, 'getBoundingClientRect').mockReturnValue({
bottom: 180,
height: 180,
left: 0,
right: 800,
top: 0,
width: 800,
x: 0,
y: 0,
toJSON: () => ({}),
})
resizeItemMock.mockClear()

const assistantContinuation = {
...buildMessages(1, 'assistant-continuation')[0],
id: 'assistant-continuation',
role: 'assistant' as const,
status: 'streaming' as const,
}
view.rerender(
<MessageList
{...props}
messages={[messages[0], assistantContinuation, guidanceMessage, messages[2]]}
/>
)

expect(resizeItemMock).toHaveBeenCalledWith(2, 180)
})

test('deduplicates a streaming message that is also a forced navigation target', () => {
const messages = buildMessages(100, 'streaming-navigation')
messages[80] = {
Expand Down
Loading