From d77486504d269da8c8a37e4f0d2eeefb5fd2ed31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 02:08:43 -0400 Subject: [PATCH] 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 --- package.json | 1 + pnpm-lock.yaml | 20 +++ .../components/communication/MessageList.tsx | 159 +++++++++++------- .../components/execution/ActivityList.tsx | 89 ++++++++-- .../components/execution/ApprovalList.tsx | 75 +++++++-- .../components/execution/CostList.tsx | 119 ++++++++++--- .../components/execution/TaskQueue.tsx | 83 +++++++-- .../components/organization/AgentRoster.tsx | 144 ++++++++++------ src/renderer/lib/keyboard.ts | 94 +++++++++++ tests/unit/keyboard.test.ts | 152 +++++++++++++++++ 10 files changed, 761 insertions(+), 175 deletions(-) create mode 100644 src/renderer/lib/keyboard.ts create mode 100644 tests/unit/keyboard.test.ts diff --git a/package.json b/package.json index b488118a..5b4e6a76 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@agentcompany/adapter-utils": "workspace:*", "@fontsource-variable/jetbrains-mono": "5.2.8", "@phosphor-icons/react": "2.1.10", + "@tanstack/react-virtual": "^3.13.23", "clsx": "2.1.1", "electron-log": "5.4.3", "electron-updater": "6.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aa6adc5..70cda949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@phosphor-icons/react': specifier: 2.1.10 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': + specifier: ^3.13.23 + version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) clsx: specifier: 2.1.1 version: 2.1.1 @@ -1012,6 +1015,15 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3624,6 +3636,14 @@ snapshots: tailwindcss: 4.2.1 vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) + '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/virtual-core@3.13.23': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 diff --git a/src/renderer/components/communication/MessageList.tsx b/src/renderer/components/communication/MessageList.tsx index eadca07e..81a3e925 100644 --- a/src/renderer/components/communication/MessageList.tsx +++ b/src/renderer/components/communication/MessageList.tsx @@ -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(null) - - useEffect(() => { - messageEndRef.current?.scrollIntoView({ behavior: "auto" }) - }, [selectedChannelKey, messageCount]) + const parentRef = useRef(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 (
@@ -69,35 +103,21 @@ export function MessageList({ ) } - if (activeChannel.kind === "task") { - if (taskDiscussionComments.length === 0) { - return ( -
- -
- {t("comm.noDiscussionYet")} -
-
- {t("comm.commentsAppearHere")} -
-
- ) - } + if (activeChannel.kind === "task" && taskDiscussionComments.length === 0) { return ( -
- {taskDiscussionComments.map((comment) => ( - - ))} -
+
+ +
+ {t("comm.noDiscussionYet")} +
+
+ {t("comm.commentsAppearHere")} +
) } - if (filteredMessages.length === 0) { + if (activeChannel.kind !== "task" && filteredMessages.length === 0) { return (
@@ -116,36 +136,57 @@ export function MessageList({ } return ( -
- {threadedMessages.topLevel.map((msg) => { - const replies = threadedMessages.replyMap.get(msg.id) ?? [] - return ( -
- onReply(msg)} - /> - {replies.length > 0 ? ( -
- {replies.map((reply) => ( - onReply(reply)} - /> - ))} -
- ) : null} -
- ) - })} -
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const item = flatItems[virtualRow.index] + return ( +
+ {item.type === "comment" ? ( + + ) : ( + onReply(item.message)} + /> + )} +
+ ) + })} +
) } diff --git a/src/renderer/components/execution/ActivityList.tsx b/src/renderer/components/execution/ActivityList.tsx index 8f23bada..9fac3eda 100644 --- a/src/renderer/components/execution/ActivityList.tsx +++ b/src/renderer/components/execution/ActivityList.tsx @@ -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("all"); const [visibleCount, setVisibleCount] = useState(100) + const [search, setSearch] = useState("") + const [entityFilter, setEntityFilter] = useState("all") + const parentRef = useRef(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 return ( @@ -27,24 +48,58 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
({ value: id, label: id }))]} />
- - {filtered.map((entry) => ( - -
-
{runs.find((run) => run.id === entry.runId)?.summary || "Run cost"}
-
{agents.find((a) => a.id === entry.agentId)?.name ?? "Unknown agent"} · {entry.connectorId}
-
-
-
{formatMoney(entry.amountUsd)}
- {entry.unattributed ?
unattributed
: null} -
-
- ))} - -
Total ({filtered.length} entries)
-
{formatMoney(total)}
-
-
+
+