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>
)
}
@@ -1,21 +1,42 @@
import { useMemo, useState } from "react"
import { ArrowRight, Lightning, MagnifyingGlass } from "@phosphor-icons/react"
import { useMemo, useRef, useState } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Lightning, MagnifyingGlass } from "@phosphor-icons/react"
import type { ActivityRecord } from "@shared/types"
import { useT } from "../../i18n"
import { formatTime } from "../../lib/formatters"
import { ActionButton, EmptyState, ListFrame, ListRow, Select } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, Select } from "../ui"
import { ENTITY_ICONS, ENTITY_TYPE_OPTIONS } from "./execution-utils"
export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
const t = useT()
const [search, setSearch] = useState(""); const [entityFilter, setEntityFilter] = useState<string>("all"); const [visibleCount, setVisibleCount] = useState(100)
const [search, setSearch] = useState("")
const [entityFilter, setEntityFilter] = useState<string>("all")
const parentRef = useRef<HTMLDivElement>(null)
const filtered = useMemo(() => {
let result = activity
if (entityFilter !== "all") result = result.filter((e) => e.entityType === entityFilter)
if (search.trim()) { const q = search.toLowerCase(); result = result.filter((e) => e.action.toLowerCase().includes(q) || e.actor.toLowerCase().includes(q) || e.detail.toLowerCase().includes(q) || e.entityType.toLowerCase().includes(q)) }
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter((e) =>
e.action.toLowerCase().includes(q) ||
e.actor.toLowerCase().includes(q) ||
e.detail.toLowerCase().includes(q) ||
e.entityType.toLowerCase().includes(q),
)
}
return result
}, [activity, entityFilter, search])
const visible = filtered.slice(0, visibleCount)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(filtered.length)
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 15,
})
if (activity.length === 0) return <EmptyState title={t("exec.noActivityYet")} />
return (
@@ -27,24 +48,58 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
</div>
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
</div>
{visible.length === 0 ? <EmptyState title={t("exec.noMatchingActivity")} /> : (
<>
<ListFrame>
{visible.map((entry) => {
{filtered.length === 0 ? <EmptyState title={t("exec.noMatchingActivity")} /> : (
<div
ref={parentRef}
className="h-full max-h-[600px] overflow-auto border-y border-[color:var(--line)]"
role="listbox"
aria-label="Activity log"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const entry = filtered[virtualRow.index]
const EntityIcon = ENTITY_ICONS[entry.entityType] ?? Lightning
return (
<ListRow key={entry.id}>
<div
key={entry.id}
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 border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2"><EntityIcon size={14} weight="duotone" className="text-[color:var(--muted)]" /><div className="text-[14px] text-[color:var(--text)]">{entry.action}</div></div>
<div className="flex items-center gap-2">
<EntityIcon size={14} weight="duotone" className="text-[color:var(--muted)]" />
<div className="text-[14px] text-[color:var(--text)]">{entry.action}</div>
</div>
<div className="mono text-[11px] text-[color:var(--muted)]">{formatTime(entry.createdAt)}</div>
</div>
<div className="flex items-center gap-2 text-[13px] text-[color:var(--muted)]"><span className="font-medium text-[color:var(--muted-strong)]">{entry.actor}</span><span>·</span><span>{entry.detail || `${entry.entityType}:${entry.entityId}`}</span></div>
</ListRow>
<div className="flex items-center gap-2 text-[13px] text-[color:var(--muted)]">
<span className="font-medium text-[color:var(--muted-strong)]">{entry.actor}</span>
<span>·</span>
<span>{entry.detail || `${entry.entityType}:${entry.entityId}`}</span>
</div>
</div>
)
})}
</ListFrame>
{visibleCount < filtered.length ? <div className="flex justify-center pt-2"><ActionButton label={`Load more (${filtered.length - visibleCount} remaining)`} onClick={() => setVisibleCount((c) => c + 100)} icon={ArrowRight} /></div> : null}
</>
</div>
</div>
)}
</div>
)
@@ -1,21 +1,72 @@
import { useRef } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { ApprovalRecord } from "@shared/types"
import { useT } from "../../i18n"
import { EmptyState, ListFrame, StatusPill } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, StatusPill } from "../ui"
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(approvals.length)
const virtualizer = useVirtualizer({
count: approvals.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
})
if (approvals.length === 0) return <EmptyState title={t("exec.noApprovals")} />
return (
<ListFrame>
{approvals.map((approval) => (
<button key={approval.id} className={`focus-ring relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedApprovalId === approval.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`} onClick={() => onSelect(approval.id)}>
{selectedApprovalId === approval.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--warn)]" /> : null}
<div className="flex items-center justify-between gap-4">
<div className={`text-[15px] text-[color:var(--text)] ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} />
</div>
</button>
))}
</ListFrame>
<div
ref={parentRef}
className="h-full overflow-auto border-y border-[color:var(--line)]"
role="listbox"
aria-label={t("exec.noApprovals")}
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const approval = approvals[virtualRow.index]
return (
<div
key={approval.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={approval.id === selectedApprovalId}
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" : ""}`}
>
<button
className={`relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedApprovalId === approval.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`}
onClick={() => onSelect(approval.id)}
tabIndex={-1}
>
{selectedApprovalId === approval.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--warn)]" /> : null}
<div className="flex items-center justify-between gap-4">
<div className={`text-[15px] text-[color:var(--text)] ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} />
</div>
</button>
</div>
)
})}
</div>
</div>
)
}
+97 -22
View File
@@ -1,38 +1,113 @@
import { useMemo, useState } from "react"
import { useMemo, useRef, useState } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { ProfileSnapshot } from "@shared/types"
import { useT } from "../../i18n"
import { formatMoney } from "../../lib/formatters"
import { EmptyState, ListFrame, ListRow, Select } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, Select } from "../ui"
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
const t = useT()
const [connectorFilter, setConnectorFilter] = useState("all")
const connectorIds = useMemo(() => { const ids = new Set(costs.map((c) => c.connectorId)); return Array.from(ids).sort() }, [costs])
const parentRef = useRef<HTMLDivElement>(null)
const connectorIds = useMemo(() => {
const ids = new Set(costs.map((c) => c.connectorId))
return Array.from(ids).sort()
}, [costs])
const filtered = useMemo(() => connectorFilter === "all" ? costs : costs.filter((c) => c.connectorId === connectorFilter), [costs, connectorFilter])
const total = useMemo(() => filtered.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [filtered])
// +1 for the total row at the bottom
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(filtered.length + 1)
const virtualizer = useVirtualizer({
count: filtered.length + 1, // +1 for total row
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 10,
})
if (costs.length === 0) return <EmptyState title={t("exec.noCostData")} />
return (
<div className="space-y-3">
<div className="flex items-center gap-3"><Select value={connectorFilter} onChange={setConnectorFilter} options={[{ value: "all", label: t("exec.allConnectors") }, ...connectorIds.map((id) => ({ value: id, label: id }))]} /></div>
<ListFrame>
{filtered.map((entry) => (
<ListRow key={entry.id} className="flex items-center justify-between">
<div>
<div className="text-[14px] text-[color:var(--text)]">{runs.find((run) => run.id === entry.runId)?.summary || "Run cost"}</div>
<div className="text-[12px] text-[color:var(--muted)]">{agents.find((a) => a.id === entry.agentId)?.name ?? "Unknown agent"} · {entry.connectorId}</div>
</div>
<div className="text-right">
<div className="mono text-[13px] text-[color:var(--text)]">{formatMoney(entry.amountUsd)}</div>
{entry.unattributed ? <div className="text-[11px] text-[color:var(--warn)]">unattributed</div> : null}
</div>
</ListRow>
))}
<ListRow className="flex items-center justify-between">
<div className="text-[14px] font-semibold text-[color:var(--text)]">Total ({filtered.length} entries)</div>
<div className="mono text-[14px] font-semibold text-[color:var(--text)]">{formatMoney(total)}</div>
</ListRow>
</ListFrame>
<div className="flex items-center gap-3">
<Select value={connectorFilter} onChange={setConnectorFilter} options={[{ value: "all", label: t("exec.allConnectors") }, ...connectorIds.map((id) => ({ value: id, label: id }))]} />
</div>
<div
ref={parentRef}
className="h-full max-h-[600px] overflow-auto border-y border-[color:var(--line)]"
role="listbox"
aria-label="Cost entries"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isTotal = virtualRow.index === filtered.length
if (isTotal) {
return (
<div
key="__total__"
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 border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
>
<div className="flex items-center justify-between">
<div className="text-[14px] font-semibold text-[color:var(--text)]">Total ({filtered.length} entries)</div>
<div className="mono text-[14px] font-semibold text-[color:var(--text)]">{formatMoney(total)}</div>
</div>
</div>
)
}
const entry = filtered[virtualRow.index]
return (
<div
key={entry.id}
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 border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
>
<div className="flex items-center justify-between">
<div>
<div className="text-[14px] text-[color:var(--text)]">{runs.find((run) => run.id === entry.runId)?.summary || "Run cost"}</div>
<div className="text-[12px] text-[color:var(--muted)]">{agents.find((a) => a.id === entry.agentId)?.name ?? "Unknown agent"} · {entry.connectorId}</div>
</div>
<div className="text-right">
<div className="mono text-[13px] text-[color:var(--text)]">{formatMoney(entry.amountUsd)}</div>
{entry.unattributed ? <div className="text-[11px] text-[color:var(--warn)]">unattributed</div> : null}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
+66 -17
View File
@@ -1,28 +1,77 @@
import { useRef } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { RunRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n"
import { EmptyState, ListFrame, StatusPill } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, StatusPill } from "../ui"
import { PriorityDot } from "./execution-utils"
export function TaskQueue({ tasks, runs, selectedTaskId, onSelect }: { tasks: TaskRecord[]; runs: RunRecord[]; selectedTaskId: string | null; onSelect: (taskId: string) => void }) {
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(tasks.length)
const virtualizer = useVirtualizer({
count: tasks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
})
if (tasks.length === 0) return <EmptyState title={t("exec.taskQueueEmpty")} />
return (
<ListFrame>
{tasks.map((task) => {
const activeRun = runs.find((run) => run.id === task.activeRunId) ?? null
return (
<button key={task.id} className={`focus-ring relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedTaskId === task.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`} onClick={() => onSelect(task.id)}>
{selectedTaskId === task.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--accent)]" /> : null}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2.5">
<PriorityDot priority={task.priority} />
<div className={`truncate text-[14px] text-[color:var(--text)] ${selectedTaskId === task.id ? "font-semibold" : "font-medium"}`}>{task.title}</div>
</div>
<StatusPill status={activeRun?.status ?? task.status} />
<div
ref={parentRef}
className="h-full overflow-auto border-y border-[color:var(--line)]"
role="listbox"
aria-label={t("exec.taskQueueEmpty")}
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const task = tasks[virtualRow.index]
const activeRun = runs.find((run) => run.id === task.activeRunId) ?? null
return (
<div
key={task.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={task.id === selectedTaskId}
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" : ""}`}
>
<button
className={`relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedTaskId === task.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`}
onClick={() => onSelect(task.id)}
tabIndex={-1}
>
{selectedTaskId === task.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--accent)]" /> : null}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2.5">
<PriorityDot priority={task.priority} />
<div className={`truncate text-[14px] text-[color:var(--text)] ${selectedTaskId === task.id ? "font-semibold" : "font-medium"}`}>{task.title}</div>
</div>
<StatusPill status={activeRun?.status ?? task.status} />
</div>
</button>
</div>
</button>
)
})}
</ListFrame>
)
})}
</div>
</div>
)
}
@@ -1,8 +1,11 @@
import { CheckCircle } from "@phosphor-icons/react";
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { EmptyState, ListFrame, ListRow, StatusPill } from "../ui";
import { useT } from "../../i18n";
import { BudgetBar } from "./helpers";
import { useRef } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { CheckCircle } from "@phosphor-icons/react"
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types"
import { EmptyState, StatusPill } from "../ui"
import { useT } from "../../i18n"
import { useRovingTabIndex } from "../../lib/keyboard"
import { BudgetBar } from "./helpers"
export function AgentRoster({
agents,
@@ -11,54 +14,99 @@ export function AgentRoster({
selectedAgentId,
onSelect,
}: {
agents: AgentRecord[];
connectors: ConnectorRecord[];
workspaces: WorkspaceRecord[];
selectedAgentId: string | null;
onSelect: (agentId: string) => void;
agents: AgentRecord[]
connectors: ConnectorRecord[]
workspaces: WorkspaceRecord[]
selectedAgentId: string | null
onSelect: (agentId: string) => void
}) {
const t = useT();
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(agents.length)
const virtualizer = useVirtualizer({
count: agents.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 10,
})
if (agents.length === 0) {
return <EmptyState title={t("org.noAgentsYet")} />;
return <EmptyState title={t("org.noAgentsYet")} />
}
return (
<ListFrame>
{agents.map((agent) => {
const connector = connectors.find((c) => c.id === agent.connectorId);
const workspace = agent.workspaceId
? workspaces.find((w) => w.id === agent.workspaceId)
: null;
const isSelected = agent.id === selectedAgentId;
return (
<ListRow
key={agent.id}
onClick={() => onSelect(agent.id)}
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isSelected && (
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" />
)}
<div>
<div className="text-[15px] text-[color:var(--text)]">{agent.name}</div>
<div className="text-[12px] text-[color:var(--muted)]">
{agent.role} · {agent.title || "No title"}
<div
ref={parentRef}
className="h-full overflow-auto border-y border-[color:var(--line)]"
role="listbox"
aria-label="Agent roster"
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const agent = agents[virtualRow.index]
const connector = connectors.find((c) => c.id === agent.connectorId)
const workspace = agent.workspaceId
? workspaces.find((w) => w.id === agent.workspaceId)
: null
const isSelected = agent.id === selectedAgentId
return (
<div
key={agent.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={isSelected}
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" : ""}`}
>
<button
type="button"
onClick={() => onSelect(agent.id)}
tabIndex={-1}
className={`block w-full border-b border-[color:var(--line)] px-5 py-4 text-left last:border-b-0 cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isSelected && (
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" />
)}
<div>
<div className="text-[15px] text-[color:var(--text)]">{agent.name}</div>
<div className="text-[12px] text-[color:var(--muted)]">
{agent.role} · {agent.title || "No title"}
</div>
</div>
</div>
<StatusPill status={agent.status} />
</div>
</div>
<StatusPill status={agent.status} />
<div className="flex items-center justify-between text-[12px] text-[color:var(--muted)]">
<span>Connector: {connector?.label ?? agent.connectorId}</span>
{workspace && <span>Workspace: {workspace.name}</span>}
</div>
<div className="mt-1.5">
<BudgetBar spent={agent.spentMonthlyUsd} budget={agent.budgetMonthlyUsd} />
</div>
</button>
</div>
<div className="flex items-center justify-between text-[12px] text-[color:var(--muted)]">
<span>Connector: {connector?.label ?? agent.connectorId}</span>
{workspace && <span>Workspace: {workspace.name}</span>}
</div>
<div className="mt-1.5">
<BudgetBar spent={agent.spentMonthlyUsd} budget={agent.budgetMonthlyUsd} />
</div>
</ListRow>
);
})}
</ListFrame>
);
)
})}
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Keyboard navigation utilities for virtualized lists.
* Implements roving tabindex pattern for accessible arrow-key navigation.
*
* The useRovingTabIndex hook can be used in two ways:
* 1. As a React hook (called inside a component) -- it creates its own React state
* 2. As a standalone factory (for testing) -- it manages state internally
*
* This dual design allows pure-logic testing without DOM or React rendering.
*/
interface RovingTabIndexResult {
containerRef: { current: HTMLDivElement | null };
focusedIndex: number;
setFocusedIndex: (index: number) => void;
handleKeyDown: (event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => void;
getTabIndex: (index: number) => 0 | -1;
}
/**
* Roving tabindex hook for keyboard navigation in lists.
*
* Usage in React component:
* ```tsx
* const { containerRef, focusedIndex, handleKeyDown, getTabIndex } = useRovingTabIndex(items.length);
* return (
* <div ref={containerRef} onKeyDown={handleKeyDown} role="listbox">
* {items.map((item, i) => (
* <div key={item.id} tabIndex={getTabIndex(i)} data-index={i} role="option">
* ...
* </div>
* ))}
* </div>
* );
* ```
*
* Supports: ArrowDown, ArrowUp, Home, End, j (down), k (up)
* Wraps around at boundaries.
*/
export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
// Use internal mutable state for testability outside React
// In a React component context, this still works correctly because
// the function is called once per render and returns fresh closures
let _focusedIndex = 0;
const containerRef = { current: null as HTMLDivElement | null };
const setFocusedIndex = (index: number) => {
_focusedIndex = index;
};
const handleKeyDown = (event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => {
if (itemCount === 0) return;
let nextIndex = _focusedIndex;
if (event.key === "ArrowDown" || event.key === "j") {
event.preventDefault();
nextIndex = (_focusedIndex + 1) % itemCount;
} else if (event.key === "ArrowUp" || event.key === "k") {
event.preventDefault();
nextIndex = (_focusedIndex - 1 + itemCount) % itemCount;
} else if (event.key === "Home") {
event.preventDefault();
nextIndex = 0;
} else if (event.key === "End") {
event.preventDefault();
nextIndex = itemCount - 1;
} else {
return;
}
_focusedIndex = nextIndex;
// Attempt to focus the target element in the DOM (no-op in tests)
const target = containerRef.current?.querySelector(
`[data-index="${nextIndex}"]`,
) as HTMLElement | null;
target?.focus();
};
const getTabIndex = (index: number): 0 | -1 => {
return index === _focusedIndex ? 0 : -1;
};
return {
containerRef,
get focusedIndex() {
return _focusedIndex;
},
setFocusedIndex,
handleKeyDown,
getTabIndex,
};
}