diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..828b276 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,69 @@ +name: Deploy Next.js site to Pages + +on: + push: + branches: ["main"] + pull_request: + types: [opened, reopened, synchronize, closed] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: site + + - name: Setup Pages + id: setup_pages + uses: actions/configure-pages@v5 + + - name: Set base uri + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "NEXT_PUBLIC_BASE_PATH=${{ steps.setup_pages.outputs.base_path }}/pr-preview/pr-${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" + else + echo "NEXT_PUBLIC_BASE_PATH=${{ steps.setup_pages.outputs.base_path }}" >> "$GITHUB_ENV" + fi + + - name: Build with Next.js + run: bun run build + working-directory: site + + - name: Deploy preview + if: github.event_name == 'pull_request' + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: site/out + + - name: Deploy production + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: JamesIves/github-pages-deploy-action@v4 + with: + clean-exclude: pr-preview/ + force: false + folder: site/out diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cf2fa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +__pycache__/ +*.py[cod] +*$py.class + +# testing +coverage + +# next.js +.next/ + +# The `out` directory should not be ignored by version control +out/ + +# production +build + +# misc +.DS_Store +*.pem +*~ +\#* + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.zealt/config.json b/.zealt/config.json new file mode 100644 index 0000000..7d0fc99 --- /dev/null +++ b/.zealt/config.json @@ -0,0 +1,5 @@ +{ + "title": "Qwik Benchmark", + "description": "Performance results of AI coding models on Qwik tasks, measuring success rate and execution time with high precision.", + "github_repo": "https://github.com/kweizh/qwik-benchmark" +} \ No newline at end of file diff --git a/README.md b/README.md index daba738..0c43578 100644 --- a/README.md +++ b/README.md @@ -1 +1,46 @@ -# qwik-benchmark \ No newline at end of file + +# Qwik Benchmark + +This repository contains benchmarks for evaluating AI models on **Qwik**. + +You can view the evaluation reports at [https://kweizh.github.io/qwik-benchmark/](https://kweizh.github.io/qwik-benchmark/). + +## Project Structure + +- `tasks/`: Contains the benchmark tasks, each with its own instructions. +- `jobs/`: Stores the results of benchmark runs. +- `site/`: A Next.js application to visualize benchmark results. + +## Getting Started + +This benchmark is evaluated using the [Harbor framework](https://github.com/harbor-framework/harbor) and the [Pochi agent](https://github.com/TabbyML/pochi). + +### Running Evaluation + +You can run the evaluation using the Harbor CLI. Here is an example: + +```bash +harbor run \ + --agent codex \ + --model "gpt-5.2-codex" \ + --env daytona \ + --path ./tasks \ + --n-attempts 1 \ + --max-retries 5 \ + --n-concurrent 5 \ + --retry-include RuntimeError \ + --retry-include DaytonaError \ + --retry-include AgentTimeoutError +``` + +### Evaluation Details + +Before starting the evaluation, you should set the necessary environment variables for your chosen agent. +For example, if using Pochi, you should export `POCHI_API_KEY`. + +Evaluation can be run locally with Docker (default), or using [Daytona.io](https://www.daytona.io/) by setting `--env daytona`. + +When running with Daytona, please note that Daytona blocks some network access for tier 1 and tier 2 users. If you meet any network issues, please refer to [Daytona network limits](https://www.daytona.io/docs/en/network-limits/). + +--- +Generated by [Zealt](https://github.com/TabbyML/zealt) diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..82904b8 --- /dev/null +++ b/plan.md @@ -0,0 +1,338 @@ +# Technical Research & Benchmark Specification: Qwik (qwik.dev) & Typesense Integration + +This specification provides a comprehensive technical overview, project setup guides, core API details, developer friction points, and evaluation/benchmark ideas for **Qwik** and its integration with **Typesense** running inside a container. + +--- + +## 1. Library Overview + +### Description +[Qwik](https://qwik.dev/) is a modern frontend framework designed to deliver instant-loading web applications of any scale. Unlike traditional frameworks that rely on hydration (downloading and executing all JavaScript to make server-rendered HTML interactive), Qwik introduces **Resumability**. Qwik serializes the execution state of the application and the framework on the server and resumes it on the client with virtually zero initial JavaScript execution (typically ~1kb on boot). + +### Ecosystem Role +Qwik sits as a high-performance alternative to Next.js, Nuxt, and SvelteKit. It is paired with **Qwik City**, its official meta-framework, which handles directory-based routing, server-side data loading (`routeLoader$`), form actions (`routeAction$`), and middleware. The Qwik compiler is powered by an Optimizer written in Rust, which splits code aggressively into tiny, lazy-loadable chunks triggered on-demand by user interactions. + +### Project Setup +A Qwik project can be initialized interactively or via non-interactive automated commands suitable for containerized and CI/CD environments. + +#### Non-Interactive CLI Setup +To scaffold a new Qwik City application non-interactively, use the `create-qwik` command-line mode: +```bash +# Command Syntax: npm create qwik@latest +# We use the "empty" starter (Empty App with Qwik City routing) and name it "qwik-app" +npm create qwik@latest empty qwik-app +``` +*Note: In non-interactive environments, this command scaffolds the project without prompting for wizard inputs.* + +#### Programmatic Node API Setup +Alternatively, you can scaffold a project programmatically using the Node.js API: +```javascript +// setup.cjs +const { createApp } = require('create-qwik'); +const path = require('path'); + +async function run() { + const result = await createApp({ + projectName: 'qwik-app', + starterId: 'empty', // options: 'empty', 'playground', 'todo', 'library' + outDir: path.join(__dirname, 'qwik-app'), + }); + console.log('Project created successfully:', result); +} +run(); +``` + +#### Boilerplate Structure +The generated project structure follows this layout: +```text +qwik-app/ +├── public/ # Static assets (images, robots.txt, etc.) +├── src/ +│ ├── components/ # Reusable presentation components +│ ├── routes/ # Directory-based routing (Qwik City) +│ │ ├── layout.tsx # Root layout / middleware +│ │ └── index.tsx # Homepage route (/) +│ ├── entry.ssr.tsx # SSR entry point +│ └── root.tsx # Root component rendering the HTML shell +├── package.json +├── tsconfig.json +└── vite.config.ts # Vite configuration with Qwik and Qwik City plugins +``` + +--- + +## 2. Core Primitives & APIs + +### Key Concepts & Documentation Links + +| Concept / API | Specific Documentation Link | Description | +| :--- | :--- | :--- | +| `component$` | [Qwik Component Docs](https://qwik.dev/docs/core/overview/) | Declares a lazy-loadable Qwik component. | +| `$` | [Qwik Optimizer Docs](https://qwik.dev/docs/core/rendering/) | Tells the Rust Optimizer to extract expressions into lazy-loadable chunks (`QRL`s). | +| `useSignal` | [Qwik Signal Docs](https://qwik.dev/docs/core/state/) | Creates a reactive single-value cell (using `.value` access). | +| `useStore` | [Qwik Store Docs](https://qwik.dev/docs/core/state/) | Creates a reactive proxy object for complex, nested states. | +| `useTask$` | [Qwik Tasks Docs](https://qwik.dev/docs/core/tasks/) | Runs synchronous/asynchronous side effects during initialization or state changes. | +| `useVisibleTask$` | [Qwik Visible Tasks Docs](https://qwik.dev/docs/core/tasks/) | Client-only hook that executes after rendering when a component enters the viewport. | +| `server$` | [Qwik Server$ Docs](https://qwik.dev/docs/server$/) | Creates a strongly-typed RPC (Remote Procedure Call) endpoint executing only on the server. | +| `routeLoader$` | [Qwik City Route Loader Docs](https://qwik.dev/docs/route-loader/) | Pre-fetches data on the server during navigation before rendering. | +| `routeAction$` | [Qwik City Route Action Docs](https://qwik.dev/docs/action/) | Handles form submissions and updates state on the server. | + +--- + +### Detailed Explanations & Code Snippets + +#### 1. State Management and Lazy-loaded Event Handlers +This snippet demonstrates the declaration of a component, reactive state management using `useSignal` and `useStore`, and event binding using the `$` suffix. + +```typescript +// src/components/counter.tsx +import { component$, useSignal, useStore } from '@builder.io/qwik'; + +// Key Object Definition: Component is declared with component$ +export const Counter = component$(() => { + // useSignal is for primitive types + const count = useSignal(0); + + // useStore is for complex objects and nested reactivity + const state = useStore({ + title: 'Counter Store', + history: [] as number[], + }); + + return ( +
+

{state.title}

+

Current Count: {count.value}

+ + {/* onClick$ compiles to a lazy-loaded QRL boundary */} + + +
+ History: {state.history.join(', ')} +
+
+ ); +}); +``` +*Note: In other language surfaces (e.g., pure JavaScript), the `$` symbol indicates code-splitting boundaries that the compiler splits into distinct files. Since Qwik is strictly TypeScript/JavaScript, there are no separate language SDKs, but the Optimizer CLI provides options to inspect these outputs.* + +--- + +#### 2. Server-Client RPC with `server$` +The `server$` primitive allows developers to execute code strictly on the server (e.g., database queries or secured API calls) while providing a typed asynchronous function proxy to the client. + +```typescript +// src/components/search.tsx +import { component$, useSignal, useTask$ } from '@builder.io/qwik'; +import { server$ } from '@builder.io/qwik'; + +// Key Object Definition: server$ wraps a server-only execution function +const fetchSearchResultsOnServer = server$(async (query: string) => { + // This code runs strictly on the Node/Edge server environment + console.log(`Searching for "${query}" on the server...`); + + // Example: Querying a database or secure external API + const response = await fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`); + const data = await response.json(); + return data.results as string[]; +}); + +export default component$(() => { + const query = useSignal(''); + const results = useSignal([]); + + // useTask$ handles reactive changes and executes on both SSR and client + useTask$(({ track, cleanup }) => { + track(() => query.value); // Track changes to query.value + + const controller = new AbortController(); + const id = setTimeout(async () => { + if (query.value.trim() === '') { + results.value = []; + return; + } + // Call the server function transparently via RPC + results.value = await fetchSearchResultsOnServer(query.value); + }, 300); // 300ms debounce + + cleanup(() => { + clearTimeout(id); + controller.abort(); + }); + }); + + return ( +
+ +
    + {results.value.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); +}); +``` + +--- + +#### 3. Qwik City Routing, Loaders, and Actions +This example exhibits directory-based routing integration, loading data before page rendering with `routeLoader$`, and handling submissions securely using `routeAction$`. + +```typescript +// src/routes/products/index.tsx +import { component$ } from '@builder.io/qwik'; +import { routeLoader$, routeAction$, Form } from '@builder.io/qwik-city'; + +interface Product { + id: string; + name: string; + price: number; +} + +// Key Object Definition: routeLoader$ pre-fetches data on the server during SSR +export const useProductList = routeLoader$(async () => { + // Executed on server before component render + const products: Product[] = [ + { id: '1', name: 'Resumable Frameworks Guide', price: 29 }, + { id: '2', name: 'Typesense Container Handbook', price: 19 }, + ]; + return products; +}); + +// Key Object Definition: routeAction$ handles server-side form submissions +export const useAddProduct = routeAction$(async (data) => { + const name = data.name as string; + const price = parseFloat(data.price as string); + + if (!name || isNaN(price)) { + return { success: false, error: 'Invalid product details.' }; + } + + // Perform database insert or API call here + console.log(`Adding product to database: ${name} ($${price})`); + return { success: true, productId: '3' }; +}); + +export default component$(() => { + const productsSignal = useProductList(); // Read-only Signal + const addProductAction = useAddProduct(); // Action execution state + + return ( +
+

Product Catalog

+ +
    + {productsSignal.value.map((product) => ( +
  • + {product.name} + ${product.price} +
  • + ))} +
+ +

Add New Product

+ {/* Qwik City Form handles submission progressively (works without JS) */} +
+
+ + +
+
+ + +
+ +
+ + {addProductAction.value?.success && ( +

Product added with ID: {addProductAction.value.productId}

+ )} + {addProductAction.value?.error && ( +

{addProductAction.value.error}

+ )} +
+ ); +}); +``` + +--- + +## 3. Real-World Use Cases & Templates + +### Showcase Projects and Starters +* **Qwind (Tailwind CSS Integration)**: [Qwind Template](https://github.com/onwidget/qwind) is a full-featured template combining Qwik City and Tailwind CSS, demonstrating production-ready SEO optimization, layouts, and image optimization. +* **Storefront Qwik Starter**: [Storefront Qwik](https://github.com/onwidget/awesome-qwik) represents an e-commerce storefront starter built with Qwik and Vendure, showcasing complex state management, basket logic, and fast edge delivery. +* **Official TodoMVC**: [Classic TodoMVC](https://github.com/QwikDev/qwik/tree/main/starters/apps/todo-test) shows a classic, standardized task management app demonstrating serialization, stores, and client-side interactions. + +### Common Integration Patterns +* **Edge Middleware Database Connectivity**: Using Qwik City's middleware hooks (`onRequest` or `onGet`) to connect to serverless databases (Prisma, Supabase, Kysely) at the edge. +* **Instant Search Integrations**: Coupling input listeners (`onInput$`) with debounced RPC server functions (`server$`) to perform fast remote search queries on search engines like Algolia or Typesense without exposing API admin keys to the client. + +--- + +## 4. Developer Friction Points + +### 1. Cookie Mutation during Response Streaming in `useTask$` +* **Symptom**: Calling a `server$` function within `useTask$` during the initial SSR render to set or update cookies fails silently or throws errors, and cookies are not received by the browser. +* **Underlying Cause**: Qwik streams the HTML response eagerly to the client during SSR. HTTP headers (including `Set-Cookie`) must be sent *before* the body stream begins. Since `useTask$` runs as part of the rendering cycle, the headers have already been flushed by the time the cookie mutation is called. +* **Resolution**: Move cookie modifications to Qwik City `onRequest` middleware, `routeLoader$`, or `routeAction$` which execute *prior* to response streaming. Alternatively, trigger the cookie setter inside `useVisibleTask$` (which runs exclusively on the client). +* **Link**: [Qwik Issue #5951](https://github.com/QwikDev/qwik/issues/5951) + +### 2. Complex Object and Circular Reference Serialization +* **Symptom**: Runtime crash with error: `"Only primitive and object literals can be serialized"` or `"Identifier can not be captured inside the scope because it is not serializable"`. +* **Underlying Cause**: Qwik serializes all component properties and stores to JSON in the HTML to support resumability. Storing non-serializable objects (such as active database clients, Axios instances, circular references, or third-party class instances) in a Qwik store or capturing them in a lexical scope (`$`) violates this constraint. +* **Resolution**: Wrap non-serializable properties using the `noSerialize()` wrapper from `@builder.io/qwik`. This instructs the serializer to ignore the property during SSR serialization and restore it on the client as `undefined` or re-instantiated. +* **Link**: [Qwik Issue #417](https://github.com/QwikDev/qwik/issues/417), [Qwik Issue #2083](https://github.com/QwikDev/qwik/issues/2083) + +### 3. Out-of-Order State Mutation Warning during SSR +* **Symptom**: Warning displayed in CLI console: `QWIK WARN Serializing dirty watch. Looks like an internal error`. +* **Underlying Cause**: This occurs when a `useTask$` tracks a reactive state and mutates that same state (or a state rendered earlier in the HTML stream) during the initial SSR rendering. Because of eager streaming, Qwik cannot re-render elements whose serialized HTML has already been sent to the client, leading to a "dirty" state mismatch. +* **Resolution**: Avoid mutating tracked state inside `useTask$` during the initial server render if that mutation affects elements already rendered. Ensure state changes are triggered by user actions (`onClick$`) or deferred to `useVisibleTask$`. +* **Link**: [Qwik Issue #2715](https://github.com/QwikDev/qwik/issues/2715) + +--- + +## 5. Evaluation Ideas (Benchmark Tasks) + +These tasks are designed for downstream AI coding agents to execute in a Docker environment. They focus heavily on running **Typesense in a local container** without external dependencies and integrating it with **Qwik**. + +### Simple Tier +1. **Local Typesense Container Initialization Script**: Write a standalone bash script that runs a Typesense server inside a Docker container with a specific API key (`dev-api-key`), custom data directory (`./typesense-data`), and CORS enabled, validating its health status using `curl http://localhost:8108/health`. +2. **Typesense Collection Bootstrapping**: Create a Node.js script that uses the `typesense` package to define a `books` schema (fields: `title`, `author`, `year`) and creates the collection inside the running Typesense container. +3. **Basic Qwik Static Search Component**: Implement a simple Qwik component containing a search input and a static list of items that filters on the client using `useSignal` and a computed property. + +### Medium Tier +4. **Qwik City Search Pre-fetching with routeLoader$**: Implement a Qwik City route `/search` that reads a `q` URL query parameter, queries a local Typesense container from the server using `routeLoader$`, and displays the search results during SSR. +5. **Debounced Typesense Autocomplete Input**: Create an instant autocomplete input in Qwik that queries a local Typesense container on the client. The agent must implement a debouncer inside `useTask$` to prevent spamming the Typesense container on every keystroke. +6. **Typesense Container Health Dashboard**: Build a Qwik City dashboard route `/health` that queries the local Typesense server health API and displays CPU, memory, and collection document counts dynamically. + +### Complex Tier +7. **Progressive Indexing Pipeline via routeAction$**: Implement a "Create Book" form page in Qwik City. Submitting the form must trigger a `routeAction$` that validates the input, indexes the new document in the local Typesense container via the SDK, and updates the search results on the page without a full-page reload. +8. **Resumable Multi-Facet Search UI**: Build a comprehensive search portal in Qwik (including text search, category facet filters, and sorting) connected to a local Typesense container. The UI must be fully resumable, ensuring that facet selections are preserved and lazy-loaded dynamically when clicked. + +--- + +## 6. Sources + +1. [Qwik Official Website](https://qwik.dev/) - Core documentation, concept guides, and API specifications. +2. [Qwik City Documentation](https://qwik.dev/docs/qwikcity/) - File-system routing, loaders, actions, and middleware docs. +3. [Qwik GitHub Repository](https://github.com/QwikDev/qwik) - Source code, issue tracker, and discussion boards. +4. [Typesense Official Installation Guide](https://typesense.org/docs/guide/install-typesense.html) - Docker setup instructions and CLI parameters. +5. [Typesense JavaScript SDK Repository](https://github.com/typesense/typesense-js) - Client initialization, collection creation, and search query examples. +6. [NPM create-qwik Package](https://www.npmjs.com/package/create-qwik) - CLI usage details, command modes, and programmatic API options. diff --git a/scratchpad/pending-tasks/debounced_client_side_autocomplete_search/instruction.md b/scratchpad/pending-tasks/debounced_client_side_autocomplete_search/instruction.md new file mode 100644 index 0000000..3b9a3fe --- /dev/null +++ b/scratchpad/pending-tasks/debounced_client_side_autocomplete_search/instruction.md @@ -0,0 +1,7 @@ +Instant search requires providing real-time feedback without overwhelming the search backend or exposing administrative API keys to the client. + +You need to build a Qwik component named `` that features a text input. It must use `useTask$` to track the input's value, apply a 300ms debounce, and fetch autocomplete suggestions via a `server$` RPC function. + +**Constraints:** +- The Typesense query execution must reside inside the `server$` block to prevent exposing the connection details. +- Implement an `AbortController` cleanup inside `useTask$` to cancel pending debounce timers if the user continues typing. \ No newline at end of file diff --git a/scratchpad/pending-tasks/handling_non_serializable_objects_in_state/instruction.md b/scratchpad/pending-tasks/handling_non_serializable_objects_in_state/instruction.md new file mode 100644 index 0000000..9f98cf9 --- /dev/null +++ b/scratchpad/pending-tasks/handling_non_serializable_objects_in_state/instruction.md @@ -0,0 +1,8 @@ +Qwik achieves resumability by serializing component state to JSON in the HTML. Placing active class instances, like a database client, into reactive state causes runtime crashes. + +You need to debug and fix a crashing Qwik component that attempts to store an active `Typesense.Client` instance directly inside a `useStore` object. The error presented is: "Only primitive and object literals can be serialized". + +**Constraints:** +- Do NOT remove the `useStore` implementation. +- You must wrap the `Typesense.Client` instantiation with Qwik's `noSerialize()` function before assigning it to the store property. +- Ensure TypeScript correctly types the store property as potentially `undefined` upon client resumption. \ No newline at end of file diff --git a/scratchpad/pending-tasks/local_typesense_container_initialization/instruction.md b/scratchpad/pending-tasks/local_typesense_container_initialization/instruction.md new file mode 100644 index 0000000..dc30124 --- /dev/null +++ b/scratchpad/pending-tasks/local_typesense_container_initialization/instruction.md @@ -0,0 +1,9 @@ +Local development environments require a robust, reproducible search engine setup. Typesense distributed via a Docker container is ideal for local testing. + +You need to write a standalone bash script named `start-typesense.sh` that initializes and runs a Typesense server inside a Docker container. + +**Constraints:** +- The container must expose port `8108`. +- The API key must be explicitly set to `dev-api-key`. +- The data directory must be mapped to a local `./typesense-data` folder. +- CORS must be enabled within the Typesense server configuration to allow local web client testing. \ No newline at end of file diff --git a/scratchpad/pending-tasks/progressive_indexing_form_with_routeaction/instruction.md b/scratchpad/pending-tasks/progressive_indexing_form_with_routeaction/instruction.md new file mode 100644 index 0000000..b682b4c --- /dev/null +++ b/scratchpad/pending-tasks/progressive_indexing_form_with_routeaction/instruction.md @@ -0,0 +1,8 @@ +Handling form submissions securely on the server while supporting environments without JavaScript is a primary feature of Qwik City. + +You need to implement a "Create Book" page at `src/routes/admin/add-book/index.tsx`. The page must contain a Qwik City `
` bound to a `routeAction$`. The action must receive `title`, `author`, and `year` payloads, validate them, and insert the new document into the Typesense `books` collection. + +**Constraints:** +- The form submission must function progressively (without relying on client-side JS execution). +- The `routeAction$` must return a success object containing the newly generated Typesense document ID upon successful insertion. +- Handle invalid payload types by returning an appropriate error object. \ No newline at end of file diff --git a/scratchpad/pending-tasks/server_side_search_pre_fetching_with_routeloader/instruction.md b/scratchpad/pending-tasks/server_side_search_pre_fetching_with_routeloader/instruction.md new file mode 100644 index 0000000..ed537fd --- /dev/null +++ b/scratchpad/pending-tasks/server_side_search_pre_fetching_with_routeloader/instruction.md @@ -0,0 +1,8 @@ +Qwik City excels at server-side rendering and data pre-fetching to deliver zero-JS initial loads. + +You need to implement a Qwik City route file at `src/routes/search/index.tsx` that utilizes a `routeLoader$` to read a `q` URL query parameter. The loader should query the local Typesense `books` collection strictly on the server and return the matched documents. + +**Constraints:** +- Do not use client-side fetching for the initial page load. +- The returned data must be consumed by the default component and rendered directly into the initial HTML stream. +- The Typesense client instance must be kept inside the server execution boundary. \ No newline at end of file diff --git a/scratchpad/pending-tasks/typesense_collection_bootstrapping/instruction.md b/scratchpad/pending-tasks/typesense_collection_bootstrapping/instruction.md new file mode 100644 index 0000000..c95f9e5 --- /dev/null +++ b/scratchpad/pending-tasks/typesense_collection_bootstrapping/instruction.md @@ -0,0 +1,8 @@ +Before performing any search operations, the Typesense instance needs a predefined schema for its collections. + +You need to write a Node.js script named `bootstrap.js` that connects to the local Typesense container and creates a `books` collection. The schema must strictly define three fields: `title` (string), `author` (string), and `year` (int32). + +**Constraints:** +- Use the official `typesense` npm package. +- Target `http://localhost:8108` using the `dev-api-key`. +- The script must log "Collection created successfully" upon completion and handle duplicate collection errors gracefully. \ No newline at end of file diff --git a/site/app/(home)/components/leaderboard-table.tsx b/site/app/(home)/components/leaderboard-table.tsx new file mode 100644 index 0000000..d10b1f0 --- /dev/null +++ b/site/app/(home)/components/leaderboard-table.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Search, Trophy, ListTree } from "lucide-react"; +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +import Link from "next/link"; +import zealtConfig from "@/zealt/config.json"; + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export interface LeaderboardEntry { + id: string; + model: string; + rawModel: string; + agent: string; + passedEvals: number; + successRate: number; + avgLatency: number; + isNew?: boolean; +} + +function ProgressBar({ value, colorClass }: { value: number; colorClass: string }) { + return ( +
+
+
+ ); +} + +function ScoreCell({ value }: { value: number }) { + let colorClass = "bg-primary"; + let textClass = "text-muted-foreground"; + + if (value >= 90) { + colorClass = "bg-emerald-500"; + textClass = "text-emerald-500 font-bold"; + } else if (value >= 75) { + colorClass = "bg-blue-500"; + textClass = "text-blue-500 font-medium"; + } else if (value >= 60) { + colorClass = "bg-amber-500"; + textClass = "text-amber-500"; + } else { + colorClass = "bg-red-500"; + textClass = "text-red-500"; + } + + return ( +
+ {value}% + +
+ ); +} + +export default function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) { + const router = useRouter(); + const [devMode, setDevMode] = useState(process.env.NODE_ENV === "development"); + const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + const isDev = process.env.NODE_ENV === "development" || + localStorage.getItem("devMode") === "true"; + setDevMode(isDev); + }, []); + + const filteredData = useMemo(() => { + let processedData = data; + const config = zealtConfig as { pending_models?: string[] }; + + if (!devMode && config.pending_models && config.pending_models.length > 0) { + processedData = processedData.filter((item) => + !config.pending_models!.includes(item.rawModel) + ); + } + + if (searchQuery) { + const query = searchQuery.toLowerCase(); + processedData = processedData.filter(item => + item.model.toLowerCase().includes(query) + ); + } + + return processedData; + }, [data, searchQuery, devMode]); + + return ( + <> + {/* Controls & Filters */} +
+

+ Model Performance +

+ +
+ + + View Tasks + + +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-4 py-2 bg-card border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 w-full sm:w-64 transition-all" + /> +
+
+
+ + {/* Leaderboard Table */} +
+
+ + + + + + + + + + + {filteredData.length === 0 ? ( + + + + ) : ( + filteredData.map((row, index) => ( + router.push(`./tasks?model=${encodeURIComponent(row.model)}`)} + className="group hover:bg-secondary/30 transition-colors duration-200 cursor-pointer" + > + + + + + + )))} + +
ModelPassedAvg DurationSuccess Rate
+ No results found matching your search. +
+ #{index + 1} +
+ + {row.model} + {index === 0 && } + {row.isNew && ( + + NEW + + )} + +
+
+ {row.passedEvals} + + {row.avgLatency > 0 ? `${row.avgLatency.toFixed(1)}s` : '-'} + +
+ +
+
+
+
+ + ); +} diff --git a/site/app/(home)/page.tsx b/site/app/(home)/page.tsx new file mode 100644 index 0000000..d617123 --- /dev/null +++ b/site/app/(home)/page.tsx @@ -0,0 +1,176 @@ +import { Suspense } from "react"; +import { Github, Terminal, ClipboardList, ListTree } from "lucide-react"; +import Link from "next/link"; +import tasksData from "@/zealt/tasks.json"; +import pendingTasksData from "@/zealt/pending-tasks.json"; +import zealtConfig from "@/zealt/config.json"; +import PendingReviewCard from "@/components/pending-review-card"; +import LeaderboardTable, { type LeaderboardEntry } from "./components/leaderboard-table"; + +type TaskTrial = { + agent: string; + model: string; + passed: boolean; + latency_sec: number | null; +}; + +type TaskValue = { + trials?: TaskTrial[]; +}; + +type PendingTasksValue = { + 'pending-tasks'?: number; +}; + +export default function Home() { + const totalTasks = Object.keys(tasksData as Record).length; + const hasTasks = totalTasks > 0; + const pendingSampleCases = Math.max( + 0, + Number((pendingTasksData as PendingTasksValue)['pending-tasks'] ?? 0), + ); + + // Process tasks.json to compute leaderboard stats directly on the server + const statsMap = new Map(); + + Object.values(tasksData as Record).forEach((taskValue) => { + let trials: TaskTrial[] = []; + if (Array.isArray(taskValue)) { + trials = taskValue as TaskTrial[]; + } else if (typeof taskValue === "object" && taskValue !== null) { + const task = taskValue as TaskValue; + trials = Array.isArray(task.trials) ? task.trials : []; + } + + trials.forEach((trial) => { + // Simplify model name + const modelName = trial.model.split('/').pop() || trial.model; + const agentName = trial.agent.charAt(0).toUpperCase() + trial.agent.slice(1); + + const key = `${modelName}-${agentName}`; + + if (!statsMap.has(key)) { + statsMap.set(key, { + passed: 0, + total: 0, + totalLatency: 0, + latencyCount: 0, + model: modelName, + rawModel: trial.model, + agent: agentName + }); + } + + const stats = statsMap.get(key); + if (!stats) { + return; + } + stats.total += 1; + if (trial.passed) { + stats.passed += 1; + } + if (trial.latency_sec) { + stats.totalLatency += trial.latency_sec; + stats.latencyCount += 1; + } + }); + }); + + const data: LeaderboardEntry[] = Array.from(statsMap.values()) + .map((stats, index) => { + const successRate = stats.total > 0 ? Math.round((stats.passed / stats.total) * 100) : 0; + const avgLatency = stats.latencyCount > 0 ? stats.totalLatency / stats.latencyCount : 0; + return { + id: String(index + 1), + model: stats.model, + rawModel: stats.rawModel, + agent: stats.agent, + passedEvals: stats.passed, + successRate: successRate, + avgLatency: avgLatency, + }; + }) + .sort((a, b) => b.successRate - a.successRate); + + // Re-assign IDs based on sorted order and adjust isNew + data.forEach((item, index) => { + item.id = String(index + 1); + item.isNew = index === 0; // Keeping the original visual effect for the top item + }); + + return ( +
+ {/* Background Gradient Effect */} +
+ +
+ {/* Header Section */} +
+
+ + Live Benchmarks +
+ +

+ {zealtConfig.title} +

+ +

+ {zealtConfig.description} +

+ +
+ + + View on GitHub + + {data.length > 0 && ( + <> +
+ + + Total tasks: {totalTasks} + + + )} +
+ + + Last run: {new Date().toLocaleDateString()} + +
+
+ + {!hasTasks ? ( + + ) : data.length === 0 ? ( +
+

No evaluation data yet

+
+ + + View Tasks + +
+
+ ) : ( + // Client Component for Interactive Table + Loading leaderboard...
}> + + + )} +
+
+ ); +} diff --git a/site/app/globals.css b/site/app/globals.css new file mode 100644 index 0000000..437c041 --- /dev/null +++ b/site/app/globals.css @@ -0,0 +1,149 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer utilities { + .custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + + .custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--foreground) 30%, transparent); + } +} \ No newline at end of file diff --git a/site/app/jobs/[jobName]/[trialName]/run/components/artifacts-panel.tsx b/site/app/jobs/[jobName]/[trialName]/run/components/artifacts-panel.tsx new file mode 100644 index 0000000..bf9202c --- /dev/null +++ b/site/app/jobs/[jobName]/[trialName]/run/components/artifacts-panel.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { ChevronRight, File, Folder } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + fetchLogText, + LogContentSkeleton, + LogErrorView, + getLogErrorMessage, +} from "./trajectory-page"; + +export type ArtifactNode = { + name: string; + type: "file" | "dir"; + path: string; + children?: ArtifactNode[]; +}; + +export type ArtifactNodeWithUrl = + | { name: string; type: "file"; path: string; url: string } + | { name: string; type: "dir"; path: string; children: ArtifactNodeWithUrl[] }; + +type ArtifactsPanelProps = { + artifactTree: ArtifactNodeWithUrl[]; +}; + +export function ArtifactsPanel({ artifactTree }: ArtifactsPanelProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const queryArtifact = searchParams.get("artifact"); + const queryPath = searchParams.get("path"); + + const activeArtifact = useMemo(() => { + if (queryArtifact) { + const found = artifactTree.find((node) => node.name === queryArtifact); + if (found) return found; + } + return artifactTree[0] ?? null; + }, [artifactTree, queryArtifact]); + + const updateParams = (next: { artifact?: string | null; path?: string | null }) => { + const params = new URLSearchParams(searchParams.toString()); + if (next.artifact === null) { + params.delete("artifact"); + } else if (next.artifact !== undefined) { + params.set("artifact", next.artifact); + } + if (next.path === null) { + params.delete("path"); + } else if (next.path !== undefined) { + params.set("path", next.path); + } + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + const handleSelectArtifact = (name: string) => { + updateParams({ artifact: name, path: null }); + }; + + if (!activeArtifact) { + return ( +
+ No artifacts available. +
+ ); + } + + return ( +
+
+
+ {artifactTree.map((node) => ( + + ))} +
+
+ +
+ {activeArtifact.type === "file" ? ( + + ) : ( + updateParams({ path: path ?? null })} + /> + )} +
+
+ ); +} + +type DirectoryArtifactViewProps = { + artifact: Extract; + currentPath: string | null; + onPathChange: (path: string | null) => void; +}; + +function DirectoryArtifactView({ artifact, currentPath, onPathChange }: DirectoryArtifactViewProps) { + // Resolve the active path. If currentPath isn't valid for this artifact, fall back to the artifact root. + const resolved = useMemo(() => resolvePath(artifact, currentPath), [artifact, currentPath]); + const { folder, selectedFile, breadcrumbDirs } = resolved; + + return ( +
+
+ {breadcrumbDirs.map((dir, idx) => { + const isLast = idx === breadcrumbDirs.length - 1 && !selectedFile; + return ( + + + {(idx < breadcrumbDirs.length - 1 || selectedFile) && ( + + )} + + ); + })} + {selectedFile && ( + {selectedFile.name} + )} +
+ +
+
+ {folder.children.length === 0 && ( + Empty directory. + )} + {folder.children.map((child) => { + const isActive = child.type === "file" && selectedFile?.path === child.path; + return ( + + ); + })} +
+
+ +
+ {selectedFile ? ( + + ) : ( +
+ Select a file to view its contents. +
+ )} +
+
+ ); +} + +function FileViewer({ url }: { url: string }) { + const query = useQuery({ + queryKey: ["artifact-file", url], + queryFn: () => fetchLogText(url), + }); + + return ( + +
+ {query.isPending || query.isFetching ? ( + + ) : query.isError ? ( + void query.refetch()} + /> + ) : query.data ? ( +
+            {query.data}
+          
+ ) : ( +

Empty file.

+ )} +
+
+ ); +} + +type ResolvedPath = { + folder: Extract; + selectedFile: Extract | null; + breadcrumbDirs: { name: string; path: string }[]; +}; + +function resolvePath( + root: Extract, + targetPath: string | null, +): ResolvedPath { + const breadcrumbDirs: { name: string; path: string }[] = [{ name: root.name, path: root.path }]; + let folder = root; + let selectedFile: Extract | null = null; + + if (!targetPath || !targetPath.startsWith(`${root.path}/`)) { + return { folder, selectedFile, breadcrumbDirs }; + } + + const remainder = targetPath.slice(root.path.length + 1); + const segments = remainder.split("/").filter(Boolean); + + let currentPath = root.path; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + currentPath = `${currentPath}/${segment}`; + const child = folder.children.find((c) => c.name === segment); + if (!child) { + // Path doesn't exist; bail and show what we resolved so far. + return { folder, selectedFile: null, breadcrumbDirs }; + } + if (child.type === "dir") { + folder = child; + breadcrumbDirs.push({ name: child.name, path: child.path }); + } else { + // File at the end of the path. + selectedFile = child; + // Don't push the file as a breadcrumb dir; the caller renders it separately. + break; + } + } + + return { folder, selectedFile, breadcrumbDirs }; +} diff --git a/site/app/jobs/[jobName]/[trialName]/run/components/trajectory-page.tsx b/site/app/jobs/[jobName]/[trialName]/run/components/trajectory-page.tsx new file mode 100644 index 0000000..0fab8fc --- /dev/null +++ b/site/app/jobs/[jobName]/[trialName]/run/components/trajectory-page.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTheme } from "next-themes"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { HttpError } from "@/lib/http-error"; +import { ArtifactsPanel, type ArtifactNodeWithUrl } from "./artifacts-panel"; + +export type TabConfig = { + value: string; + label: React.ReactNode; +}; + +type TrajectoryPageProps = { + trajectoryUrl: string; + browserVerificationUrls: { name: string; url: string }[]; + fallbackUrl: string; + stderrLogUrl: string | null; + verifierLogUrl: string | null; + tabsConfig: TabConfig[]; + artifactTree?: ArtifactNodeWithUrl[]; +}; + +export async function fetchLogText(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { cache: "force-cache" }); + } catch (_e) { + throw new HttpError("Network request failed."); + } + + if (!response.ok) { + throw new HttpError(`Request failed with status ${response.status}.`, { status: response.status }); + } + + return response.text(); +} + +export function TrajectoryPage({ + trajectoryUrl, + browserVerificationUrls, + fallbackUrl, + stderrLogUrl, + verifierLogUrl, + tabsConfig, + artifactTree, +}: TrajectoryPageProps) { + const { resolvedTheme } = useTheme(); + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + const [mounted, setMounted] = useState(false); + const [iframeLoading, setIframeLoading] = useState(true); + const [devModeEnabled, setDevModeEnabled] = useState(process.env.NODE_ENV === "development"); + + useEffect(() => { + const isDev = process.env.NODE_ENV === "development" || + localStorage.getItem("devMode") === "true"; + setDevModeEnabled(isDev); + }, []); + + const visibleTabsConfig = useMemo( + () => + devModeEnabled + ? tabsConfig + : tabsConfig.filter((t) => t.value !== "artifacts"), + [tabsConfig, devModeEnabled], + ); + const validTabs = visibleTabsConfig.map((t) => t.value); + const [activeTab, setActiveTab] = useState(() => { + const queryTab = searchParams.get("tab"); + return queryTab && validTabs.includes(queryTab) ? queryTab : validTabs[0]; + }); + + const handleTabChange = (value: string) => { + setActiveTab(value); + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }; + const [browserIframeLoading, setBrowserIframeLoading] = useState>({}); + const [activeBrowserVerificationTab, setActiveBrowserVerificationTab] = useState( + browserVerificationUrls[0]?.name || "", + ); + + const iframeTheme = mounted && resolvedTheme === "light" ? "light" : "dark"; + + const iframeUrl = useMemo(() => { + const url = new URL(trajectoryUrl); + const hashParams = new URLSearchParams(url.hash.slice(1)); + hashParams.set("theme", iframeTheme); + url.hash = hashParams.toString(); + return url.toString(); + }, [trajectoryUrl, iframeTheme]); + + const activeBrowserVerificationBaseUrl = useMemo(() => { + const testCase = browserVerificationUrls.find((tc) => tc.name === activeBrowserVerificationTab); + return testCase ? testCase.url : null; + }, [browserVerificationUrls, activeBrowserVerificationTab]); + + const activeBrowserVerificationUrl = useMemo(() => { + if (!activeBrowserVerificationBaseUrl) { + return null; + } + + const url = new URL(activeBrowserVerificationBaseUrl); + const hashParams = new URLSearchParams(url.hash.slice(1)); + hashParams.set("theme", iframeTheme); + url.hash = hashParams.toString(); + return url.toString(); + }, [activeBrowserVerificationBaseUrl, iframeTheme]); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted || !activeBrowserVerificationBaseUrl) { + return; + } + + setBrowserIframeLoading((prev) => ({ ...prev, [activeBrowserVerificationTab]: true })); + }, [activeBrowserVerificationBaseUrl, activeBrowserVerificationTab, mounted]); + + const stderrQuery = useQuery({ + queryKey: ["trajectory-stderr", stderrLogUrl], + enabled: Boolean(stderrLogUrl), + queryFn: async () => { + if (!stderrLogUrl) { + return null; + } + + return fetchLogText(stderrLogUrl); + }, + }); + + const verifierQuery = useQuery({ + queryKey: ["trajectory-verifier", verifierLogUrl], + enabled: Boolean(verifierLogUrl), + queryFn: async () => { + if (!verifierLogUrl) { + return null; + } + + return fetchLogText(verifierLogUrl); + }, + }); + + const handleIframeLoad = () => { + setIframeLoading(false); + }; + + const handleBrowserIframeLoad = (testCaseName: string) => { + setBrowserIframeLoading((prev) => ({ ...prev, [testCaseName]: false })); + }; + + const handleIframeError = () => { + window.location.replace(fallbackUrl); + }; + + const renderLogContent = ( + text: string | null | undefined, + isLoading: boolean, + isError: boolean, + error: unknown, + onRetry: () => void, + emptyMessage: string, + ) => { + if (isLoading) { + return ; + } + + if (isError) { + return ; + } + + if (!text) { + return

{emptyMessage}

; + } + + return ( +
+        {text}
+      
+ ); + }; + + return ( +
+
+ +
+ + {visibleTabsConfig.map((tab) => ( + + {tab.label} + + ))} + +
+ + +
+ +
+ {mounted && ( +