feat(01-04): add TanStack Virtual list virtualization and keyboard navigation hook

- Install @tanstack/react-virtual for virtualized list rendering
- Create useRovingTabIndex hook in src/renderer/lib/keyboard.ts for arrow-key navigation
- Add virtualization to 6 lists: TaskQueue, ApprovalList, ActivityList, CostList, AgentRoster, MessageList
- MessageList uses measureElement for variable-height message items with auto-scroll
- All 6 lists have role="listbox" with role="option" on rows for accessibility
- All 6 lists support ArrowDown/ArrowUp/j/k/Home/End keyboard navigation
- Add 8 unit tests for useRovingTabIndex covering all key bindings and edge cases
This commit is contained in:
ZacharyZhang-NY committed 2026-03-22 02:08:43 -04:00
1 parent 7ff8dc70a2
commit d77486504d
10 files changed
+761 -175

No files matched your search

@@ -1,9 +1,11 @@
import { useRef, useEffect, useMemo } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { ChatCircle, Tray } from "@phosphor-icons/react"
import type { AgentMessageRecord, AgentRecord, CommentRecord, TaskRecord } from "@shared/types"
import { MessageBubble, TaskCommentBubble } from "./MessageItem"
import type { AnyChannel } from "./ChannelHeader"
import { useT } from "../../i18n"
import { useRovingTabIndex } from "../../lib/keyboard"
interface FilterState {
search: string
@@ -36,11 +38,7 @@ export function MessageList({
selectedChannelKey,
}: MessageListProps) {
const t = useT()
const messageEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
}, [selectedChannelKey, messageCount])
const parentRef = useRef<HTMLDivElement>(null)
const threadedMessages = useMemo(() => {
const topLevel = filteredMessages.filter((m) => !m.parentMessageId)
@@ -55,6 +53,42 @@ export function MessageList({
return { topLevel, replyMap }
}, [filteredMessages])
// Flatten for virtualization: each top-level message + its replies
const flatItems = useMemo(() => {
const items: Array<{ type: "message"; message: AgentMessageRecord; isThread: boolean; replyCount: number } | { type: "comment"; comment: CommentRecord }> = []
if (activeChannel?.kind === "task") {
for (const comment of taskDiscussionComments) {
items.push({ type: "comment", comment })
}
} else {
for (const msg of threadedMessages.topLevel) {
const replies = threadedMessages.replyMap.get(msg.id) ?? []
items.push({ type: "message", message: msg, isThread: false, replyCount: replies.length })
for (const reply of replies) {
items.push({ type: "message", message: reply, isThread: true, replyCount: 0 })
}
}
}
return items
}, [activeChannel?.kind, taskDiscussionComments, threadedMessages])
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(flatItems.length)
const virtualizer = useVirtualizer({
count: flatItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 10,
})
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (flatItems.length > 0) {
virtualizer.scrollToIndex(flatItems.length - 1, { align: "end" })
}
}, [selectedChannelKey, messageCount, flatItems.length, virtualizer])
if (!activeChannel) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
@@ -69,35 +103,21 @@ export function MessageList({
)
}
if (activeChannel.kind === "task") {
if (taskDiscussionComments.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">
{t("comm.noDiscussionYet")}
</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
{t("comm.commentsAppearHere")}
</div>
</div>
)
}
if (activeChannel.kind === "task" && taskDiscussionComments.length === 0) {
return (
<div className="space-y-1">
{taskDiscussionComments.map((comment) => (
<TaskCommentBubble
key={comment.id}
comment={comment}
agent={comment.authorAgentId ? (agentMap.get(comment.authorAgentId) ?? undefined) : undefined}
/>
))}
<div ref={messageEndRef} />
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">
{t("comm.noDiscussionYet")}
</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
{t("comm.commentsAppearHere")}
</div>
</div>
)
}
if (filteredMessages.length === 0) {
if (activeChannel.kind !== "task" && filteredMessages.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -116,36 +136,57 @@ export function MessageList({
}
return (
<div className="space-y-1">
{threadedMessages.topLevel.map((msg) => {
const replies = threadedMessages.replyMap.get(msg.id) ?? []
return (
<div key={msg.id}>
<MessageBubble
message={msg}
agent={agentMap.get(msg.fromAgentId)}
isThread={false}
replyCount={replies.length}
onReply={() => onReply(msg)}
/>
{replies.length > 0 ? (
<div className="space-y-0.5">
{replies.map((reply) => (
<MessageBubble
key={reply.id}
message={reply}
agent={agentMap.get(reply.fromAgentId)}
isThread={true}
replyCount={0}
onReply={() => onReply(reply)}
/>
))}
</div>
) : null}
</div>
)
})}
<div ref={messageEndRef} />
<div
ref={parentRef}
className="h-full overflow-auto"
role="listbox"
aria-label="Messages"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = flatItems[virtualRow.index]
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={false}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className={`focus-ring outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
>
{item.type === "comment" ? (
<TaskCommentBubble
comment={item.comment}
agent={item.comment.authorAgentId ? (agentMap.get(item.comment.authorAgentId) ?? undefined) : undefined}
/>
) : (
<MessageBubble
message={item.message}
agent={agentMap.get(item.message.fromAgentId)}
isThread={item.isThread}
replyCount={item.replyCount}
onReply={() => onReply(item.message)}
/>
)}
</div>
)
})}
</div>
</div>
)
}