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:
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user