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.
- inspect_ai/_cli/common.py +3 -1
- inspect_ai/_cli/eval.py +15 -9
- inspect_ai/_display/core/active.py +4 -1
- inspect_ai/_display/core/config.py +3 -3
- inspect_ai/_display/core/panel.py +7 -3
- inspect_ai/_display/plain/__init__.py +0 -0
- inspect_ai/_display/plain/display.py +203 -0
- inspect_ai/_display/rich/display.py +0 -5
- inspect_ai/_display/textual/widgets/port_mappings.py +110 -0
- inspect_ai/_display/textual/widgets/samples.py +79 -12
- inspect_ai/_display/textual/widgets/sandbox.py +37 -0
- inspect_ai/_eval/eval.py +10 -1
- inspect_ai/_eval/loader.py +79 -19
- inspect_ai/_eval/registry.py +6 -0
- inspect_ai/_eval/score.py +3 -1
- inspect_ai/_eval/task/results.py +51 -22
- inspect_ai/_eval/task/run.py +47 -13
- inspect_ai/_eval/task/sandbox.py +10 -5
- inspect_ai/_util/constants.py +1 -0
- inspect_ai/_util/port_names.py +61 -0
- inspect_ai/_util/text.py +23 -0
- inspect_ai/_view/www/App.css +31 -1
- inspect_ai/_view/www/dist/assets/index.css +31 -1
- inspect_ai/_view/www/dist/assets/index.js +25498 -2044
- inspect_ai/_view/www/log-schema.json +32 -2
- inspect_ai/_view/www/package.json +2 -0
- inspect_ai/_view/www/src/App.mjs +14 -16
- inspect_ai/_view/www/src/Types.mjs +1 -2
- inspect_ai/_view/www/src/api/Types.ts +133 -0
- inspect_ai/_view/www/src/api/{api-browser.mjs → api-browser.ts} +25 -13
- inspect_ai/_view/www/src/api/api-http.ts +219 -0
- inspect_ai/_view/www/src/api/api-shared.ts +47 -0
- inspect_ai/_view/www/src/api/{api-vscode.mjs → api-vscode.ts} +22 -19
- inspect_ai/_view/www/src/api/{client-api.mjs → client-api.ts} +93 -53
- inspect_ai/_view/www/src/api/index.ts +51 -0
- inspect_ai/_view/www/src/api/jsonrpc.ts +225 -0
- inspect_ai/_view/www/src/components/ChatView.mjs +133 -43
- inspect_ai/_view/www/src/components/DownloadButton.mjs +1 -1
- inspect_ai/_view/www/src/components/ExpandablePanel.mjs +0 -4
- inspect_ai/_view/www/src/components/LargeModal.mjs +19 -20
- inspect_ai/_view/www/src/components/TabSet.mjs +3 -1
- inspect_ai/_view/www/src/components/VirtualList.mjs +266 -84
- inspect_ai/_view/www/src/index.js +77 -4
- inspect_ai/_view/www/src/log/{remoteLogFile.mjs → remoteLogFile.ts} +62 -46
- inspect_ai/_view/www/src/navbar/Navbar.mjs +4 -1
- inspect_ai/_view/www/src/navbar/SecondaryBar.mjs +19 -10
- inspect_ai/_view/www/src/samples/SampleDialog.mjs +5 -1
- inspect_ai/_view/www/src/samples/SampleDisplay.mjs +23 -15
- inspect_ai/_view/www/src/samples/SampleList.mjs +19 -49
- inspect_ai/_view/www/src/samples/SampleScores.mjs +1 -1
- inspect_ai/_view/www/src/samples/SampleTranscript.mjs +8 -3
- inspect_ai/_view/www/src/samples/SamplesDescriptor.mjs +38 -26
- inspect_ai/_view/www/src/samples/SamplesTab.mjs +14 -11
- inspect_ai/_view/www/src/samples/SamplesTools.mjs +8 -8
- inspect_ai/_view/www/src/samples/tools/SampleFilter.mjs +712 -89
- inspect_ai/_view/www/src/samples/tools/SortFilter.mjs +2 -2
- inspect_ai/_view/www/src/samples/tools/filters.mjs +260 -87
- inspect_ai/_view/www/src/samples/transcript/ErrorEventView.mjs +24 -2
- inspect_ai/_view/www/src/samples/transcript/EventPanel.mjs +29 -24
- inspect_ai/_view/www/src/samples/transcript/EventRow.mjs +1 -1
- inspect_ai/_view/www/src/samples/transcript/InfoEventView.mjs +24 -2
- inspect_ai/_view/www/src/samples/transcript/InputEventView.mjs +24 -2
- inspect_ai/_view/www/src/samples/transcript/ModelEventView.mjs +31 -10
- inspect_ai/_view/www/src/samples/transcript/SampleInitEventView.mjs +24 -2
- inspect_ai/_view/www/src/samples/transcript/SampleLimitEventView.mjs +23 -2
- inspect_ai/_view/www/src/samples/transcript/ScoreEventView.mjs +24 -2
- inspect_ai/_view/www/src/samples/transcript/StepEventView.mjs +33 -3
- inspect_ai/_view/www/src/samples/transcript/SubtaskEventView.mjs +25 -2
- inspect_ai/_view/www/src/samples/transcript/ToolEventView.mjs +25 -2
- inspect_ai/_view/www/src/samples/transcript/TranscriptView.mjs +193 -11
- inspect_ai/_view/www/src/samples/transcript/Types.mjs +10 -0
- inspect_ai/_view/www/src/samples/transcript/state/StateEventView.mjs +26 -2
- inspect_ai/_view/www/src/types/log.d.ts +13 -2
- inspect_ai/_view/www/src/utils/Format.mjs +10 -3
- inspect_ai/_view/www/src/utils/{Json.mjs → json-worker.ts} +13 -9
- inspect_ai/_view/www/src/utils/vscode.ts +36 -0
- inspect_ai/_view/www/src/workspace/WorkSpace.mjs +11 -5
- inspect_ai/_view/www/vite.config.js +7 -0
- inspect_ai/_view/www/yarn.lock +116 -0
- inspect_ai/approval/_human/__init__.py +0 -0
- inspect_ai/approval/_human/manager.py +1 -1
- inspect_ai/approval/_policy.py +12 -6
- inspect_ai/log/_log.py +1 -1
- inspect_ai/log/_samples.py +16 -0
- inspect_ai/log/_transcript.py +4 -1
- inspect_ai/model/_call_tools.py +59 -0
- inspect_ai/model/_conversation.py +16 -7
- inspect_ai/model/_generate_config.py +12 -12
- inspect_ai/model/_model.py +117 -18
- inspect_ai/model/_model_output.py +22 -2
- inspect_ai/model/_openai.py +383 -0
- inspect_ai/model/_providers/anthropic.py +152 -55
- inspect_ai/model/_providers/azureai.py +21 -21
- inspect_ai/model/_providers/bedrock.py +37 -40
- inspect_ai/model/_providers/goodfire.py +248 -0
- inspect_ai/model/_providers/google.py +46 -54
- inspect_ai/model/_providers/groq.py +7 -3
- inspect_ai/model/_providers/hf.py +6 -0
- inspect_ai/model/_providers/mistral.py +13 -12
- inspect_ai/model/_providers/openai.py +51 -218
- inspect_ai/model/_providers/openai_o1.py +11 -12
- inspect_ai/model/_providers/providers.py +23 -1
- inspect_ai/model/_providers/together.py +12 -12
- inspect_ai/model/_providers/util/__init__.py +2 -3
- inspect_ai/model/_providers/util/hf_handler.py +1 -1
- inspect_ai/model/_providers/util/llama31.py +1 -1
- inspect_ai/model/_providers/util/util.py +0 -76
- inspect_ai/model/_providers/vertex.py +1 -4
- inspect_ai/scorer/_metric.py +3 -0
- inspect_ai/scorer/_reducer/reducer.py +1 -1
- inspect_ai/scorer/_scorer.py +4 -3
- inspect_ai/solver/__init__.py +4 -5
- inspect_ai/solver/_basic_agent.py +1 -1
- inspect_ai/solver/_bridge/__init__.py +3 -0
- inspect_ai/solver/_bridge/bridge.py +100 -0
- inspect_ai/solver/_bridge/patch.py +170 -0
- inspect_ai/solver/_prompt.py +35 -5
- inspect_ai/solver/_solver.py +6 -0
- inspect_ai/solver/_task_state.py +80 -38
- inspect_ai/tool/__init__.py +2 -0
- inspect_ai/tool/_tool.py +12 -1
- inspect_ai/tool/_tool_call.py +10 -0
- inspect_ai/tool/_tool_def.py +16 -5
- inspect_ai/tool/_tool_with.py +21 -4
- inspect_ai/tool/beta/__init__.py +5 -0
- inspect_ai/tool/beta/_computer/__init__.py +3 -0
- inspect_ai/tool/beta/_computer/_common.py +133 -0
- inspect_ai/tool/beta/_computer/_computer.py +155 -0
- inspect_ai/tool/beta/_computer/_computer_split.py +198 -0
- inspect_ai/tool/beta/_computer/_resources/Dockerfile +100 -0
- inspect_ai/tool/beta/_computer/_resources/README.md +30 -0
- inspect_ai/tool/beta/_computer/_resources/entrypoint/entrypoint.sh +18 -0
- inspect_ai/tool/beta/_computer/_resources/entrypoint/novnc_startup.sh +20 -0
- inspect_ai/tool/beta/_computer/_resources/entrypoint/x11vnc_startup.sh +48 -0
- inspect_ai/tool/beta/_computer/_resources/entrypoint/xfce_startup.sh +13 -0
- inspect_ai/tool/beta/_computer/_resources/entrypoint/xvfb_startup.sh +48 -0
- inspect_ai/tool/beta/_computer/_resources/image_home_dir/Desktop/Firefox Web Browser.desktop +10 -0
- inspect_ai/tool/beta/_computer/_resources/image_home_dir/Desktop/Visual Studio Code.desktop +10 -0
- inspect_ai/tool/beta/_computer/_resources/image_home_dir/Desktop/XPaint.desktop +10 -0
- inspect_ai/tool/beta/_computer/_resources/tool/__init__.py +0 -0
- inspect_ai/tool/beta/_computer/_resources/tool/_logger.py +22 -0
- inspect_ai/tool/beta/_computer/_resources/tool/_run.py +42 -0
- inspect_ai/tool/beta/_computer/_resources/tool/_tool_result.py +33 -0
- inspect_ai/tool/beta/_computer/_resources/tool/_x11_client.py +262 -0
- inspect_ai/tool/beta/_computer/_resources/tool/computer_tool.py +85 -0
- inspect_ai/tool/beta/_computer/_resources/tool/requirements.txt +0 -0
- inspect_ai/util/__init__.py +2 -0
- inspect_ai/util/_display.py +5 -0
- inspect_ai/util/_limit.py +26 -0
- inspect_ai/util/_sandbox/docker/docker.py +64 -1
- inspect_ai/util/_sandbox/docker/internal.py +3 -1
- inspect_ai/util/_sandbox/docker/prereqs.py +1 -1
- inspect_ai/util/_sandbox/environment.py +14 -0
- {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/METADATA +3 -2
- {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/RECORD +159 -126
- inspect_ai/_view/www/src/api/Types.mjs +0 -117
- inspect_ai/_view/www/src/api/api-http.mjs +0 -300
- inspect_ai/_view/www/src/api/api-shared.mjs +0 -10
- inspect_ai/_view/www/src/api/index.mjs +0 -49
- inspect_ai/_view/www/src/api/jsonrpc.mjs +0 -208
- inspect_ai/_view/www/src/samples/transcript/TranscriptState.mjs +0 -70
- inspect_ai/_view/www/src/utils/vscode.mjs +0 -16
- {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/LICENSE +0 -0
- {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/WHEEL +0 -0
- {inspect_ai-0.3.58.dist-info → inspect_ai-0.3.60.dist-info}/entry_points.txt +0 -0
- {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
|
/**
|
@@ -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 = (
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
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 =
|
48
|
+
const tabContentsRef =
|
49
|
+
scrollRef || useRef(/** @type {HTMLElement|null} */ (null));
|
48
50
|
useEffect(() => {
|
49
51
|
setTimeout(() => {
|
50
52
|
if (
|
@@ -1,98 +1,280 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
import {
|
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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
13
|
-
|
76
|
+
const currentScrollTop = scrollElement.scrollTop;
|
77
|
+
const viewportHeight = scrollElement.offsetHeight;
|
14
78
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
27
|
-
|
28
|
-
|
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 (
|
31
|
-
|
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
|
-
|
61
|
-
|
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
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
96
|
-
|
97
|
-
|
98
|
-
}
|
278
|
+
`;
|
279
|
+
},
|
280
|
+
);
|