inspect-ai 0.3.58__py3-none-any.whl → 0.3.60__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. inspect_ai/_cli/common.py +3 -1
  2. inspect_ai/_cli/eval.py +15 -9
  3. inspect_ai/_display/core/active.py +4 -1
  4. inspect_ai/_display/core/config.py +3 -3
  5. inspect_ai/_display/core/panel.py +7 -3
  6. inspect_ai/_display/plain/__init__.py +0 -0
  7. inspect_ai/_display/plain/display.py +203 -0
  8. inspect_ai/_display/rich/display.py +0 -5
  9. inspect_ai/_display/textual/widgets/port_mappings.py +110 -0
  10. inspect_ai/_display/textual/widgets/samples.py +79 -12
  11. inspect_ai/_display/textual/widgets/sandbox.py +37 -0
  12. inspect_ai/_eval/eval.py +10 -1
  13. inspect_ai/_eval/loader.py +79 -19
  14. inspect_ai/_eval/registry.py +6 -0
  15. inspect_ai/_eval/score.py +3 -1
  16. inspect_ai/_eval/task/results.py +51 -22
  17. inspect_ai/_eval/task/run.py +47 -13
  18. inspect_ai/_eval/task/sandbox.py +10 -5
  19. inspect_ai/_util/constants.py +1 -0
  20. inspect_ai/_util/port_names.py +61 -0
  21. inspect_ai/_util/text.py +23 -0
  22. inspect_ai/_view/www/App.css +31 -1
  23. inspect_ai/_view/www/dist/assets/index.css +31 -1
  24. inspect_ai/_view/www/dist/assets/index.js +25498 -2044
  25. inspect_ai/_view/www/log-schema.json +32 -2
  26. inspect_ai/_view/www/package.json +2 -0
  27. inspect_ai/_view/www/src/App.mjs +14 -16
  28. inspect_ai/_view/www/src/Types.mjs +1 -2
  29. inspect_ai/_view/www/src/api/Types.ts +133 -0
  30. inspect_ai/_view/www/src/api/{api-browser.mjs → api-browser.ts} +25 -13
  31. inspect_ai/_view/www/src/api/api-http.ts +219 -0
  32. inspect_ai/_view/www/src/api/api-shared.ts +47 -0
  33. inspect_ai/_view/www/src/api/{api-vscode.mjs → api-vscode.ts} +22 -19
  34. inspect_ai/_view/www/src/api/{client-api.mjs → client-api.ts} +93 -53
  35. inspect_ai/_view/www/src/api/index.ts +51 -0
  36. inspect_ai/_view/www/src/api/jsonrpc.ts +225 -0
  37. inspect_ai/_view/www/src/components/ChatView.mjs +133 -43
  38. inspect_ai/_view/www/src/components/DownloadButton.mjs +1 -1
  39. inspect_ai/_view/www/src/components/ExpandablePanel.mjs +0 -4
  40. inspect_ai/_view/www/src/components/LargeModal.mjs +19 -20
  41. inspect_ai/_view/www/src/components/TabSet.mjs +3 -1
  42. inspect_ai/_view/www/src/components/VirtualList.mjs +266 -84
  43. inspect_ai/_view/www/src/index.js +77 -4
  44. inspect_ai/_view/www/src/log/{remoteLogFile.mjs → remoteLogFile.ts} +62 -46
  45. inspect_ai/_view/www/src/navbar/Navbar.mjs +4 -1
  46. inspect_ai/_view/www/src/navbar/SecondaryBar.mjs +19 -10
  47. inspect_ai/_view/www/src/samples/SampleDialog.mjs +5 -1
  48. inspect_ai/_view/www/src/samples/SampleDisplay.mjs +23 -15
  49. inspect_ai/_view/www/src/samples/SampleList.mjs +19 -49
  50. inspect_ai/_view/www/src/samples/SampleScores.mjs +1 -1
  51. inspect_ai/_view/www/src/samples/SampleTranscript.mjs +8 -3
  52. inspect_ai/_view/www/src/samples/SamplesDescriptor.mjs +38 -26
  53. inspect_ai/_view/www/src/samples/SamplesTab.mjs +14 -11
  54. inspect_ai/_view/www/src/samples/SamplesTools.mjs +8 -8
  55. inspect_ai/_view/www/src/samples/tools/SampleFilter.mjs +712 -89
  56. inspect_ai/_view/www/src/samples/tools/SortFilter.mjs +2 -2
  57. inspect_ai/_view/www/src/samples/tools/filters.mjs +260 -87
  58. inspect_ai/_view/www/src/samples/transcript/ErrorEventView.mjs +24 -2
  59. inspect_ai/_view/www/src/samples/transcript/EventPanel.mjs +29 -24
  60. inspect_ai/_view/www/src/samples/transcript/EventRow.mjs +1 -1
  61. inspect_ai/_view/www/src/samples/transcript/InfoEventView.mjs +24 -2
  62. inspect_ai/_view/www/src/samples/transcript/InputEventView.mjs +24 -2
  63. inspect_ai/_view/www/src/samples/transcript/ModelEventView.mjs +31 -10
  64. inspect_ai/_view/www/src/samples/transcript/SampleInitEventView.mjs +24 -2
  65. inspect_ai/_view/www/src/samples/transcript/SampleLimitEventView.mjs +23 -2
  66. inspect_ai/_view/www/src/samples/transcript/ScoreEventView.mjs +24 -2
  67. inspect_ai/_view/www/src/samples/transcript/StepEventView.mjs +33 -3
  68. inspect_ai/_view/www/src/samples/transcript/SubtaskEventView.mjs +25 -2
  69. inspect_ai/_view/www/src/samples/transcript/ToolEventView.mjs +25 -2
  70. inspect_ai/_view/www/src/samples/transcript/TranscriptView.mjs +193 -11
  71. inspect_ai/_view/www/src/samples/transcript/Types.mjs +10 -0
  72. inspect_ai/_view/www/src/samples/transcript/state/StateEventView.mjs +26 -2
  73. inspect_ai/_view/www/src/types/log.d.ts +13 -2
  74. inspect_ai/_view/www/src/utils/Format.mjs +10 -3
  75. inspect_ai/_view/www/src/utils/{Json.mjs → json-worker.ts} +13 -9
  76. inspect_ai/_view/www/src/utils/vscode.ts +36 -0
  77. inspect_ai/_view/www/src/workspace/WorkSpace.mjs +11 -5
  78. inspect_ai/_view/www/vite.config.js +7 -0
  79. inspect_ai/_view/www/yarn.lock +116 -0
  80. inspect_ai/approval/_human/__init__.py +0 -0
  81. inspect_ai/approval/_human/manager.py +1 -1
  82. inspect_ai/approval/_policy.py +12 -6
  83. inspect_ai/log/_log.py +1 -1
  84. inspect_ai/log/_samples.py +16 -0
  85. inspect_ai/log/_transcript.py +4 -1
  86. inspect_ai/model/_call_tools.py +59 -0
  87. inspect_ai/model/_conversation.py +16 -7
  88. inspect_ai/model/_generate_config.py +12 -12
  89. inspect_ai/model/_model.py +117 -18
  90. inspect_ai/model/_model_output.py +22 -2
  91. inspect_ai/model/_openai.py +383 -0
  92. inspect_ai/model/_providers/anthropic.py +152 -55
  93. inspect_ai/model/_providers/azureai.py +21 -21
  94. inspect_ai/model/_providers/bedrock.py +37 -40
  95. inspect_ai/model/_providers/goodfire.py +248 -0
  96. inspect_ai/model/_providers/google.py +46 -54
  97. inspect_ai/model/_providers/groq.py +7 -3
  98. inspect_ai/model/_providers/hf.py +6 -0
  99. inspect_ai/model/_providers/mistral.py +13 -12
  100. inspect_ai/model/_providers/openai.py +51 -218
  101. inspect_ai/model/_providers/openai_o1.py +11 -12
  102. inspect_ai/model/_providers/providers.py +23 -1
  103. inspect_ai/model/_providers/together.py +12 -12
  104. inspect_ai/model/_providers/util/__init__.py +2 -3
  105. inspect_ai/model/_providers/util/hf_handler.py +1 -1
  106. inspect_ai/model/_providers/util/llama31.py +1 -1
  107. inspect_ai/model/_providers/util/util.py +0 -76
  108. inspect_ai/model/_providers/vertex.py +1 -4
  109. inspect_ai/scorer/_metric.py +3 -0
  110. inspect_ai/scorer/_reducer/reducer.py +1 -1
  111. inspect_ai/scorer/_scorer.py +4 -3
  112. inspect_ai/solver/__init__.py +4 -5
  113. inspect_ai/solver/_basic_agent.py +1 -1
  114. inspect_ai/solver/_bridge/__init__.py +3 -0
  115. inspect_ai/solver/_bridge/bridge.py +100 -0
  116. inspect_ai/solver/_bridge/patch.py +170 -0
  117. inspect_ai/solver/_prompt.py +35 -5
  118. inspect_ai/solver/_solver.py +6 -0
  119. inspect_ai/solver/_task_state.py +80 -38
  120. inspect_ai/tool/__init__.py +2 -0
  121. inspect_ai/tool/_tool.py +12 -1
  122. inspect_ai/tool/_tool_call.py +10 -0
  123. inspect_ai/tool/_tool_def.py +16 -5
  124. inspect_ai/tool/_tool_with.py +21 -4
  125. inspect_ai/tool/beta/__init__.py +5 -0
  126. inspect_ai/tool/beta/_computer/__init__.py +3 -0
  127. inspect_ai/tool/beta/_computer/_common.py +133 -0
  128. inspect_ai/tool/beta/_computer/_computer.py +155 -0
  129. inspect_ai/tool/beta/_computer/_computer_split.py +198 -0
  130. inspect_ai/tool/beta/_computer/_resources/Dockerfile +100 -0
  131. inspect_ai/tool/beta/_computer/_resources/README.md +30 -0
  132. inspect_ai/tool/beta/_computer/_resources/entrypoint/entrypoint.sh +18 -0
  133. inspect_ai/tool/beta/_computer/_resources/entrypoint/novnc_startup.sh +20 -0
  134. inspect_ai/tool/beta/_computer/_resources/entrypoint/x11vnc_startup.sh +48 -0
  135. inspect_ai/tool/beta/_computer/_resources/entrypoint/xfce_startup.sh +13 -0
  136. inspect_ai/tool/beta/_computer/_resources/entrypoint/xvfb_startup.sh +48 -0
  137. inspect_ai/tool/beta/_computer/_resources/image_home_dir/Desktop/Firefox Web Browser.desktop +10 -0
  138. inspect_ai/tool/beta/_computer/_resources/image_home_dir/Desktop/Visual Studio Code.desktop +10 -0
  139. inspect_ai/tool/beta/_computer/_resources/image_home_dir/Desktop/XPaint.desktop +10 -0
  140. inspect_ai/tool/beta/_computer/_resources/tool/__init__.py +0 -0
  141. inspect_ai/tool/beta/_computer/_resources/tool/_logger.py +22 -0
  142. inspect_ai/tool/beta/_computer/_resources/tool/_run.py +42 -0
  143. inspect_ai/tool/beta/_computer/_resources/tool/_tool_result.py +33 -0
  144. inspect_ai/tool/beta/_computer/_resources/tool/_x11_client.py +262 -0
  145. inspect_ai/tool/beta/_computer/_resources/tool/computer_tool.py +85 -0
  146. inspect_ai/tool/beta/_computer/_resources/tool/requirements.txt +0 -0
  147. inspect_ai/util/__init__.py +2 -0
  148. inspect_ai/util/_display.py +5 -0
  149. inspect_ai/util/_limit.py +26 -0
  150. inspect_ai/util/_sandbox/docker/docker.py +64 -1
  151. inspect_ai/util/_sandbox/docker/internal.py +3 -1
  152. inspect_ai/util/_sandbox/docker/prereqs.py +1 -1
  153. inspect_ai/util/_sandbox/environment.py +14 -0
  154. {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/METADATA +3 -2
  155. {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/RECORD +159 -126
  156. inspect_ai/_view/www/src/api/Types.mjs +0 -117
  157. inspect_ai/_view/www/src/api/api-http.mjs +0 -300
  158. inspect_ai/_view/www/src/api/api-shared.mjs +0 -10
  159. inspect_ai/_view/www/src/api/index.mjs +0 -49
  160. inspect_ai/_view/www/src/api/jsonrpc.mjs +0 -208
  161. inspect_ai/_view/www/src/samples/transcript/TranscriptState.mjs +0 -70
  162. inspect_ai/_view/www/src/utils/vscode.mjs +0 -16
  163. {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/LICENSE +0 -0
  164. {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/WHEEL +0 -0
  165. {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/entry_points.txt +0 -0
  166. {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,54 @@ import { MessageContent } from "./MessageContent.mjs";
7
7
  import { ExpandablePanel } from "./ExpandablePanel.mjs";
8
8
  import { FontSize, TextStyle } from "../appearance/Fonts.mjs";
9
9
  import { resolveToolInput, ToolCallView } from "./Tools.mjs";
10
+ import { VirtualList } from "./VirtualList.mjs";
11
+
12
+ /**
13
+ * Renders the ChatViewVirtualList component.
14
+ *
15
+ * @param {Object} props - The properties passed to the component.
16
+ * @param {string} props.id - The ID for the chat view.
17
+ * @param {import("../types/log").Messages} props.messages - The array of chat messages.
18
+ * @param {"compact" | "complete"} [props.toolCallStyle] - Whether to show tool calls
19
+ * @param {Object} [props.style] - Inline styles for the chat view.
20
+ * @param {boolean} props.indented - Whether the chatview has indented messages
21
+ * @param {boolean} [props.numbered] - Whether the chatview is numbered
22
+ * @param {import("htm/preact").MutableRef<HTMLElement>} props.scrollRef - The scrollable parent element
23
+ * @returns {import("preact").JSX.Element} The component.
24
+ */
25
+ export const ChatViewVirtualList = ({
26
+ id,
27
+ messages,
28
+ toolCallStyle,
29
+ style,
30
+ indented,
31
+ numbered = true,
32
+ scrollRef,
33
+ }) => {
34
+ const collapsedMessages = resolveMessages(messages);
35
+
36
+ const renderRow = (item, index) => {
37
+ const number =
38
+ collapsedMessages.length > 1 && numbered ? index + 1 : undefined;
39
+ return html`<${ChatMessageRow}
40
+ id=${id}
41
+ number=${number}
42
+ resolvedMessage=${item}
43
+ indented=${indented}
44
+ toolCallStyle=${toolCallStyle}
45
+ />`;
46
+ };
47
+
48
+ const result = html`<${VirtualList}
49
+ data=${collapsedMessages}
50
+ tabIndex="0"
51
+ renderRow=${renderRow}
52
+ scrollRef=${scrollRef}
53
+ style=${{ width: "100%", marginTop: "1em", ...style }}
54
+ />`;
55
+
56
+ return result;
57
+ };
10
58
 
11
59
  /**
12
60
  * Renders the ChatView component.
@@ -28,6 +76,90 @@ export const ChatView = ({
28
76
  indented,
29
77
  numbered = true,
30
78
  }) => {
79
+ const collapsedMessages = resolveMessages(messages);
80
+ const result = html` <div style=${style}>
81
+ ${collapsedMessages.map((msg, index) => {
82
+ const number =
83
+ collapsedMessages.length > 1 && numbered ? index + 1 : undefined;
84
+ return html`<${ChatMessageRow}
85
+ id=${id}
86
+ number=${number}
87
+ resolvedMessage=${msg}
88
+ indented=${indented}
89
+ toolCallStyle=${toolCallStyle}
90
+ />`;
91
+ })}
92
+ </div>`;
93
+ return result;
94
+ };
95
+
96
+ /**
97
+ * Renders the ChatMessage component.
98
+ *
99
+ * @param {Object} props - The properties passed to the component.
100
+ * @param {string} props.id - The ID for the chat view.
101
+ * @param {number} [props.number] - The message number
102
+ * @param {ResolvedMessage} props.resolvedMessage - The array of chat messages.
103
+ * @param {"compact" | "complete"} [props.toolCallStyle] - Whether to show tool calls
104
+ * @param {boolean} props.indented - Whether the chatview has indented messages
105
+ * @returns {import("preact").JSX.Element} The component.
106
+ */
107
+ export const ChatMessageRow = ({
108
+ id,
109
+ number,
110
+ resolvedMessage,
111
+ toolCallStyle,
112
+ indented,
113
+ }) => {
114
+ if (number) {
115
+ return html` <div
116
+ style=${{
117
+ display: "grid",
118
+ gridTemplateColumns: "max-content auto",
119
+ columnGap: "0.4em",
120
+ }}
121
+ >
122
+ <div
123
+ style=${{
124
+ fontSize: FontSize.smaller,
125
+ ...TextStyle.secondary,
126
+ marginTop: "0.1em",
127
+ }}
128
+ >
129
+ ${number}
130
+ </div>
131
+ <${ChatMessage}
132
+ id=${`${id}-chat-messages`}
133
+ message=${resolvedMessage.message}
134
+ toolMessages=${resolvedMessage.toolMessages}
135
+ indented=${indented}
136
+ toolCallStyle=${toolCallStyle}
137
+ />
138
+ </div>`;
139
+ } else {
140
+ return html`<${ChatMessage}
141
+ id=${`${id}-chat-messages`}
142
+ message=${resolvedMessage.message}
143
+ toolMessages=${resolvedMessage.toolMessages}
144
+ indented=${indented}
145
+ toolCallStyle=${toolCallStyle}
146
+ />`;
147
+ }
148
+ };
149
+
150
+ /**
151
+ * @typedef {Object} ResolvedMessage
152
+ * @property {import("../types/log").ChatMessageAssistant | import("../types/log").ChatMessageSystem | import("../types/log").ChatMessageUser} message - The main chat message.
153
+ * @property {import("../types/log").ChatMessageTool[]} [toolMessages] - Optional array of tool-related messages.
154
+ */
155
+
156
+ /**
157
+ * Renders the ChatView component.
158
+ *
159
+ * @param {import("../types/log").Messages} messages - The array of chat messages.
160
+ * @returns {ResolvedMessage[]} The component.
161
+ */
162
+ export const resolveMessages = (messages) => {
31
163
  // Filter tool messages into a sidelist that the chat stream
32
164
  // can use to lookup the tool responses
33
165
 
@@ -88,49 +220,7 @@ export const ChatView = ({
88
220
  if (systemMessage && systemMessage.content.length > 0) {
89
221
  collapsedMessages.unshift({ message: systemMessage });
90
222
  }
91
-
92
- const result = html`
93
- <div style=${style}>
94
- ${collapsedMessages.map((msg, index) => {
95
- if (collapsedMessages.length > 1 && numbered) {
96
- return html` <div
97
- style=${{
98
- display: "grid",
99
- gridTemplateColumns: "max-content auto",
100
- columnGap: "0.4em",
101
- }}
102
- >
103
- <div
104
- style=${{
105
- fontSize: FontSize.smaller,
106
- ...TextStyle.secondary,
107
- marginTop: "0.1em",
108
- }}
109
- >
110
- ${index + 1}
111
- </div>
112
- <${ChatMessage}
113
- id=${`${id}-chat-messages`}
114
- message=${msg.message}
115
- toolMessages=${msg.toolMessages}
116
- indented=${indented}
117
- toolCallStyle=${toolCallStyle}
118
- />
119
- </div>`;
120
- } else {
121
- return html` <${ChatMessage}
122
- id=${`${id}-chat-messages`}
123
- message=${msg.message}
124
- toolMessages=${msg.toolMessages}
125
- indented=${indented}
126
- toolCallStyle=${toolCallStyle}
127
- />`;
128
- }
129
- })}
130
- </div>
131
- `;
132
-
133
- return result;
223
+ return collapsedMessages;
134
224
  };
135
225
 
136
226
  /**
@@ -1,5 +1,5 @@
1
1
  import { html } from "htm/preact";
2
- import api from "../api/index.mjs";
2
+ import api from "../api/index";
3
3
  import { FontSize } from "../appearance/Fonts.mjs";
4
4
 
5
5
  export const DownloadButton = ({ label, fileName, fileContents }) => {
@@ -70,10 +70,6 @@ export const ExpandablePanel = ({
70
70
  contentsStyle.border = "solid var(--bs-light-border-subtle) 1px";
71
71
  }
72
72
 
73
- if (!showToggle) {
74
- contentsStyle.marginBottom = "1em";
75
- }
76
-
77
73
  return html`<div
78
74
  class="expandable-panel"
79
75
  ref=${contentsRef}
@@ -5,25 +5,24 @@ import { FontSize } from "../appearance/Fonts.mjs";
5
5
  import { ProgressBar } from "./ProgressBar.mjs";
6
6
  import { MessageBand } from "./MessageBand.mjs";
7
7
 
8
- export const LargeModal = (props) => {
9
- const {
10
- id,
11
- title,
12
- detail,
13
- detailTools,
14
- footer,
15
- onkeyup,
16
- visible,
17
- onHide,
18
- showProgress,
19
- children,
20
- initialScrollPositionRef,
21
- setInitialScrollPosition,
22
- warning,
23
- warningHidden,
24
- setWarningHidden,
25
- } = props;
26
-
8
+ export const LargeModal = ({
9
+ id,
10
+ title,
11
+ detail,
12
+ detailTools,
13
+ footer,
14
+ onkeyup,
15
+ visible,
16
+ onHide,
17
+ showProgress,
18
+ children,
19
+ initialScrollPositionRef,
20
+ setInitialScrollPosition,
21
+ warning,
22
+ warningHidden,
23
+ setWarningHidden,
24
+ scrollRef,
25
+ }) => {
27
26
  // The footer
28
27
  const modalFooter = footer
29
28
  ? html`<div class="modal-footer">${footer}</div>`
@@ -31,7 +30,7 @@ export const LargeModal = (props) => {
31
30
 
32
31
  // Support restoring the scroll position
33
32
  // but only do this for the first time that the children are set
34
- const scrollRef = useRef(/** @type {HTMLElement|null} */ (null));
33
+ scrollRef = scrollRef || useRef(/** @type {HTMLElement|null} */ (null));
35
34
  useEffect(() => {
36
35
  if (scrollRef.current) {
37
36
  setTimeout(() => {
@@ -38,13 +38,15 @@ export const TabPanel = ({
38
38
  selected,
39
39
  style,
40
40
  scrollable,
41
+ scrollRef,
41
42
  classes,
42
43
  scrollPosition,
43
44
  setScrollPosition,
44
45
  children,
45
46
  }) => {
46
47
  const tabContentsId = computeTabContentsId(id, index);
47
- const tabContentsRef = useRef(/** @type {HTMLElement|null} */ (null));
48
+ const tabContentsRef =
49
+ scrollRef || useRef(/** @type {HTMLElement|null} */ (null));
48
50
  useEffect(() => {
49
51
  setTimeout(() => {
50
52
  if (
@@ -1,98 +1,280 @@
1
- // preact-virtual-list.mjs
2
-
3
- import { html, Component } from "htm/preact";
4
- import { createRef } from "preact";
1
+ import { html } from "htm/preact";
2
+ import { useRef, useState, useEffect, useMemo } from "preact/hooks";
3
+ import { forwardRef, useImperativeHandle } from "preact/compat";
5
4
  import { throttle } from "../utils/sync.mjs";
6
5
 
7
- const STYLE_INNER =
8
- "position:relative; overflow:hidden; width:100%; min-height:100%;";
9
- const STYLE_CONTENT =
10
- "position:absolute; top:0; left:0; height:100%; width:100%; overflow:visible;";
6
+ /**
7
+ * A virtualized list component that efficiently renders large lists by only
8
+ * rendering the items that are currently visible in the viewport.
9
+ * Supports dynamic row heights that are measured after rendering.
10
+ *
11
+ * @template T
12
+ * @param {Object} props - The component props
13
+ * @param {T[]} props.data - Array of items to be rendered in the list
14
+ * @param {(item: T, index: number) => preact.VNode} props.renderRow - Function to render each row
15
+ * @param {number} [props.overscanCount=15] - Number of extra rows to render above and below the visible area
16
+ * @param {number} [props.estimatedRowHeight=50] - Estimated height of each row before measurement
17
+ * @param {boolean} [props.sync=false] - If true, forces a re-render on scroll
18
+ * @param {import("preact").RefObject<HTMLElement>} [props.scrollRef] - Optional ref for the scroll container
19
+ * @param {import("preact").Ref<{ scrollToIndex: (index: number) => void }>} ref - Ref object exposing the list's methods
20
+ * @returns {preact.VNode} The virtualized list component
21
+ */
22
+ export const VirtualList = forwardRef(
23
+ (
24
+ /** @type {props} */ {
25
+ data,
26
+ renderRow,
27
+ overscanCount = 15,
28
+ estimatedRowHeight = 50,
29
+ sync = false,
30
+ scrollRef,
31
+ ...props
32
+ },
33
+ ref,
34
+ ) => {
35
+ const [height, setHeight] = useState(0);
36
+ const [offset, setOffset] = useState(0);
37
+
38
+ const [listMetrics, setListMetrics] = useState({
39
+ rowHeights: new Map(),
40
+ totalHeight: data.length * estimatedRowHeight,
41
+ });
42
+
43
+ const baseRef = useRef(null);
44
+ const containerRef = useRef(null);
45
+ const rowRefs = useRef(new Map());
46
+
47
+ // Function to get row height (measured or estimated)
48
+ const getRowHeight = (index) => {
49
+ return listMetrics.rowHeights.get(index) || estimatedRowHeight;
50
+ };
51
+
52
+ // Calculate row positions based on current heights
53
+ const rowPositions = useMemo(() => {
54
+ let currentPosition = 0;
55
+ const positions = new Map();
56
+
57
+ for (let i = 0; i < data.length; i++) {
58
+ positions.set(i, currentPosition);
59
+ currentPosition += getRowHeight(i);
60
+ }
61
+
62
+ return positions;
63
+ }, [listMetrics.rowHeights, data.length]);
64
+
65
+ // Expose scrollToIndex method via ref
66
+ useImperativeHandle(
67
+ ref,
68
+ () => ({
69
+ focus: () => {
70
+ baseRef.current;
71
+ },
72
+ scrollToIndex: (index, direction) => {
73
+ const scrollElement = scrollRef?.current || baseRef.current;
74
+ if (!scrollElement || index < 0 || index >= data.length) return;
11
75
 
12
- export class VirtualList extends Component {
13
- /** @type {HTMLElement} */ base;
76
+ const currentScrollTop = scrollElement.scrollTop;
77
+ const viewportHeight = scrollElement.offsetHeight;
14
78
 
15
- constructor(props) {
16
- super(props);
17
- this.state = {
18
- height: 0,
19
- offset: 0,
79
+ // Get position and height of target row
80
+ const rowTop = rowPositions.get(index) || 0;
81
+ const rowHeight = getRowHeight(index);
82
+ const rowBottom = rowTop + rowHeight;
83
+
84
+ // If this is already visible, don't scroll
85
+ const isVisible =
86
+ rowTop >= currentScrollTop &&
87
+ rowBottom <= currentScrollTop + viewportHeight;
88
+ if (isVisible) {
89
+ return;
90
+ }
91
+
92
+ // Calculate new scroll position based on direction
93
+ let newScrollTop;
94
+ if (direction === "up") {
95
+ // Align top of element with top of viewport
96
+ newScrollTop = rowTop;
97
+ } else {
98
+ // Align bottom of element with bottom of viewport
99
+ newScrollTop = rowBottom - viewportHeight;
100
+ }
101
+
102
+ // Clamp scroll position to valid range
103
+ newScrollTop = Math.max(
104
+ 0,
105
+ Math.min(newScrollTop, listMetrics.totalHeight - viewportHeight),
106
+ );
107
+ scrollElement.scrollTop = newScrollTop;
108
+ },
109
+ }),
110
+ [rowPositions, data.length],
111
+ );
112
+
113
+ // Measure rendered rows and update heights if needed
114
+ const measureRows = () => {
115
+ // Keep track of updated heights
116
+ let updates = [];
117
+
118
+ rowRefs.current.forEach((element, index) => {
119
+ if (element) {
120
+ const measuredHeight = element.offsetHeight;
121
+ // If the measured height is different, schedule an update
122
+ if (
123
+ measuredHeight &&
124
+ measuredHeight !== listMetrics.rowHeights.get(index)
125
+ ) {
126
+ updates.push([index, measuredHeight]);
127
+ }
128
+ }
129
+ });
130
+
131
+ // If no rows changed, do nothing
132
+ if (updates.length === 0) return;
133
+
134
+ // Create a new Map of rowHeights so we don't mutate state directly
135
+ const newHeights = new Map(listMetrics.rowHeights);
136
+ updates.forEach(([index, height]) => {
137
+ newHeights.set(index, height);
138
+ });
139
+
140
+ // Recompute total height only once
141
+ let newTotalHeight = 0;
142
+ for (let i = 0; i < data.length; i++) {
143
+ newTotalHeight += newHeights.get(i) || estimatedRowHeight;
144
+ }
145
+
146
+ // Now update our single state object in one go:
147
+ setListMetrics({
148
+ rowHeights: newHeights,
149
+ totalHeight: newTotalHeight,
150
+ });
20
151
  };
21
- this.resize = () => {
22
- if (this.state.height !== this.base.offsetHeight) {
23
- this.setState({ height: this.base.offsetHeight });
152
+
153
+ // Handle container resize
154
+ const resize = () => {
155
+ const scrollElement = scrollRef?.current || baseRef.current;
156
+ if (scrollElement && height !== scrollElement.offsetHeight) {
157
+ setHeight(scrollElement.offsetHeight);
24
158
  }
25
159
  };
26
- this.handleScroll = throttle(() => {
27
- if (this.base) {
28
- this.setState({ offset: this.base.scrollTop });
160
+
161
+ // Handle scroll with throttling
162
+ const handleScroll = throttle(() => {
163
+ const scrollElement = scrollRef?.current || baseRef.current;
164
+ if (scrollElement) {
165
+ setOffset(scrollElement.scrollTop);
29
166
  }
30
- if (this.props.sync) {
31
- this.forceUpdate();
167
+ if (sync) {
168
+ setOffset((prev) => prev);
32
169
  }
33
170
  }, 100);
34
- this.containerRef = createRef();
35
- }
36
-
37
- componentDidUpdate() {
38
- this.resize();
39
- }
40
-
41
- componentDidMount() {
42
- this.resize();
43
- window.addEventListener("resize", this.resize);
44
- }
45
-
46
- componentWillUnmount() {
47
- window.removeEventListener("resize", this.resize);
48
- }
49
-
50
- render(
51
- { data, rowMap, renderRow, overscanCount = 10, ...props },
52
- { offset = 0, height = 0 },
53
- ) {
54
- // Compute the start and ending rows
55
- const firstVisibleIdx = rowMap.findIndex((row) => {
56
- return row.start + row.height >= offset;
57
- });
58
- const firstIndex = firstVisibleIdx > -1 ? firstVisibleIdx : 0;
59
171
 
60
- const lastVisibleIdx = rowMap.findIndex((row) => {
61
- return row.start + row.height >= offset + height;
172
+ // Setup scroll and resize listeners
173
+ useEffect(() => {
174
+ resize();
175
+ const scrollElement = scrollRef?.current || baseRef.current;
176
+
177
+ if (scrollElement) {
178
+ scrollElement.addEventListener("scroll", handleScroll);
179
+ window.addEventListener("resize", resize);
180
+
181
+ return () => {
182
+ scrollElement.removeEventListener("scroll", handleScroll);
183
+ window.removeEventListener("resize", resize);
184
+ };
185
+ }
186
+ }, [scrollRef?.current]);
187
+
188
+ // Measure rows after render
189
+ useEffect(() => {
190
+ measureRows();
62
191
  });
63
- const lastIndex = lastVisibleIdx > -1 ? lastVisibleIdx : rowMap.length - 1;
64
-
65
- // Compute the total height
66
- const lastRow = rowMap[rowMap.length - 1];
67
- const totalHeight = lastRow ? lastRow.start + lastRow.height : 0;
68
-
69
- // Compute the visible rows (including overscan)
70
- let visibleRowCount = lastIndex - firstIndex;
71
- if (overscanCount) {
72
- visibleRowCount += overscanCount;
73
- }
74
-
75
- // Account for overscan
76
- const start = firstVisibleIdx;
77
- const end = Math.min(data.length, start + visibleRowCount);
78
-
79
- const selection = data.slice(start, end);
80
-
81
- // const firstRow
82
- const top = firstVisibleIdx !== -1 ? rowMap[firstVisibleIdx].start : 0;
83
- const rows = html`<div onscroll=${this.handleScroll} ...${props}>
84
- <div style=${`${STYLE_INNER} height:${totalHeight}px;`}>
85
- <div style=${`${STYLE_CONTENT} top:${top}px;`} ref=${this.containerRef}>
86
- ${selection.map((item, index) => {
87
- const component = renderRow(item, start + index);
88
-
89
- return html` <div key=${`list-item-${start + index}`}>
90
- ${component}
91
- </div>`;
92
- })}
192
+
193
+ const findRowAtOffset = (targetOffset) => {
194
+ if (targetOffset <= 0) return 0;
195
+ if (targetOffset >= listMetrics.totalHeight) return data.length - 1;
196
+
197
+ let low = 0;
198
+ let high = data.length - 1;
199
+ let lastValid = 0;
200
+
201
+ while (low <= high) {
202
+ const mid = Math.floor((low + high) / 2);
203
+ const rowStart = rowPositions.get(mid) || 0;
204
+
205
+ if (rowStart <= targetOffset) {
206
+ lastValid = mid;
207
+ low = mid + 1;
208
+ } else {
209
+ high = mid - 1;
210
+ }
211
+ }
212
+ return lastValid;
213
+ };
214
+
215
+ const firstVisibleIdx = findRowAtOffset(offset);
216
+ const lastVisibleIdx = findRowAtOffset(offset + height);
217
+
218
+ // Calculate range of rows to render including overscan
219
+ const start = Math.max(0, firstVisibleIdx - overscanCount);
220
+ const end = Math.min(data.length, lastVisibleIdx + overscanCount);
221
+
222
+ // Memoize the rendered rows to prevent unnecessary re-renders
223
+ const renderedRows = useMemo(() => {
224
+ const selection = data.slice(start, end);
225
+ return selection.map((item, index) => {
226
+ const actualIndex = start + index;
227
+ return html`
228
+ <div
229
+ key=${`list-item-${actualIndex}`}
230
+ ref=${(el) => {
231
+ if (el) {
232
+ rowRefs.current.set(actualIndex, el);
233
+ } else {
234
+ rowRefs.current.delete(actualIndex);
235
+ }
236
+ }}
237
+ >
238
+ ${renderRow(item, actualIndex)}
239
+ </div>
240
+ `;
241
+ });
242
+ }, [data, start, end, renderRow]);
243
+
244
+ const style_inner = {
245
+ position: "relative",
246
+ overflow: scrollRef?.current ? "visible" : "hidden",
247
+ width: "100%",
248
+ minHeight: "100%",
249
+ };
250
+
251
+ const style_content = {
252
+ position: "absolute",
253
+ top: 0,
254
+ left: 0,
255
+ height: "100%",
256
+ width: "100%",
257
+ overflow: "visible",
258
+ };
259
+
260
+ const top = rowPositions.get(start) || 0;
261
+
262
+ // Only attach onscroll to baseRef if no scrollRef is provided
263
+ const scrollProps = scrollRef ? {} : { onscroll: handleScroll };
264
+
265
+ return html`
266
+ <div ref=${baseRef} ...${props} ...${scrollProps}>
267
+ <div
268
+ style=${{ ...style_inner, height: `${listMetrics.totalHeight}px` }}
269
+ >
270
+ <div
271
+ style=${{ ...style_content, top: `${top}px` }}
272
+ ref=${containerRef}
273
+ >
274
+ ${renderedRows}
275
+ </div>
93
276
  </div>
94
277
  </div>
95
- </div>`;
96
- return rows;
97
- }
98
- }
278
+ `;
279
+ },
280
+ );