fix(lens): resolve onboarding review bugs and merge main

This commit is contained in:
moe-berri 2026-10-03 19:59:20 -07:00
commit acae4640ab
66 changed files with 2723 additions and 1139 deletions

View file

@ -2,62 +2,74 @@
Lens reviews recorded activity and saves evidence-linked findings in the LiteLLM dashboard under Observability, Lens (`/ui/lens/`)
## Install the release stack
## Install
Each stable, RC, and dev release containing Lens publishes the worker at the same version on GHCR and Docker Hub. Use the [LiteLLM releases page](https://github.com/BerriAI/litellm/releases) to select a version that includes the coordinated worker release
Build LiteLLM and its worker from the same source commit with the same release identity. The worker runs separately and connects to your gateway using a limited worker token
For a new local installation, install Docker with Compose, download the two release files, and create a private environment file. Replace `X.Y.Z` with the release version, without `v` (RCs use `X.Y.Z-rc.N`)
### New local installation
Install Docker with Compose and Git. This builds LiteLLM and its worker from the same checkout and starts the existing local tracing stack:
```bash
mkdir litellm-lens
cd litellm-lens
LENS_RELEASE=X.Y.Z
curl -fSLo compose.yaml "https://raw.githubusercontent.com/BerriAI/litellm/v${LENS_RELEASE}/deploy/lens/stack.yaml"
curl -fSLo config.yaml "https://raw.githubusercontent.com/BerriAI/litellm/v${LENS_RELEASE}/deploy/lens/config.yaml"
umask 077
printf 'LITELLM_VERSION=%s\nLITELLM_MASTER_KEY=sk-%s\nLITELLM_SALT_KEY=sk-%s\n' \
"$LENS_RELEASE" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env
printf 'POSTGRES_PASSWORD=%s\nCLICKHOUSE_PASSWORD=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" >> .env
docker compose up -d
git clone https://github.com/BerriAI/litellm.git
cd litellm
export LITELLM_RELEASE_TAG="sha-$(git rev-parse HEAD)"
export LENS_WORKER_IMAGE="litellm-lens-worker:${LITELLM_RELEASE_TAG}"
export OPENAI_API_KEY='sk-...'
docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
-f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" .
docker compose -f docker/docker-compose.tracing.yml up -d --build
```
Open `http://localhost:4000/ui/`, log in as `admin` with `LITELLM_MASTER_KEY` from `.env`, and add a model in the dashboard. In Lens, select **Connect worker**, choose that model and a monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?**, copy the worker token, and add `LENS_WORKER_TOKEN=<token>` to `.env`
Open `http://localhost:4002/ui/` and sign in as `admin` with password `sk-1234`. Go to **Lens > Investigations > Connect worker**, choose a model and monthly budget, then **Get install command**. Expand **Using Docker Compose or Helm?** and copy the worker token. In the same terminal, run:
```bash
docker compose --profile lens up -d
export LITELLM_URL=http://litellm:4000
export LENS_WORKER_TOKEN='<paste-your-worker-token>'
docker compose -f docker/docker-compose.tracing.yml -f deploy/lens/compose.yaml up -d
```
The stack starts LiteLLM, PostgreSQL, ClickHouse, and the worker from published images. The dashboard shows **Worker connected**. The worker has a limited token, no database credentials, and no provider keys. The stack exposes only the dashboard on localhost; use your normal ingress and managed databases for a public production deployment
The worker joins the gateway's Docker network, and the dashboard shows **Worker connected**. Save the token privately for restarts and upgrades
Keep `.env` private and preserve its salt key. Keep both named database volumes. To upgrade, wait for active investigations to finish, stop the worker, change only `LITELLM_VERSION`, then pull and recreate the stack:
This stack is for local evaluation: it binds to localhost and uses development database credentials. For a hosted deployment, keep your normal database, keys, networking, and deployment process. Build both images from one source revision with the same `LITELLM_RELEASE_TAG`, publish the worker to your registry, and set `LENS_WORKER_IMAGE` on LiteLLM to that image
### Existing LiteLLM installation
Keep your deployment and PostgreSQL database. A working gateway/worker pair can stay as it is until you upgrade both. For a gateway built from source, use its exact commit and `LITELLM_RELEASE_TAG`; a release version or the latest commit on `main` is not a substitute for that source identity
The public development package is `ghcr.io/berriai/litellm-lens-worker-dev:sha-<full-commit>`. It publishes amd64 images on Lens-related changes, so an arbitrary source commit may have no image. Check the exact image exists before using it. If it is unavailable, your gateway uses a different release identity, or you need native arm64, build the worker from the gateway's checkout:
```bash
docker compose --profile lens stop lens-worker
# Update LITELLM_VERSION in .env to the new release
docker compose --profile lens pull
docker compose --profile lens up -d
export LITELLM_RELEASE_TAG='<gateway-release-identity>'
export LENS_WORKER_IMAGE='<your-registry>/litellm-lens-worker:<your-image-tag>'
docker build --build-arg LITELLM_RELEASE_TAG="$LITELLM_RELEASE_TAG" \
-f deploy/lens/Dockerfile -t "$LENS_WORKER_IMAGE" .
```
This preserves your investigations, findings, model credentials, and worker token. Never use `down -v` during an upgrade. If moving from an existing installation, keep its databases and add the standalone worker instead of creating an empty replacement stack
For a remote worker host, publish that image to a registry the host can pull from. Set the gateway's `LENS_WORKER_IMAGE` to the resulting image reference, restart the gateway using its normal deployment process, then copy its install command. Prefer the published image digest for hosted installations. Do not change the gateway's release identity just to accept another worker
For Kubernetes or Render, run the standalone worker using `LITELLM_URL` and `LENS_WORKER_TOKEN` from setup. Keep existing databases and secrets. The worker needs no inbound port.
## Helm
The componentized `helm/litellm` chart includes an optional Lens worker. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values:
The componentized source chart at `helm/litellm` includes an optional Lens worker. Use the chart from the same checkout as your gateway and keep your component image overrides in your values. Configure PostgreSQL and ClickHouse as usual, install the chart, then obtain a limited worker token from Lens setup. Store it in a Kubernetes Secret and enable the worker in your values:
```yaml
lensWorker:
enabled: true
image:
repository: <your-worker-image-repository>
digest: sha256:<matching-worker-image-digest>
tokenSecret:
name: litellm-lens-worker
key: token
```
Published release charts pin the worker's approved image digest. Source charts without a digest default to the chart's application version. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart so the gateway and worker upgrade together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too
Set the worker repository and digest explicitly to an image built from the gateway's source commit and release identity. The chart connects the worker to the backend service. Keep these values and the Secret when upgrading the chart and update the gateway and worker image overrides together. `lensWorker.replicaCount` controls simultaneous investigations. To use a private registry or external proxy, set `lensWorker.image.repository`, `lensWorker.image.digest` (or `tag` for a source build), and `lensWorker.url`. A digest takes precedence over the tag. The dashboard uses the chart's worker image for standalone install commands too
## Standalone worker
Upgrade your existing LiteLLM proxy to a release that includes Lens with PostgreSQL and agent tracing. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries:
Start with a source deployment that includes Lens, PostgreSQL, and agent tracing, and prepare its matching worker as described above. Configure one ClickHouse URL for trace writes, bounded reads, and Lens queries:
```yaml
general_settings:
@ -74,13 +86,13 @@ Retention changes require a proxy restart. ClickHouse removes expired rows durin
In **Lens > Investigations**, click **Connect worker**, choose an analysis model and monthly limit, then **Get install command**. Use **Advanced options** to select an existing virtual key or change the proxy URL if the server running Docker needs a different network address. Copy the command and run it on your server. The dashboard shows **Worker connected** when the container checks in
The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. No source checkout, environment file, or second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis
The command already contains the compatible worker image and one worker token. The selected virtual key stays on the proxy; its secret is never sent to the worker. Once the matching image is available on the worker host, no second LiteLLM deployment is needed. Keep the command private because it includes the token. The LiteLLM release provides the dashboard and APIs; the container only runs background analysis
The dashboard selects the worker image matching the running gateway release. Release images support Linux amd64 and arm64. CI also publishes `:sha-<commit>` development images; use those only with a gateway built from the same commit and release tag
The dashboard uses the gateway's `LENS_WORKER_IMAGE` override when set. Public `:sha-<commit>` development images must match both the gateway commit and release identity. Build from source for the worker host's native architecture
After upgrading the gateway, update the worker image and redeploy it while keeping its proxy URL and token. Existing containers do not update automatically. If an investigation reports a worker compatibility error, update the image before retrying
For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and `LITELLM_VERSION` (without `v`) in a private environment file. To use another registry, set `LENS_WORKER_IMAGE` to the compatible image instead of setting a version:
For deployments managed with Compose, download `compose.yaml` and provide `LITELLM_URL`, `LENS_WORKER_TOKEN`, and an explicit `LENS_WORKER_IMAGE` in a private environment file:
```bash
docker compose --env-file /path/to/lens.env -f compose.yaml up -d
@ -219,9 +231,9 @@ Upgrades using `--use_prisma_db_push` stop before schema changes if any legacy L
## Release compatibility
Released gateway and worker images carry `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched. During a rolling upgrade, workers wait for a gateway from their release
Gateway and worker builds carry the same `LITELLM_RELEASE_TAG`. A worker announces its release and protocol before claiming an investigation. A mismatch returns HTTP 409 with the required image, leaving queued investigations untouched. During a rolling upgrade, workers wait for a gateway from their release
The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Worker-only Compose accepts `LITELLM_VERSION` (without `v`) or an explicit `LENS_WORKER_IMAGE`. Release workers are available as `ghcr.io/berriai/litellm-lens-worker:vX.Y.Z` and `docker.io/litellm/litellm-lens-worker:vX.Y.Z`, including matching RC/dev suffixes, on amd64 and arm64
The dashboard reads its image from the running gateway. `LENS_WORKER_IMAGE` overrides the registry/image for private deployments. Set an explicit `LENS_WORKER_IMAGE` for worker-only Compose. Verify that the image exists and matches the gateway before deploying it
For source development, use `make lens-dev`, which gives the proxy and source worker the same commit identity. For custom containers, build both from the same checkout with `--build-arg LITELLM_RELEASE_TAG=sha-$(git rev-parse HEAD)` and set the proxy's `LENS_WORKER_IMAGE` to the worker image you built. An unlabelled custom build refuses worker setup and claims instead of guessing from the Python package version. Normal package-index installations use their installed release version

View file

@ -1,6 +1,6 @@
services:
lens-worker:
image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION:?Set LITELLM_VERSION to the gateway release, without the v prefix}}
image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}}
environment:
LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container}
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI}

View file

@ -5,6 +5,8 @@ services:
build:
context: ..
target: runtime
args:
LITELLM_RELEASE_TAG: ${LITELLM_RELEASE_TAG:-}
command: ["--config", "/app/tracing-config.yaml", "--port", "4000"]
environment:
LITELLM_MASTER_KEY: sk-1234
@ -15,6 +17,7 @@ services:
CLICKHOUSE_URL: http://default:local-tracing@clickhouse:8123
CLICKHOUSE_DATABASE: litellm
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LENS_WORKER_IMAGE: ${LENS_WORKER_IMAGE:-}
volumes:
- ./tracing-config.yaml:/app/tracing-config.yaml:ro
ports:

View file

@ -1,6 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
worker_image() {
env -u LENS_WORKER_IMAGE -u LITELLM_VERSION \
LITELLM_URL=http://litellm:4000 LENS_WORKER_TOKEN=config-test "$@" \
docker compose --env-file /dev/null -f deploy/lens/compose.yaml config --images
}
[[ "$(worker_image LENS_WORKER_IMAGE=registry.example/lens:source)" == registry.example/lens:source ]]
[[ "$(worker_image LITELLM_VERSION=1.2.3)" == ghcr.io/berriai/litellm-lens-worker:v1.2.3 ]]
[[ "$(worker_image LENS_WORKER_IMAGE=registry.example/lens:source LITELLM_VERSION=1.2.3)" == registry.example/lens:source ]]
if worker_image > /dev/null 2>&1; then
printf 'Worker Compose accepted neither an image nor a release version\n' >&2
exit 1
fi
qa_dir=$(mktemp -d)
master_key="sk-$(openssl rand -hex 32)"
compose=(docker compose -p lens-compose-ci --env-file "$qa_dir/env" -f deploy/lens/stack.yaml)

View file

@ -99,6 +99,11 @@ const eslintConfig = [
],
rules: { "local/no-ad-hoc-z-index": ["error", { allowPopupLayer: true }] },
},
{
files: ["src/components/view_logs/TraceView/**/*.tsx", "src/components/lens/**/*.tsx"],
ignores: ["src/**/*.test.tsx"],
rules: { "local/no-arbitrary-design-value": "error" },
},
{
files: ["tests/eslint-rules/**/*.{ts,tsx}"],
rules: { "local/no-noop-hover-variant": "off", "local/no-ad-hoc-z-index": "off" },

View file

@ -12,7 +12,14 @@ const nextConfig = {
? {
async rewrites() {
return {
beforeFiles: [{ source: "/ui/:path*", destination: "/:path*" }],
beforeFiles: [
{
source: "/:path*",
has: [{ type: "header", key: "content-type", value: "application/json.*" }],
destination: `${devProxyUrl}/:path*`,
},
{ source: "/ui/:path*", destination: "/:path*" },
],
fallback: [{ source: "/:path*", destination: `${devProxyUrl}/:path*` }],
};
},

View file

@ -10,6 +10,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.92.0",
"@base-ui/react": "^1.6.0",
"@handlewithcare/react-prosemirror": "3.2.9",
"@headlessui/tailwindcss": "0.2.2",
"@heroicons/react": "1.0.6",
"@hookform/resolvers": "5.4.0",
@ -17,6 +18,7 @@
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "5.100.7",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.13",
"@types/papaparse": "5.5.2",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
@ -32,18 +34,24 @@
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"prosemirror-model": "1.25.12",
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.42.3",
"react": "19.2.8",
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.6",
"react-hook-form": "7.82.0",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"uuid": "14.0.0",
"zod": "4.6.5"
},
@ -1344,6 +1352,40 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@handlewithcare/react-prosemirror": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@handlewithcare/react-prosemirror/-/react-prosemirror-3.2.9.tgz",
"integrity": "sha512-zNGR4BDAvXQGY0ph2ZVK/wDdOr3nL3kTjtXv9Bwwmvzy39Gtooc+vQHSHRtWg0AoN33csJgg0ev78dtoScAR9g==",
"license": "Apache-2.0",
"dependencies": {
"classnames": "^2.5.1"
},
"engines": {
"node": ">=16.9"
},
"peerDependencies": {
"@tiptap/core": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"@tiptap/react": "^3.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "1.42.3",
"react": ">=17 <20",
"react-dom": ">=17 <20",
"react-reconciler": ">=0.26.1 <=0.33.0"
},
"peerDependenciesMeta": {
"@tiptap/core": {
"optional": true
},
"@tiptap/pm": {
"optional": true
},
"@tiptap/react": {
"optional": true
}
}
},
"node_modules/@headlessui/tailwindcss": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz",
@ -3499,6 +3541,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.13",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz",
"integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
@ -3522,6 +3581,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.11",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -5161,6 +5230,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -8450,6 +8525,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -9966,6 +10047,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@ -10314,6 +10401,46 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -10379,6 +10506,21 @@
"react": "^19.2.8"
}
},
"node_modules/react-error-boundary": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.6.tgz",
"integrity": "sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-hook-form": {
"version": "7.82.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz",
@ -10455,6 +10597,21 @@
"react": ">=18"
}
},
"node_modules/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
@ -12117,6 +12274,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",

View file

@ -27,6 +27,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.92.0",
"@base-ui/react": "^1.6.0",
"@handlewithcare/react-prosemirror": "3.2.9",
"@headlessui/tailwindcss": "0.2.2",
"@heroicons/react": "1.0.6",
"@hookform/resolvers": "5.4.0",
@ -34,6 +35,7 @@
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "5.100.7",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.13",
"@types/papaparse": "5.5.2",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
@ -49,18 +51,24 @@
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"prosemirror-model": "1.25.12",
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.42.3",
"react": "19.2.8",
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.6",
"react-hook-form": "7.82.0",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"uuid": "14.0.0",
"zod": "4.6.5"
},

View file

@ -4,6 +4,7 @@ import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs";
import filenamePascalCase from "./filename-pascal-case.mjs";
import noNoopHoverVariant from "./no-noop-hover-variant.mjs";
import noAdHocZIndex from "./no-ad-hoc-z-index.mjs";
import noArbitraryDesignValue from "./no-arbitrary-design-value.mjs";
const plugin = {
rules: {
@ -13,6 +14,7 @@ const plugin = {
"filename-pascal-case": filenamePascalCase,
"no-noop-hover-variant": noNoopHoverVariant,
"no-ad-hoc-z-index": noAdHocZIndex,
"no-arbitrary-design-value": noArbitraryDesignValue,
},
};

View file

@ -1,26 +1,7 @@
import { utilityOf } from "./tailwind-utility.mjs";
const AD_HOC_Z = /^-?z-(?:\d+|\[[^\]]*\]|\([^)]*\))$/;
const OPENERS = { "[": "]", "(": ")" };
const utilityOf = (token) => {
const closers = [];
const lastTopLevelColon = [...token].reduce((found, ch, i) => {
if (closers.length > 0 && ch === closers[closers.length - 1]) {
closers.pop();
return found;
}
if (ch in OPENERS) {
closers.push(OPENERS[ch]);
return found;
}
return ch === ":" && closers.length === 0 ? i : found;
}, -1);
return token
.slice(lastTopLevelColon + 1)
.replace(/^!/, "")
.replace(/!$/, "");
};
const classify = (token, allowPopupLayer) => {
const utility = utilityOf(token);
if (AD_HOC_Z.test(utility)) return "adHoc";

View file

@ -0,0 +1,42 @@
import { utilityOf } from "./tailwind-utility.mjs";
const ARBITRARY_SCALE = /^(?:text|tracking|leading|rounded(?:-[a-z]+)?|border(?:-[a-z]+)?)-\[/;
const ARBITRARY_PROPERTY = /^\[[a-z-]+:/;
const isOffending = (token) => {
const utility = utilityOf(token);
return ARBITRARY_SCALE.test(utility) || ARBITRARY_PROPERTY.test(utility);
};
const rule = {
meta: {
type: "problem",
docs: {
description:
"Disallow arbitrary font size, tracking, leading, radius and border values, and arbitrary CSS properties. Use the theme scale so surfaces share one type and shape system.",
},
schema: [],
messages: {
arbitrary:
"`{{token}}` bypasses the theme scale. Use a scale utility (text-xs/sm, leading-*, tracking-*, rounded-sm/md/lg, border/border-2) instead.",
},
},
create(context) {
const check = (node, value) => {
if (typeof value !== "string" || !value.includes("[")) return;
for (const token of value.split(/\s+/).filter(isOffending)) {
context.report({ node, messageId: "arbitrary", data: { token } });
}
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
};
},
};
export default rule;

View file

@ -0,0 +1,20 @@
const OPENERS = { "[": "]", "(": ")" };
export const utilityOf = (token) => {
const closers = [];
const lastTopLevelColon = [...token].reduce((found, ch, i) => {
if (closers.length > 0 && ch === closers[closers.length - 1]) {
closers.pop();
return found;
}
if (ch in OPENERS) {
closers.push(OPENERS[ch]);
return found;
}
return ch === ":" && closers.length === 0 ? i : found;
}, -1);
return token
.slice(lastTopLevelColon + 1)
.replace(/^!/, "")
.replace(/!$/, "");
};

View file

@ -58,6 +58,39 @@ beforeEach(() => {
});
describe("Lens setup journey", () => {
it.each(["/lens", "/lens/activity/available"])(
"keeps recorded traces visible while %s is pending",
async (pendingPath) => {
serve({ enabled: true, traces: true });
const normal = network.getMockImplementation()!;
network.mockImplementation((input, init) =>
new URL(String(input), "http://localhost").pathname === pendingPath
? new Promise<Response>(() => {})
: normal(input, init),
);
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />);
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
},
);
it.each(["/v1/traces", "/lens/activity/available"])(
"opens a saved investigation while %s is pending",
async (pendingPath) => {
serve();
list.mockResolvedValue({ lenses: data.lenses, workers: [worker()], tracing_enabled: false });
const normal = network.getMockImplementation()!;
network.mockImplementation((input, init) =>
new URL(String(input), "http://localhost").pathname === pendingPath
? new Promise<Response>(() => {})
: normal(input, init),
);
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: `?lens=${data.lenses[0].id}`,
});
expect(await screen.findByRole("heading", { name: data.lenses[0].settings.name })).toBeVisible();
},
);
it("shares the introduction across tabs and stays in setup after the first trace", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
@ -108,7 +141,7 @@ describe("Lens setup journey", () => {
});
it("allows request-only investigations without forcing agent instrumentation", async () => {
serve({ enabled: true, requests: true });
serve({ requests: true });
const user = userEvent.setup();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=investigations",
@ -118,8 +151,18 @@ describe("Lens setup journey", () => {
expect(screen.queryByRole("heading", { name: "Before you start" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set up Lens" }));
expect(await screen.findByRole("heading", { name: "Before you start" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "View investigations" }));
expect(await screen.findByRole("heading", { name: "Run your first investigation" })).toBeVisible();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: /Enable tracing on the gateway/ }));
expect(screen.getByRole("button", { name: "Continue with request logs" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: /Send your first trace/ }));
await user.click(screen.getByRole("button", { name: "Continue with request logs" }));
const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" }));
await user.click(connection.getByRole("combobox", { name: "Analysis model" }));
await user.click(await screen.findByRole("option", { name: "analysis" }));
await user.click(connection.getByRole("button", { name: "Get install command" }));
const connected = within(await screen.findByRole("dialog", { name: "Worker connected" }));
await user.click(connected.getByRole("button", { name: "New investigation" }));
expect(await screen.findByRole("dialog", { name: "Which activity should we investigate?" })).toBeVisible();
});
it("keeps setup recoverable when checking for a first trace fails", async () => {
@ -159,33 +202,50 @@ describe("Lens setup journey", () => {
);
});
it("opens the saved investigation directly when guided setup finishes", async () => {
serve({ enabled: true, traces: true, connected: true });
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?setup=lens",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: "New investigation" }));
const dialog = within(screen.getByRole("dialog"));
fireEvent.change(dialog.getByRole("textbox", { name: "Investigation name" }), {
target: { value: "My first review" },
});
await user.click(dialog.getByRole("button", { name: "Continue" }));
await user.click(dialog.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(dialog.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
await user.click(dialog.getByRole("button", { name: "Run and monitor" }));
expect(await screen.findByRole("heading", { name: "My first review" })).toBeVisible();
expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument();
expect(
within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" }),
).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBeNull());
const create = network.mock.calls.find(
([input, init]) => new URL(String(input), "http://localhost").pathname === "/lens" && init?.method === "POST",
);
expect(create).toBeDefined();
expect(JSON.parse(String(create?.[1]?.body))).toEqual(expect.objectContaining({ name: "My first review" }));
});
it.each(["traces", "requests with trace errors", "requests with pending traces", "traces with activity errors"])(
"finishes guided setup with %s and opens the saved investigation",
async (scenario) => {
const source = scenario.startsWith("requests") ? "requests" : "traces";
const activity = { enabled: true, traces: source === "traces", requests: source === "requests", connected: true };
serve(activity);
const normal = network.getMockImplementation()!;
const failingPath = scenario === "requests with trace errors" ? "/v1/traces" : "/lens/activity/available";
network.mockImplementation((input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces" && scenario === "requests with pending traces")
return new Promise<Response>(() => {});
if (scenario.endsWith("errors") && path === failingPath)
return Promise.resolve(Response.json({ detail: "Activity unavailable" }, { status: 503 }));
return normal(input, init);
});
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?setup=lens",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: "New investigation" }));
const dialog = within(screen.getByRole("dialog"));
fireEvent.change(dialog.getByRole("textbox", { name: "Investigation name" }), {
target: { value: "My first review" },
});
await user.click(dialog.getByRole("button", { name: "Continue" }));
await user.click(dialog.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(dialog.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
await user.click(dialog.getByRole("button", { name: "Run and monitor" }));
expect(await screen.findByRole("heading", { name: "My first review" })).toBeVisible();
expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument();
expect(
within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" }),
).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBeNull());
const create = network.mock.calls.find(
([input, init]) => new URL(String(input), "http://localhost").pathname === "/lens" && init?.method === "POST",
);
expect(create).toBeDefined();
expect(JSON.parse(String(create?.[1]?.body))).toEqual(
expect.objectContaining({ name: "My first review", source }),
);
},
);
});

View file

@ -1,7 +1,7 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./demo/createLensDemo";
@ -34,29 +34,27 @@ describe("Lens interactive demo", () => {
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Explore with sample data" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.getByText("You’re viewing demo data")).toBeVisible();
expect(screen.getByRole("switch", { name: "Demo data" })).toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
network.mockClear();
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(screen.getByText("Where is order #1042?")).toBeVisible();
fireEvent.change(screen.getByPlaceholderText("Search input or trace ID"), { target: { value: "headphones" } });
const search = screen.getByRole("combobox", { name: "Search runs" });
await user.type(search, "headphones");
await waitFor(() => expect(screen.queryByText("Where is order #1042?")).not.toBeInTheDocument());
expect(screen.getByText("Can I return my headphones?")).toBeVisible();
expect(screen.queryByText("Where is order #1042?")).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "" } });
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by agent" }), "support_agent");
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed");
expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4);
await expectUrl(onUrlUpdate, (url) => expect(url.get("agent")).toBe("support_agent"));
await expectUrl(onUrlUpdate, (url) => expect(url.get("status")).toBe("error"));
await user.clear(search);
await user.type(search, "agent:support_agent status:error");
const table = screen.getByRole("table", { name: "Agent runs" });
await waitFor(() => expect(within(table).getAllByRole("row")).toHaveLength(4));
await expectUrl(onUrlUpdate, (url) => expect(url.get("q")).toBe("agent:support_agent status:error"));
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("row", { name: /Support quality/ })).toBeVisible();
expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument();
expect(network).not.toHaveBeenCalled();
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByText(/Investigations require proxy administrator access/)).toBeVisible();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(screen.queryByText("Can I return my headphones?")).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Demo data" })).not.toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect([...url.entries()]).toEqual([["tab", "investigations"]]));
@ -69,7 +67,7 @@ describe("Lens interactive demo", () => {
searchParams: "?demo=true",
onUrlUpdate,
});
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
expect(await screen.findByRole("switch", { name: "Demo data" })).toBeChecked();
await user.click(await screen.findByText("Where is order #1042?"));
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
await expectUrl(onUrlUpdate, (url) => expect(url.get("trace")).toBeTruthy());
@ -179,9 +177,7 @@ describe("Lens interactive demo", () => {
await user.click(within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Traces" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set up tracing" }));
expect(await screen.findByRole("heading", { name: "Connect another agent" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
});
it("offers sample data in the main panel only for the active tab that needs setup", async () => {
@ -206,4 +202,25 @@ describe("Lens interactive demo", () => {
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
});
it("marks the Investigations tab while a scan runs and clears it once the scan finishes", async () => {
const saved = createLensDemoData().lenses[0];
const withJob = (status: (typeof saved.jobs)[number]["status"]) => ({
...saved,
jobs: [{ ...saved.jobs[0], status }, ...saved.jobs.slice(1)],
});
const lenses = vi.fn(() => [withJob("running")]);
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: lenses(), workers: [], tracing_enabled: false });
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
return Response.json({ data: [], traces: false, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
const tab = within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" });
await waitFor(() => expect(tab).toHaveAccessibleDescription("An investigation is running"));
lenses.mockReturnValue([withJob("completed")]);
await testQueryClient.refetchQueries({ queryKey: ["lens", "list"] });
await waitFor(() => expect(tab).toHaveAccessibleDescription(""));
});
});

View file

@ -1,8 +1,9 @@
"use client";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { Activity, Aperture, Info, Loader2, ScanSearch } from "lucide-react";
import { Activity, Aperture, ArrowUpRight, Loader2, ScanSearch } from "lucide-react";
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent } from "@/components/ui/tabs";
@ -11,6 +12,10 @@ import { LensPreviewContext } from "./LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { createLensDemo } from "./demo/createLensDemo";
import { lensQueries } from "./api/queries";
import { useLensApi } from "./services";
import { investigationActivity, type InvestigationActivity } from "./model/status";
import { cn } from "@/lib/cva.config";
import { LENS_TABS, useLensRoute, type LensTab } from "./route";
import { Button } from "@/components/ui/button";
import { LensGettingStarted } from "./setup/LensGettingStarted";
@ -35,45 +40,112 @@ function SampleSession() {
function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolean) => void }) {
const id = useId();
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{demo && (
<p role="status" className="flex items-center gap-1.5 font-medium text-info">
<Info aria-hidden="true" className="size-3.5 shrink-0" />
You’re viewing demo data
</p>
)}
<div className={cn("flex items-center gap-2 text-xs", demo ? "font-medium text-info" : "text-muted-foreground")}>
<label htmlFor={id}>Demo data</label>
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} />
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} className="data-checked:bg-info" />
</div>
);
}
const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const;
function LensModeSwitch() {
const ACTIVITY_DOT: Record<Exclude<InvestigationActivity, "idle">, { className: string; label: string }> = {
running: { className: "bg-info motion-safe:animate-pulse", label: "An investigation is running" },
queued: { className: "bg-muted-foreground/60", label: "An investigation is queued" },
};
function ActivityDot({ activity }: { activity: InvestigationActivity }) {
if (activity === "idle") return null;
return (
<TabsPrimitive.List
aria-label="Lens"
className="relative inline-flex h-8 items-center rounded-full border border-border bg-muted/60 p-0.5"
>
<TabsPrimitive.Indicator className="absolute top-0.5 bottom-0.5 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-200 ease-out motion-reduce:transition-none" />
{Object.entries(LENS_TABS).map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
return (
<TabsPrimitive.Tab
key={view}
value={view}
className="relative z-raised inline-flex h-full items-center gap-1.5 rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
>
<Icon aria-hidden="true" className="size-3.5" />
{label}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full animate-in fade-in-0 zoom-in-50 duration-300",
ACTIVITY_DOT[activity].className,
)}
/>
);
}
const FRAME = {
live: {
card: "border border-foreground/15",
tab: "-mb-px border-x border-t border-foreground/15",
corner: { left: "border-r border-b", right: "border-l border-b", tone: "border-foreground/15" },
},
demo: {
card: "border-2 border-info",
tab: "-mb-0.5 border-x-2 border-t-2 border-info",
corner: { left: "border-r-2 border-b-2", right: "border-l-2 border-b-2", tone: "border-info" },
},
} as const;
const frameOf = (demo: boolean) => FRAME[demo ? "demo" : "live"];
/** Inverted corner joining the tab's side border to the card's top border; plain CSS borders so both snap to the same pixels. */
function NotchCorner({ side, demo }: { side: "left" | "right"; demo: boolean }) {
const { corner } = frameOf(demo);
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-0 size-3 overflow-hidden",
side === "left" ? "-left-3" : "-right-3",
)}
>
<span
className={cn(
"block size-full shadow-[0_0_0_12px_var(--card)]",
side === "left" ? "rounded-br-xl" : "rounded-bl-xl",
corner[side],
corner.tone,
)}
/>
</span>
);
}
function LensModeSwitch({ activity, demo }: { activity: InvestigationActivity; demo: boolean }) {
return (
<div className={cn("relative z-raised rounded-t-2xl bg-card px-1.5 pt-1.5 pb-[7px]", frameOf(demo).tab)}>
<NotchCorner side="left" demo={demo} />
<NotchCorner side="right" demo={demo} />
<TabsPrimitive.List
aria-label="Lens"
className="relative inline-flex h-9 items-center rounded-full bg-muted/70 p-1"
>
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
{Object.entries(LENS_TABS).map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
return (
<TabsPrimitive.Tab
key={view}
value={view}
aria-description={
view === "investigations" && activity !== "idle" ? ACTIVITY_DOT[activity].label : undefined
}
className="relative z-raised inline-flex h-full items-center gap-2 rounded-full px-4 text-sm font-medium text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
>
<Icon aria-hidden="true" className="size-4" />
{label}
{view === "investigations" && <ActivityDot activity={activity} />}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
</div>
);
}
function useInvestigationActivity(accessToken: string, enabled: boolean): InvestigationActivity {
const api = useLensApi(accessToken);
const { data } = useQuery({ ...lensQueries.list(api, false), enabled });
return investigationActivity(data?.lenses ?? []);
}
const PANEL =
"flex min-h-0 flex-1 flex-col overflow-y-auto animate-in fade-in-0 duration-300 motion-reduce:animate-none";
function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
const { tab, lensId, demo, settingUp, setTab, setLensId, setDemo, setSetup } = useLensRoute();
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
@ -90,23 +162,23 @@ function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
setSetup(false);
setTab(setupState.tracesReady ? "traces" : "investigations");
};
const checkingSetup = !demo && setupState.loading;
const showCreated = (id: string) => {
setSetup(false);
setLensId(id);
setTab("investigations");
};
const activity = useInvestigationActivity(accessToken, canInvestigate);
const preview = (view: LensTab) => ({
target: previewTarget,
open: !demo && activeTab === view ? () => setDemo(true) : undefined,
});
const setupContent = checkingSetup ? (
const setupContent = setupState.loading ? (
<p role="status" className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
Checking Lens setup…
</p>
) : (
<TabsContent value={activeTab} keepMounted className="min-h-0 overflow-y-auto">
<TabsContent value={activeTab} keepMounted className={cn(PANEL, "p-4")}>
<LensGettingStarted
accessToken={accessToken}
state={setupState}
@ -121,17 +193,29 @@ function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
</TabsContent>
);
return (
<main className="flex h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-2">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-4 pt-3 pb-4">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
<div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3">
<div className="flex min-w-0 flex-col gap-1 pb-3">
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
Lens
</h1>
<LensModeSwitch />
<p className="truncate text-xs text-muted-foreground">
Trace your agents and investigate what goes wrong.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/lens"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
>
Docs
<ArrowUpRight aria-hidden="true" className="size-3" />
</a>
</p>
</div>
<div className="flex items-center gap-3">
<LensModeSwitch activity={activity} demo={demo} />
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
{showSetupButton && (
<Button variant="outline" size="sm" onClick={startSetup}>
Set up Lens
@ -141,37 +225,39 @@ function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
<DemoToggle demo={demo} onChange={setDemo} />
</div>
</div>
{checkingSetup || showSetup ? (
setupContent
) : (
<>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col overflow-y-auto">
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className="flex min-h-0 flex-col overflow-y-auto">
<LensPreviewContext.Provider value={preview("investigations")}>
{isProxyAdminTierRole(userRole) ? (
<InvestigationsView
active={activeTab === "investigations"}
<div className={cn("flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-card", frameOf(demo).card)}>
{showSetup ? (
setupContent
) : (
<>
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</>
)}
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={cn(PANEL, "p-4")}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canInvestigate ? (
<InvestigationsView
active={activeTab === "investigations"}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</>
)}
</div>
</Tabs>
</main>
);

View file

@ -1,6 +1,6 @@
"use client";
import { Fragment, useState, type ReactNode } from "react";
import { Fragment, useState, type KeyboardEvent, type ReactNode } from "react";
import {
ChevronRight,
Circle,
@ -26,16 +26,22 @@ import { scopeLabel } from "../model/format";
import { type Finding, type Lens } from "../model/types";
import { useListSearchRoute } from "../route";
const PRIORITY_COLOR = { high: "text-[#e5484d]", medium: "text-amber-500", low: "text-muted-foreground" } as const;
const PRIORITY_COLOR = { high: "text-destructive", medium: "text-amber-500", low: "text-muted-foreground" } as const;
const ROW =
"border-b border-border/60 transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none";
const META = "truncate text-[11px] text-muted-foreground";
const META = "truncate text-xs text-muted-foreground";
function openOnRowKeyDown(event: KeyboardEvent<HTMLTableRowElement>, open: () => void) {
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
open();
}
function JobIcon({ lens }: { lens: Lens }) {
const status = lens.jobs[0]?.status;
const className = "size-4 shrink-0";
if (status === "queued" || status === "running")
return <CircleDashed aria-hidden="true" className={cn(className, "text-[#3b5bfd]")} />;
return <CircleDashed aria-hidden="true" className={cn(className, "text-info")} />;
if (status === "failed") return <CircleX aria-hidden="true" className={cn(className, "text-destructive")} />;
if (status === "cancelled")
return <CircleSlash aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
@ -47,7 +53,7 @@ function TwoLine({ meta, title, className }: { meta: ReactNode; title: ReactNode
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className={META}>{meta}</span>
<span className={cn("truncate text-[13px] text-foreground", className)}>{title}</span>
<span className={cn("truncate text-sm text-foreground", className)}>{title}</span>
</span>
);
}
@ -56,6 +62,7 @@ export function InvestigationList({
lenses,
connected,
readOnly = false,
onOpen,
onEdit,
onRunNow,
onOpenFinding,
@ -63,6 +70,7 @@ export function InvestigationList({
lenses: Lens[];
connected: boolean;
readOnly?: boolean;
onOpen: (id: string) => void;
onEdit: (id: string) => void;
onRunNow: (id: string) => void;
onOpenFinding: (lens: Lens, finding: Finding) => void;
@ -87,7 +95,7 @@ export function InvestigationList({
<Input
aria-label="Search investigations"
placeholder="Search investigations"
className="h-7 bg-background pl-8 text-[12px]"
className="h-7 bg-background pl-8 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -112,9 +120,11 @@ export function InvestigationList({
return (
<Fragment key={lens.id}>
<tr
onClick={readOnly ? undefined : () => onEdit(lens.id)}
onClick={() => onOpen(lens.id)}
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpen(lens.id))}
tabIndex={0}
aria-label={lens.settings.name}
className={cn(ROW, "group h-14", !readOnly && "cursor-pointer")}
className={cn(ROW, "group h-14 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
>
<td className="pl-2">
<span className="flex min-w-0 items-center gap-2">
@ -149,7 +159,7 @@ export function InvestigationList({
</td>
<td
className={cn(
"w-[180px] truncate px-3 text-right text-[12px]",
"w-[180px] truncate px-3 text-right text-xs",
latest?.status === "failed" ? "text-destructive" : "text-muted-foreground",
)}
>
@ -159,14 +169,14 @@ export function InvestigationList({
{findings.length > 0 && (
<span
title={`${findings.length} open ${findings.length === 1 ? "finding" : "findings"}`}
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-[11px] tabular-nums text-foreground"
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground"
>
{findings.length}
</span>
)}
</td>
<td
className="w-[120px] px-3 text-right text-[12px] text-muted-foreground"
className="w-[120px] px-3 text-right text-xs text-muted-foreground"
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
>
{latest ? agoLabel(Date.parse(latest.created_at), now) : "never run"}
@ -186,12 +196,18 @@ export function InvestigationList({
>
<Play className="size-3.5" />
</button>
<span
aria-hidden="true"
className="inline-flex size-7 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground"
<button
type="button"
aria-label={`Edit ${lens.settings.name}`}
title="Edit"
onClick={(event) => {
event.stopPropagation();
onEdit(lens.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground/60 hover:bg-muted hover:text-foreground group-hover:text-muted-foreground"
>
<Pencil className="size-3.5" />
</span>
</button>
</span>
)}
</td>
@ -205,8 +221,10 @@ export function InvestigationList({
<tr
key={finding.id}
onClick={() => onOpenFinding(lens, finding)}
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpenFinding(lens, finding))}
tabIndex={0}
aria-label={finding.title}
className={cn(ROW, "h-12 cursor-pointer")}
className={cn(ROW, "h-12 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
>
<td className="pl-2" title={finding.suggestion ? `Fix: ${finding.suggestion}` : undefined}>
<span className="flex min-w-0 items-center gap-2">
@ -227,12 +245,12 @@ export function InvestigationList({
/>
</span>
</td>
<td className="px-3 text-right text-[12px] text-muted-foreground">
<td className="px-3 text-right text-xs text-muted-foreground">
{runs} {runs === 1 ? "run" : "runs"}
</td>
<td />
<td
className="px-3 text-right text-[12px] text-muted-foreground"
className="px-3 text-right text-xs text-muted-foreground"
title={formatActivityTimestamp(finding.last_seen)}
>
{agoLabel(Date.parse(finding.last_seen), now)}
@ -249,12 +267,10 @@ export function InvestigationList({
</tbody>
</table>
{!shown.length && (
<div className="py-16 text-center text-[12px] text-muted-foreground">
No investigations match your search.
</div>
<div className="py-16 text-center text-xs text-muted-foreground">No investigations match your search.</div>
)}
</div>
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-xs text-muted-foreground">
{shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "}
{lenses.filter((l) => l.settings.enabled).length} watching
</footer>

View file

@ -1,45 +1,127 @@
"use client";
import { Button } from "@/components/ui/button";
import type { ReactNode } from "react";
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { ApiError } from "@/lib/http/client";
export function InvestigationError({
error,
queryError,
refresh,
const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens";
function CenteredState({
role,
icon,
tone = "muted",
title,
description,
children,
}: {
error: string;
queryError: unknown;
refresh: () => void;
role: "status" | "alert";
icon: ReactNode;
tone?: "muted" | "destructive";
title: string;
description: ReactNode;
children?: ReactNode;
}) {
const unavailable = queryError instanceof ApiError && queryError.status === 404;
const queryMessage = queryError instanceof Error ? queryError.message : undefined;
return (
<div role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">
{unavailable
? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment."
: error || queryMessage}
<Button variant="ghost" size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
{unavailable ? "Reload page" : "Retry"}
<div
role={role}
className="m-auto flex max-w-sm flex-col items-center gap-3 py-16 text-center animate-in fade-in-0 duration-300 motion-reduce:animate-none"
>
<span
aria-hidden="true"
className={cn(
"flex size-10 items-center justify-center rounded-full",
tone === "destructive" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
)}
>
{icon}
</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children && <div className="mt-1 flex items-center gap-2">{children}</div>}
</div>
);
}
function DocsLink() {
return (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Lens docs
<ArrowUpRight aria-hidden="true" className="size-3.5" />
</a>
);
}
function loadFailureMessage(queryError: unknown, unavailable: boolean): string {
if (unavailable)
return "This dashboard may be newer than the proxy. Reload the page, and if it persists, check the proxy deployment.";
if (queryError instanceof Error) return queryError.message;
return "Something went wrong while contacting the proxy.";
}
export function InvestigationsLoadFailed({ queryError, refresh }: { queryError: unknown; refresh: () => void }) {
const unavailable = queryError instanceof ApiError && queryError.status === 404;
return (
<CenteredState
role="alert"
tone="destructive"
icon={<TriangleAlert className="size-5" />}
title={unavailable ? "Lens API is unavailable" : "Couldn't load investigations"}
description={loadFailureMessage(queryError, unavailable)}
>
<Button size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
{unavailable ? "Reload page" : "Try again"}
</Button>
<DocsLink />
</CenteredState>
);
}
export function InvestigationError({ message, refresh }: { message: string; refresh: () => void }) {
return (
<div
role="alert"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<span className="min-w-0">{message}</span>
<Button variant="ghost" size="sm" onClick={refresh}>
Retry
</Button>
</div>
);
}
export function InvestigationsLoading() {
return (
<p role="status" className="py-8 text-sm text-muted-foreground">
Loading investigations…
</p>
<CenteredState
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading investigations…"
description="Fetching your investigations and their latest findings."
/>
);
}
export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) {
return (
<div role="alert" className="text-sm">
This investigation was not found.{" "}
<Button variant="link" onClick={() => selectLens(null)}>
<CenteredState
role="alert"
icon={<SearchX className="size-5" />}
title="Investigation not found"
description="It may have been deleted, or the link points to a different proxy."
>
<Button size="sm" onClick={() => selectLens(null)}>
View all investigations
</Button>
</div>
</CenteredState>
);
}

View file

@ -480,6 +480,24 @@ it("allows retrying a failed trace readiness check without treating it as an emp
expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible();
});
it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const list = vi
.fn()
.mockRejectedValueOnce(new ApiError("Proxy timed out", 504, {}))
.mockResolvedValue({ lenses: [], workers: [], tracing_enabled: true });
vi.mocked(apiClient.get).mockImplementation(async (path) => (path === "/lens" ? list() : { data: [] }));
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Couldn't load investigations");
expect(alert).toHaveTextContent("Proxy timed out");
await user.click(within(alert).getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps saved investigations accessible when tracing is disabled", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
@ -583,6 +601,8 @@ it("lists investigations without edit or run controls for read-only viewers", as
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path === "/lens/lens/runs") return [];
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
@ -590,6 +610,67 @@ it("lists investigations without edit or run controls for read-only viewers", as
const row = await screen.findByRole("row", { name: lens.settings.name });
expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument();
await user.click(row);
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens investigations from the keyboard without treating nested edit keys as row activation", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path === "/lens/lens/runs") return [];
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
const row = await screen.findByRole("row", { name: lens.settings.name });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back" }));
const editButton = await screen.findByRole("button", { name: `Edit ${lens.settings.name}` });
editButton.focus();
expect(editButton).toHaveFocus();
await user.keyboard("{Enter}");
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
expect(screen.queryByRole("heading", { level: 2, name: lens.settings.name })).not.toBeInTheDocument();
});
it("opens a failed investigation's details from its row and edits only from the pencil", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const job = {
...lens.jobs[0],
id: "failed-run",
status: "failed" as const,
stage: "Failed",
error: "boom",
findings: [],
};
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path === "/lens/lens/runs") return [job];
if (path === "/lens/lens/runs/failed-run") return job;
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
await user.click(await screen.findByRole("button", { name: `Edit ${lens.settings.name}` }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await user.click(screen.getByRole("row", { name: lens.settings.name }));
expect(within(await screen.findByRole("alert")).getByLabelText("Investigation error")).toHaveTextContent("boom");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

View file

@ -1,5 +1,10 @@
"use client";
import { InvestigationMissing, InvestigationsLoading, InvestigationError } from "./InvestigationStatus";
import {
InvestigationMissing,
InvestigationsLoadFailed,
InvestigationsLoading,
InvestigationError,
} from "./InvestigationStatus";
import { InvestigationNavigation } from "./InvestigationNavigation";
import { useInvestigationResults } from "./useInvestigationResults";
@ -175,7 +180,11 @@ export function InvestigationsView({
setEditing={setEditing}
/>
)}
{(error || query.error) && <InvestigationError error={error} queryError={query.error} refresh={refresh} />}
{query.error && !query.data ? (
<InvestigationsLoadFailed queryError={query.error} refresh={refresh} />
) : (
(error || query.error) && <InvestigationError message={error || query.error?.message || ""} refresh={refresh} />
)}
{query.isLoading && <InvestigationsLoading />}
{loaded && showEmpty && (
<InvestigationsWelcome
@ -228,6 +237,7 @@ export function InvestigationsView({
lenses={lenses}
connected={connected}
readOnly={readOnly}
onOpen={selectLens}
onEdit={(id) => openDialog("edit", id)}
onRunNow={(id) => openDialog("run_now", id)}
onOpenFinding={openFinding}

View file

@ -49,7 +49,7 @@ export function InvestigationsWelcome({
className: "col-start-2 w-fit sm:col-start-auto",
});
return (
<section aria-labelledby="lens-welcome" className="max-w-3xl pb-6">
<section aria-labelledby="lens-welcome" className="m-auto w-full max-w-2xl py-10">
{showPreview && <LensPreviewButton />}
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
{introduction.title}

View file

@ -11,8 +11,8 @@ import type { Job } from "../model/types";
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
const STEP_TONE = {
model: "text-foreground",
stage: "text-[#0011b3] dark:text-[#8b9bff]",
error: "text-[#e5484d]",
stage: "text-info",
error: "text-destructive",
} as const;
export function StepFeed({ job }: { job: Job }) {
@ -21,7 +21,7 @@ export function StepFeed({ job }: { job: Job }) {
const models = modelsUsed(steps);
const calls = steps.filter((s) => s.kind === "model").length;
return (
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-[11px]">
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-xs">
<button
type="button"
onClick={() => setOpen(!open)}

View file

@ -1,4 +1,4 @@
import { nextCheckStatus, workerConnected } from "./status";
import { investigationActivity, nextCheckStatus, workerConnected } from "./status";
import { describe, expect, it } from "vitest";
import type { Job, Lens, LensList } from "./types";
@ -102,3 +102,12 @@ it("shows the actual next schedule and avoids a stale countdown during active sc
);
expect(nextCheckStatus(lens, now)).toBeNull();
});
it("summarizes activity across investigations, preferring a running scan over a queued one", () => {
const withStatus = (status: Job["status"]): Lens => ({ ...lens, jobs: [{ ...job, status }] });
expect(investigationActivity([])).toBe("idle");
expect(investigationActivity([lens, withStatus("failed")])).toBe("idle");
expect(investigationActivity([lens, withStatus("queued")])).toBe("queued");
expect(investigationActivity([withStatus("queued"), withStatus("running")])).toBe("running");
expect(investigationActivity([{ ...lens, jobs: [lens.jobs[0], { ...job, status: "running" }] }])).toBe("running");
});

View file

@ -43,3 +43,12 @@ export function readiness(
const ready = activityReady && connected && !listError;
return { tracesReady, requestsReady, activityReady, ready };
}
export type InvestigationActivity = "running" | "queued" | "idle";
export function investigationActivity(lenses: readonly Lens[]): InvestigationActivity {
const statuses = new Set(lenses.flatMap((lens) => lens.jobs.map((job) => job.status)));
if (statuses.has("running")) return "running";
if (statuses.has("queued")) return "queued";
return "idle";
}

View file

@ -0,0 +1,140 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { runs } from "./__fixtures__/runs";
import { RunSearch } from "./RunSearch";
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<>
<RunSearch value={value} onChange={setValue} runs={runs} />
<button type="button" onClick={() => setValue("status:ok")}>
Load saved query
</button>
</>
);
}
/** Delivers each emitted value only when asked, like a throttled URL. */
function LaggingHarness() {
const [value, setValue] = useState("");
const [queue, setQueue] = useState<string[]>([]);
return (
<>
<RunSearch value={value} onChange={(next) => setQueue((q) => [...q, next])} runs={runs} />
<output aria-label="undelivered">{queue.join("|")}</output>
<button
type="button"
onClick={() => {
setValue(queue[0]);
setQueue(queue.slice(1));
}}
>
Deliver next
</button>
</>
);
}
const box = () => screen.getByRole("combobox", { name: "Search runs" });
const undelivered = () => screen.getByRole("status", { name: "undelivered" });
const listbox = () => screen.getByRole("listbox", { name: "Search suggestions" });
describe("RunSearch", () => {
it("builds a filter from the keyboard: field, then value", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
expect(box()).toHaveAttribute("aria-expanded", "true");
await user.keyboard("-sta");
expect(
within(listbox())
.getAllByRole("option")
.map((o) => o.textContent),
).toEqual(["status"]);
await user.keyboard("{Enter}");
expect(box()).toHaveTextContent(/^-status:$/, { normalizeWhitespace: false });
expect(within(listbox()).getByRole("group", { name: "status" })).toBeVisible();
expect(within(listbox()).getByText("does not contain")).toBeVisible();
await user.keyboard("{ArrowDown}");
const ok = within(listbox()).getByRole("option", { name: "ok" });
expect(ok).toHaveAttribute("aria-selected", "true");
expect(box()).toHaveAttribute("aria-activedescendant", ok.id);
await user.keyboard("{Enter}");
expect(box()).toHaveTextContent(/^-status:ok $/, { normalizeWhitespace: false });
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(box()).toHaveAttribute("aria-expanded", "false");
});
it("wraps arrow navigation and picks with Tab", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
await user.keyboard("{ArrowUp}");
expect(within(listbox()).getByRole("option", { name: "trace_id" })).toHaveAttribute("aria-selected", "true");
await user.keyboard("{ArrowDown}{ArrowDown}{Tab}");
expect(box()).toHaveTextContent(/^agent:$/, { normalizeWhitespace: false });
await user.keyboard("{ArrowDown}{ArrowDown}e");
expect(within(listbox()).getByRole("option", { name: "billing-agent" })).toHaveAttribute("aria-selected", "true");
});
it("picks a suggestion with the mouse without losing focus", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
await user.keyboard("agent:");
await user.click(within(listbox()).getByRole("option", { name: "researcher" }));
expect(box()).toHaveTextContent(/^agent:researcher $/, { normalizeWhitespace: false });
expect(box()).toHaveFocus();
await user.keyboard("refund");
expect(box()).toHaveTextContent(/^agent:researcher refund$/, { normalizeWhitespace: false });
});
it("stays one line: Enter with no menu adds nothing, and Escape closes the menu until ArrowDown", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
await user.keyboard("refund{Enter}");
expect(box()).toHaveTextContent(/^refund$/, { normalizeWhitespace: false });
await user.keyboard(" {Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await user.keyboard("{ArrowDown}");
expect(listbox()).toBeVisible();
});
it("emits the query once typing pauses, not per keystroke", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<RunSearch value="" onChange={onChange} runs={runs} />);
await user.click(box());
await user.keyboard("refund");
await waitFor(() => expect(onChange).toHaveBeenCalledWith("refund"));
expect(onChange).toHaveBeenCalledOnce();
});
it("keeps typing intact while the value it emitted arrives late", async () => {
const user = userEvent.setup();
render(<LaggingHarness />);
await user.click(box());
await user.keyboard("ab");
await waitFor(() => expect(undelivered()).toHaveTextContent(/^ab$/, { normalizeWhitespace: false }));
await user.keyboard("c");
await user.click(screen.getByRole("button", { name: "Deliver next" }));
await user.click(box());
await user.keyboard("d");
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
await waitFor(() => expect(undelivered()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }));
});
it("shows a query set from outside, such as the URL", async () => {
const user = userEvent.setup();
render(<Harness initial="agent:triage" />);
expect(box()).toHaveTextContent("agent:triage");
await user.click(screen.getByRole("button", { name: "Load saved query" }));
expect(box()).toHaveTextContent(/^status:ok$/, { normalizeWhitespace: false });
});
});

View file

@ -0,0 +1,257 @@
"use client";
import "prosemirror-view/style/prosemirror.css";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import {
Bot,
Box,
Braces,
CircleDashed,
CornerDownLeft,
Hash,
type LucideIcon,
Search,
SquareChevronRight,
} from "lucide-react";
import { Schema } from "prosemirror-model";
import { EditorState, TextSelection, type Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import { useId, useMemo, useState } from "react";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { parseRunQuery, type RunField } from "./runQuery";
import { suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
const schema = new Schema({ nodes: { doc: { content: "text*" }, text: {} } });
const createState = (text: string) =>
EditorState.create({ schema, doc: schema.node("doc", null, text ? schema.text(text) : []), plugins: [reactKeys()] });
const EMIT_WAIT_MS = 150;
const KEEP_MENU_CLOSED = "runSearchKeepMenuClosed";
const FIELD_ICONS: Record<RunField, LucideIcon> = {
name: SquareChevronRight,
agent: Bot,
status: CircleDashed,
model: Box,
input: Braces,
trace_id: Hash,
};
const OPERATORS = [
{ label: "equals", example: "foo:bar" },
{ label: "not equals", example: "-foo:bar" },
{ label: "wildcard match", example: "foo:*bar*" },
{ label: "does not contain", example: "-foo:*bar*" },
];
function highlight(state: EditorState): DecorationSet {
const marks = parseRunQuery(state.doc.textContent).flatMap((clause) =>
clause.kind === "field"
? [
Decoration.inline(clause.from, clause.keyTo, { class: "text-info" }),
Decoration.inline(clause.keyTo, clause.keyTo + 1, { class: "text-muted-foreground" }),
]
: [],
);
return DecorationSet.create(state.doc, marks);
}
function applySuggestion(view: EditorView, item: Suggestion) {
const tr = view.state.tr.insertText(item.insert, item.from, item.to);
tr.setSelection(TextSelection.create(tr.doc, item.from + item.insert.length));
if (item.completesClause) tr.setMeta(KEEP_MENU_CLOSED, true);
view.dispatch(tr);
}
interface RunSearchProps {
value: string;
onChange: (value: string) => void;
/** Loaded runs, the source of value suggestions. */
runs: readonly TraceSummary[];
}
/** One query box for the runs list: free text plus `key:value` filters, with autocomplete for both. */
export function RunSearch({ value, onChange, runs }: RunSearchProps) {
const listId = useId();
const [state, setState] = useState(() => createState(value));
const [seenValue, setSeenValue] = useState(value);
const [unechoed, setUnechoed] = useState<readonly string[]>([]);
const [focused, setFocused] = useState(false);
const [menuOpen, setMenuOpen] = useState(true);
const [active, setActive] = useState(0);
// `value` can trail the editor (the URL updates are throttled), so an echo of text we emitted is not an edit.
if (value !== seenValue) {
setSeenValue(value);
const echo = unechoed.indexOf(value);
if (echo >= 0) setUnechoed(unechoed.slice(echo + 1));
else if (value !== state.doc.textContent) {
setUnechoed([]);
setState(createState(value));
}
}
const { selection } = state;
const text = state.doc.textContent;
const menu = useMemo(() => (selection.empty ? suggest(text, selection.head, runs) : null), [text, selection, runs]);
const items = menu?.groups.flatMap((group) => group.items) ?? [];
const shownMenu = focused && menuOpen ? menu : null;
const visible = shownMenu !== null;
const activeItem = visible ? items[Math.min(active, items.length - 1)] : undefined;
const emit = useDebouncedCallback(
(text: string) => {
setUnechoed((current) => [...current, text]);
onChange(text);
},
{ wait: EMIT_WAIT_MS },
);
const dispatchTransaction = (tr: Transaction) => {
setState((current) => current.apply(tr));
if (tr.selectionSet || tr.docChanged) setActive(0);
if (!tr.docChanged) return;
setMenuOpen(!tr.getMeta(KEEP_MENU_CLOSED));
emit(tr.doc.textContent);
};
const handleKeyDown = (view: EditorView, event: KeyboardEvent): boolean => {
if (event.key === "Escape" && visible) {
setMenuOpen(false);
return true;
}
if (event.key === "ArrowDown" && !visible) {
setMenuOpen(true);
return true;
}
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && items.length > 0) {
const step = event.key === "ArrowDown" ? 1 : -1;
setActive((Math.min(active, items.length - 1) + step + items.length) % items.length);
return true;
}
if ((event.key === "Enter" || event.key === "Tab") && activeItem) {
applySuggestion(view, activeItem);
return true;
}
return false;
};
return (
<ProseMirror
state={state}
dispatchTransaction={dispatchTransaction}
decorations={highlight}
handleKeyDown={handleKeyDown}
handleDOMEvents={{
focus: () => {
setFocused(true);
setMenuOpen(true);
return false;
},
blur: () => {
setFocused(false);
return false;
},
}}
transformPastedText={(pasted) => pasted.replace(/\s+/g, " ")}
attributes={{
role: "combobox",
"aria-label": "Search runs",
"aria-autocomplete": "list",
"aria-expanded": String(visible),
"aria-controls": listId,
...(activeItem && { "aria-activedescendant": `${listId}-${activeItem.id}` }),
spellcheck: "false",
class: "min-w-0 flex-1 overflow-x-auto py-1.5 font-mono text-xs whitespace-pre outline-none",
}}
>
<div className="relative w-full flex-1">
<div className="flex h-8 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<div className="relative flex min-w-0 flex-1">
<ProseMirrorDoc />
{!text && (
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center font-mono text-xs text-muted-foreground">
Search runs, or filter like agent:researcher status:error
</span>
)}
</div>
</div>
{shownMenu && <SuggestionPanel id={listId} menu={shownMenu} activeId={activeItem?.id} />}
</div>
</ProseMirror>
);
}
function SuggestionPanel({ id, menu, activeId }: { id: string; menu: SuggestionMenu; activeId: string | undefined }) {
const pick = useEditorEventCallback((view, item: Suggestion) => applySuggestion(view, item));
return (
<div className="absolute top-full left-0 z-floating mt-1 w-full max-w-xl overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md">
<div id={id} role="listbox" aria-label="Search suggestions" className="max-h-80 overflow-y-auto p-1">
{menu.groups.map((group) => (
<div key={group.heading} role="group" aria-label={group.heading}>
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">{group.heading}</div>
{group.items.map((item) => {
const Icon = FIELD_ICONS[item.field];
return (
<div
key={item.id}
id={`${id}-${item.id}`}
role="option"
aria-selected={item.id === activeId}
onMouseDown={(event) => event.preventDefault()}
onClick={() => pick(item)}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 font-mono text-xs aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
</div>
);
})}
</div>
))}
{menu.showOperators && <OperatorHints />}
</div>
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<Kbd>
<CornerDownLeft className="size-3" />
</Kbd>
Select
</span>
</div>
</div>
);
}
function OperatorHints() {
return (
<div className="pb-1">
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">Comparison operators</div>
{OPERATORS.map((op) => (
<div key={op.label} className="flex items-center justify-between px-2 py-1 font-mono text-xs">
<span className="text-warning">{op.label}</span>
<code className="rounded bg-muted px-1.5 text-muted-foreground">{op.example}</code>
</div>
))}
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border px-1 font-sans">
{children}
</kbd>
);
}

View file

@ -0,0 +1,22 @@
"use client";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { RunSearch } from "./RunSearch";
interface RunsToolbarProps {
query: string;
onQueryChange: (value: string) => void;
runs: readonly TraceSummary[];
/** Extra controls (time range, live tail) rendered on the right. */
children?: React.ReactNode;
}
export function RunsToolbar({ query, onQueryChange, runs, children }: RunsToolbarProps) {
return (
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-2">
<RunSearch value={query} onChange={onQueryChange} runs={runs} />
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -0,0 +1,42 @@
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
export const run = (overrides: Partial<TraceSummary>): TraceSummary => ({
agent_count: 1,
agent_invocations: 1,
duration_ms: 10,
error_count: 0,
input_preview: "",
input_tokens: 0,
llm_calls: 1,
models: [],
name: "run",
output_tokens: 0,
service: "svc",
span_count: 1,
spend: null,
start_time: "2026-10-01T00:00:00Z",
status: "ok",
tool_calls: 0,
trace_id: "trace",
...overrides,
});
const refundOverrides = {
trace_id: "aaa111",
name: "support",
input_preview: "Where is my refund?",
agent_names: ["billing-agent", "triage"],
models: ["gpt-5"],
};
const refund = run(refundOverrides);
const researchOverrides = {
trace_id: "bbb222",
name: "research_lead",
input_preview: "Compare vector stores",
agent_names: ["researcher"],
models: ["claude-opus"],
error_count: 2,
};
const research = run(researchOverrides);
const plain = run({ trace_id: "ccc333", name: "health", service: "cron" });
export const runs = [refund, research, plain];

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { run, runs } from "./__fixtures__/runs";
import { fieldValues, filterRuns } from "./runQuery";
const ids = (query: string) => filterRuns(runs, query).map((r) => r.trace_id);
describe("filterRuns", () => {
it("returns every run for an empty or blank query", () => {
expect(filterRuns(runs, " ")).toBe(runs);
});
it("matches free text against input, name and trace id, ignoring case, with every term required", () => {
expect(ids("REFUND")).toEqual(["aaa111"]);
expect(ids("lead")).toEqual(["bbb222"]);
expect(ids("ccc3")).toEqual(["ccc333"]);
expect(ids("vector research")).toEqual(["bbb222"]);
expect(ids("vector refund")).toEqual([]);
});
it("keeps a quoted phrase together", () => {
expect(ids('"my refund"')).toEqual(["aaa111"]);
expect(ids('"refund my"')).toEqual([]);
});
it("splits runs by status, judged by recorded errors", () => {
expect(ids("status:error")).toEqual(["bbb222"]);
expect(ids("-status:error")).toEqual(["aaa111", "ccc333"]);
expect(ids("status:OK")).toEqual(["aaa111", "ccc333"]);
});
it("matches a multi-valued field when any value matches, and negation when none does", () => {
expect(ids("agent:triage")).toEqual(["aaa111"]);
expect(ids("-agent:triage")).toEqual(["bbb222", "ccc333"]);
expect(ids("agent:cron")).toEqual(["ccc333"]);
expect(ids("model:gpt-5")).toEqual(["aaa111"]);
});
it("requires a whole-value match unless the value has wildcards", () => {
expect(ids("agent:billing")).toEqual([]);
expect(ids("agent:*bill*")).toEqual(["aaa111"]);
expect(ids("agent:billing*")).toEqual(["aaa111"]);
expect(ids("agent:*agent")).toEqual(["aaa111"]);
expect(ids("-agent:*search*")).toEqual(["aaa111", "ccc333"]);
expect(ids("name:res.arch_lead")).toEqual([]);
});
it("matches glob segments case-insensitively without overlapping exact anchors", () => {
const billing = run({ trace_id: "billing", agent_names: ["BILLING-agent"] });
const research = run({ trace_id: "research", agent_names: ["research-billing-agent"] });
const doubled = run({ trace_id: "doubled", agent_names: ["agent"] });
const globRuns = [billing, research, doubled];
expect(filterRuns(globRuns, "agent:*bill*")).toEqual([billing, research]);
expect(filterRuns(globRuns, "agent:billing*")).toEqual([billing]);
expect(filterRuns(globRuns, "agent:*agent")).toEqual(globRuns);
expect(filterRuns([run({ name: "aa" })], "name:a*a")).toHaveLength(1);
expect(filterRuns([run({ name: "a" })], "name:a*a")).toHaveLength(0);
expect(filterRuns(globRuns, "agent:**")).toEqual(filterRuns(globRuns, "agent:*"));
});
it("matches adversarial globs without catastrophic backtracking", () => {
const longValue = run({ agent_names: ["a".repeat(5000)] });
const pattern = `agent:${"a*".repeat(30)}b`;
const startedAt = performance.now();
const matches = filterRuns([longValue], pattern);
const elapsed = performance.now() - startedAt;
expect(matches).toEqual([]);
expect(elapsed).toBeLessThan(100);
});
it("searches the readable input text and quoted values with spaces", () => {
expect(ids('input:"*vector stores"')).toEqual(["bbb222"]);
expect(ids("trace_id:bbb222")).toEqual(["bbb222"]);
});
it("combines field clauses with free text", () => {
expect(ids("agent:*e* -status:error refund")).toEqual(["aaa111"]);
});
it("ignores a key still waiting for its value and treats unknown keys as text", () => {
expect(ids("agent:")).toEqual(["aaa111", "bbb222", "ccc333"]);
expect(ids("refund agent:")).toEqual(["aaa111"]);
expect(ids("foo:bar")).toEqual([]);
expect(filterRuns([run({ name: "foo:bar" })], "foo:bar")).toHaveLength(1);
});
});
describe("fieldValues", () => {
it("lists each distinct non-empty value once, sorted", () => {
expect(fieldValues(runs, "agent")).toEqual(["billing-agent", "cron", "researcher", "triage"]);
expect(fieldValues(runs, "status")).toEqual(["error", "ok"]);
expect(fieldValues([run({ models: [] })], "model")).toEqual([]);
});
});

View file

@ -0,0 +1,111 @@
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { previewText, traceAgentNames } from "@/components/view_logs/TraceView/traceUtils";
type RunFieldGroup = "Run attributes" | "Content" | "Identity";
interface RunFieldSpec {
group: RunFieldGroup;
read: (run: TraceSummary) => readonly string[];
suggestValues: boolean;
}
const runStatus = (run: TraceSummary): string => (run.error_count > 0 ? "error" : "ok");
export const RUN_FIELDS = {
name: { group: "Run attributes", read: (run) => [run.name], suggestValues: true },
agent: { group: "Run attributes", read: traceAgentNames, suggestValues: true },
status: { group: "Run attributes", read: (run) => [runStatus(run)], suggestValues: true },
model: { group: "Run attributes", read: (run) => run.models, suggestValues: true },
input: { group: "Content", read: (run) => [previewText(run.input_preview)], suggestValues: false },
trace_id: { group: "Identity", read: (run) => [run.trace_id], suggestValues: false },
} as const satisfies Record<string, RunFieldSpec>;
export type RunField = keyof typeof RUN_FIELDS;
const isRunField = (key: string): key is RunField => Object.hasOwn(RUN_FIELDS, key);
interface Span {
from: number;
to: number;
}
export type QueryClause =
| (Span & { kind: "text"; value: string })
| (Span & { kind: "field"; field: RunField; negated: boolean; keyTo: number; value: string });
const unquote = (raw: string): string => raw.replace(/^"([^"]*)"?$/, "$1");
/** Whitespace-separated tokens; a double-quoted stretch keeps its spaces. */
function tokenize(text: string): (Span & { raw: string })[] {
return Array.from(text.matchAll(/(?:"[^"]*"?|\S)+/g), (match) => ({
raw: match[0],
from: match.index,
to: match.index + match[0].length,
}));
}
function parseToken({ raw, from, to }: Span & { raw: string }): QueryClause {
const match = /^(-?)([A-Za-z_]+):(.*)$/.exec(raw);
const key = match?.[2].toLowerCase() ?? "";
if (!match || !isRunField(key)) return { kind: "text", value: unquote(raw), from, to };
const negated = match[1] === "-";
return {
kind: "field",
field: key,
negated,
keyTo: from + match[1].length + match[2].length,
value: unquote(match[3]),
from,
to,
};
}
export const parseRunQuery = (text: string): QueryClause[] => tokenize(text).map(parseToken);
/** `bar` matches the whole value, `*bar*` is a glob; both ignore case. */
function valueMatcher(pattern: string): (value: string) => boolean {
const needle = pattern.toLowerCase();
if (!needle.includes("*")) return (value) => value.toLowerCase() === needle;
const segments = needle.split("*");
const first = segments[0];
const last = segments.at(-1);
const middle = segments.slice(1, -1).filter(Boolean);
return (value) => {
const candidate = value.toLowerCase();
if (first && !candidate.startsWith(first)) return false;
if (last && !candidate.endsWith(last)) return false;
const suffixStart = last ? candidate.length - last.length : candidate.length;
let cursor = first ? first.length : 0;
if (suffixStart < cursor) return false;
for (const segment of middle) {
const found = candidate.indexOf(segment, cursor);
if (found < 0 || found + segment.length > suffixStart) return false;
cursor = found + segment.length;
}
return true;
};
}
function matchesClause(run: TraceSummary, clause: QueryClause): boolean {
if (clause.kind === "text") {
const needle = clause.value.toLowerCase();
return [run.trace_id, previewText(run.input_preview), run.name].some((text) => text.toLowerCase().includes(needle));
}
// A key typed without a value yet narrows nothing, so the list does not blank out mid-typing.
if (!clause.value) return true;
const matches = RUN_FIELDS[clause.field].read(run).some(valueMatcher(clause.value));
return clause.negated ? !matches : matches;
}
/** Every clause must hold: free text searches input, name and trace id; `key:value` clauses target one field. */
export function filterRuns(runs: TraceSummary[], query: string): TraceSummary[] {
const clauses = parseRunQuery(query);
if (clauses.length === 0) return runs;
return runs.filter((run) => clauses.every((clause) => matchesClause(run, clause)));
}
export function fieldValues(runs: readonly TraceSummary[], field: RunField): string[] {
return Array.from(new Set(runs.flatMap((run) => RUN_FIELDS[field].read(run))))
.filter(Boolean)
.sort();
}

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { run, runs } from "./__fixtures__/runs";
import { suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
const atEnd = (text: string, source = runs) => suggest(text, text.length, source);
const items = (menu: SuggestionMenu | null): Suggestion[] => menu?.groups.flatMap((g) => g.items) ?? [];
const labels = (menu: SuggestionMenu | null) => items(menu).map((item) => item.label);
const apply = (text: string, item: Suggestion) => text.slice(0, item.from) + item.insert + text.slice(item.to);
describe("suggest", () => {
it("offers every field, grouped, in an empty box", () => {
const menu = atEnd("");
expect(menu?.groups.map((g) => g.heading)).toEqual(["Run attributes", "Content", "Identity"]);
expect(labels(menu)).toEqual(["name", "agent", "status", "model", "input", "trace_id"]);
expect(menu?.showOperators).toBe(false);
});
it("narrows fields by the typed prefix and completes the key with a colon", () => {
const menu = atEnd("refund AG");
expect(labels(menu)).toEqual(["agent"]);
expect(apply("refund AG", items(menu)[0])).toBe("refund agent:");
expect(items(menu)[0].completesClause).toBe(false);
});
it("keeps a leading minus when completing a negated key", () => {
expect(apply("-st", items(atEnd("-st"))[0])).toBe("-status:");
});
it("offers nothing for free text that names no field", () => {
expect(atEnd("refund")).toBeNull();
expect(atEnd("foo:bar")).toBeNull();
});
it("offers the loaded values and the operator help once a key has its colon", () => {
const menu = atEnd("agent:");
expect(menu?.groups.map((g) => g.heading)).toEqual(["agent"]);
expect(labels(menu)).toEqual(["billing-agent", "cron", "researcher", "triage"]);
expect(menu?.showOperators).toBe(true);
});
it("narrows values by substring, ignoring wildcards, and drops operator help once a value is typed", () => {
const menu = atEnd("-agent:*AR*");
expect(labels(menu)).toEqual(["researcher"]);
expect(menu?.showOperators).toBe(false);
expect(atEnd("agent:zzz")).toBeNull();
});
it("replaces only the value, adds a separating space at the end, and finishes the clause", () => {
const item = items(atEnd("-agent:bil"))[0];
expect(apply("-agent:bil", item)).toBe("-agent:billing-agent ");
expect(item.completesClause).toBe(true);
const text = "agent:tri refund";
expect(apply(text, items(suggest(text, 9, runs))[0])).toBe("agent:triage refund");
});
it("quotes a value that contains spaces", () => {
const spaced = [run({ name: "Order lookup" })];
expect(apply("name:", items(atEnd("name:", spaced))[0])).toBe('name:"Order lookup" ');
});
it("does not list values for free-form fields but still explains the operators", () => {
const menu = atEnd("input:");
expect(menu?.groups).toEqual([]);
expect(menu?.showOperators).toBe(true);
expect(atEnd("input:x")).toBeNull();
});
it("offers fields in the gap between tokens, but nothing mid-token", () => {
const text = "refund status:ok";
expect(apply(text, items(suggest(text, 7, runs))[1])).toBe("refund agent: status:ok");
expect(suggest(text, 3, runs)).toBeNull();
expect(suggest("status:ok", 0, runs)).toBeNull();
});
});

View file

@ -0,0 +1,91 @@
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { fieldValues, parseRunQuery, RUN_FIELDS, type QueryClause, type RunField } from "./runQuery";
export interface Suggestion {
id: string;
label: string;
field: RunField;
/** Replaces `text.slice(from, to)`. */
from: number;
to: number;
insert: string;
/** A value finishes the clause and closes the menu; a field keeps it open for its values. */
completesClause: boolean;
}
export interface SuggestionGroup {
heading: string;
items: Suggestion[];
}
export interface SuggestionMenu {
groups: SuggestionGroup[];
showOperators: boolean;
}
const MAX_VALUES = 50;
const FIELDS = Object.keys(RUN_FIELDS) as RunField[];
const isSpace = (char: string | undefined) => char === undefined || /\s/.test(char);
const quoteIfNeeded = (value: string) => (/[\s"]/.test(value) ? `"${value.replaceAll('"', "")}"` : value);
function fieldMenu(prefix: string, negated: boolean, from: number, to: number): SuggestionMenu | null {
const fields = FIELDS.filter((field) => field.startsWith(prefix.toLowerCase()));
if (fields.length === 0) return null;
const groups = Array.from(new Set(fields.map((field) => RUN_FIELDS[field].group)), (heading) => ({
heading,
items: fields
.filter((field) => RUN_FIELDS[field].group === heading)
.map((field) => ({
id: `field:${field}`,
label: field,
field,
from,
to,
insert: `${negated ? "-" : ""}${field}:`,
completesClause: false,
})),
}));
return { groups, showOperators: false };
}
function valueMenu(
clause: Extract<QueryClause, { kind: "field" }>,
text: string,
runs: readonly TraceSummary[],
): SuggestionMenu | null {
const from = clause.keyTo + 1;
const needle = clause.value.replaceAll("*", "").toLowerCase();
const trailing = clause.to === text.length ? " " : "";
const values = RUN_FIELDS[clause.field].suggestValues ? fieldValues(runs, clause.field) : [];
const items = values
.filter((value) => value.toLowerCase().includes(needle))
.slice(0, MAX_VALUES)
.map((value) => ({
id: `value:${value}`,
label: value,
field: clause.field,
from,
to: clause.to,
insert: quoteIfNeeded(value) + trailing,
completesClause: true,
}));
const showOperators = clause.value === "";
if (items.length === 0 && !showOperators) return null;
return { groups: items.length ? [{ heading: clause.field, items }] : [], showOperators };
}
/** What to offer at the cursor: fields while a key is being typed, values once it has a colon. */
export function suggest(text: string, cursor: number, runs: readonly TraceSummary[]): SuggestionMenu | null {
const clause = parseRunQuery(text).find((c) => c.from < cursor && c.to === cursor);
if (!clause) {
const betweenTokens = isSpace(text[cursor - 1]) && isSpace(text[cursor]);
return betweenTokens ? fieldMenu("", false, cursor, cursor) : null;
}
if (clause.kind === "field") return valueMenu(clause, text, runs);
const raw = text.slice(clause.from, clause.to);
const negated = raw.startsWith("-");
const prefix = negated ? raw.slice(1) : raw;
if (/[:"]/.test(prefix)) return null;
return fieldMenu(prefix, negated, clause.from, clause.to);
}

View file

@ -29,7 +29,7 @@ function TraceExample({ highlighted, onHighlight }: EvidenceProps) {
</h3>
<p className="text-xs text-muted-foreground">Sample trace · 14.2s</p>
</div>
<ol className="mt-3 space-y-1.5 text-xs leading-5 sm:text-[13px]" aria-label="Sample trace timeline">
<ol className="mt-3 space-y-1.5 text-xs leading-5 sm:text-sm" aria-label="Sample trace timeline">
{traceSteps.map((item, index) => {
const linked = item.failed || item.name === "answer";
const rowClass =
@ -80,11 +80,11 @@ function TraceExample({ highlighted, onHighlight }: EvidenceProps) {
<div className="mt-3 grid gap-3 border-t pt-3 sm:grid-cols-2">
<div id="lens-benchmark-output" className={`${styles.evidenceOutput} rounded-lg bg-muted/60 p-2.5`}>
<p className="text-xs text-muted-foreground">run_benchmark · output</p>
<p className="mt-1.5 text-[13px] leading-[18px]">Error: benchmark runner unavailable (503)</p>
<p className="mt-1.5 text-sm leading-4.5">Error: benchmark runner unavailable (503)</p>
</div>
<div id="lens-answer-output" className={`${styles.evidenceOutput} rounded-lg bg-muted/60 p-2.5`}>
<p className="text-xs text-muted-foreground">answer · output</p>
<p className="mt-1.5 text-[13px] leading-[18px]">“{sampleAnswer}”</p>
<p className="mt-1.5 text-sm leading-4.5">“{sampleAnswer}”</p>
</div>
</div>
</section>
@ -118,7 +118,7 @@ function FindingExamples({ highlighted, onHighlight }: EvidenceProps) {
Claims numbers it never measured
</button>
</h4>
<div className="ml-4 mt-1.5 space-y-1.5 text-[13px] leading-[18px]">
<div className="ml-4 mt-1.5 space-y-1.5 text-sm leading-4.5">
<p className="text-muted-foreground">Reports a speedup even though the benchmark failed.</p>
<blockquote className="border-l-2 pl-3 italic">
“…40% faster than the row store for your workload.”
@ -135,7 +135,7 @@ function FindingExamples({ highlighted, onHighlight }: EvidenceProps) {
<span aria-hidden="true" className="mt-1.5 size-2 shrink-0 rounded-full bg-amber-500" />
Promises to follow up, then stops
</h4>
<div className="ml-4 mt-1.5 space-y-1.5 text-[13px] leading-[18px] text-muted-foreground">
<div className="ml-4 mt-1.5 space-y-1.5 text-sm leading-4.5 text-muted-foreground">
<p>After an order lookup times out, the agent promises to check and ends the run.</p>
<p className="text-xs">12 linked runs · Medium priority</p>
</div>
@ -153,10 +153,7 @@ export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onD
aria-labelledby="lens-introduction"
className="rounded-2xl border bg-card p-5 sm:px-6 sm:py-5 xl:px-7 xl:py-6"
>
<h2
id="lens-introduction"
className="text-2xl leading-tight font-semibold tracking-[-0.03em] sm:text-[30px] xl:text-[32px]"
>
<h2 id="lens-introduction" className="text-2xl leading-tight font-semibold tracking-tight sm:text-3xl">
The gateway that helps your agents improve
</h2>
<p className="mt-3 max-w-[780px] text-sm leading-6 text-muted-foreground">

View file

@ -18,22 +18,24 @@ type StepProps = {
};
export function initialSetupStep(state: LensSetupState) {
if (!state.tracingEnabled) return 0;
if (!state.tracesReady) return 1;
return state.connected ? 3 : 2;
if (state.tracesReady || state.requestsReady) return state.connected ? 3 : 2;
return state.tracingEnabled ? 1 : 0;
}
function StorageStep({ state, onStep, ...props }: StepProps) {
if (!state.tracingEnabled)
return (
<TracingSetupFields
detail="Tracing is not enabled"
accessToken={props.accessToken}
onOpenTrace={props.onTrace}
onCheck={state.refresh}
checking={state.checking}
readOnly={props.readOnly}
/>
<>
<TracingSetupFields
detail="Tracing is not enabled"
accessToken={props.accessToken}
onOpenTrace={props.onTrace}
onCheck={state.refresh}
checking={state.checking}
readOnly={props.readOnly}
/>
<ActivityContinuation state={state} {...props} />
</>
);
return (
<div className="space-y-4">
@ -43,6 +45,7 @@ function StorageStep({ state, onStep, ...props }: StepProps) {
<Button onClick={() => onStep(1)}>
Continue to your agent <ArrowRight aria-hidden="true" className="size-4" />
</Button>
<ActivityContinuation state={state} {...props} />
</div>
);
}
@ -52,10 +55,37 @@ function continuationLabel(state: LensSetupState) {
return state.tracesReady ? "Continue to worker" : "Continue with request logs";
}
function AgentStep({ state, onConnect, onCreate, ...props }: StepProps) {
function ActivityContinuation({
state,
onConnect,
onCreate,
readOnly,
canInvestigate,
}: Pick<StepProps, "state" | "onConnect" | "onCreate" | "readOnly" | "canInvestigate">) {
if (!state.tracesReady && !state.requestsReady) return null;
return (
<div className="mt-4">
<Button onClick={state.connected ? onCreate : onConnect} disabled={readOnly || !canInvestigate}>
{continuationLabel(state)}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
{state.requestsReady && !state.tracesReady && (
<p className="mt-2 text-xs text-muted-foreground">
Request logs are already available. You can investigate them now and add agent traces later.
</p>
)}
</div>
);
}
function AgentStep({ state, ...props }: StepProps) {
if (!state.tracingEnabled)
return <p className="text-sm text-muted-foreground">Connect trace storage in step 1 before sending a trace.</p>;
const activityReady = state.tracesReady || state.requestsReady;
return (
<>
<p className="text-sm text-muted-foreground">Connect trace storage in step 1 before sending a trace.</p>
<ActivityContinuation state={state} {...props} />
</>
);
return (
<>
<div hidden={state.tracesReady}>
@ -77,19 +107,7 @@ function AgentStep({ state, onConnect, onCreate, ...props }: StepProps) {
Your first trace is ready. Continue setup so Lens can investigate your agent’s behavior.
</p>
)}
{activityReady && (
<div className="mt-4">
<Button onClick={state.connected ? onCreate : onConnect} disabled={props.readOnly || !props.canInvestigate}>
{continuationLabel(state)}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
{state.requestsReady && !state.tracesReady && (
<p className="mt-2 text-xs text-muted-foreground">
Request logs are already available. You can investigate them now and add agent traces later.
</p>
)}
</div>
)}
<ActivityContinuation state={state} {...props} />
</>
);
}

View file

@ -26,9 +26,10 @@ export function useLensSetup(accessToken: string, enabled: boolean, canInvestiga
const requestsReady = activity.data?.requests === true && !activity.error;
const connected = data.workers.some((worker) => workerConnected(worker, now));
const hasInvestigations = data.lenses.length > 0;
const error = traceState.error || list.error || activity.error;
const activityReady = traceState.tracesReady || requestsReady;
const loadingActivity = list.isSuccess && activity.isPending;
const activityError = activityReady ? null : traceState.error || activity.error;
const error = list.error || activityError;
const loadingActivity = !activityReady && list.isSuccess && activity.isPending;
const loadingInvestigations = canInvestigate && (list.isPending || loadingActivity);
const refresh = () => {
void traces.refetch();
@ -44,7 +45,7 @@ export function useLensSetup(accessToken: string, enabled: boolean, canInvestiga
connected,
hasInvestigations,
missingTraces: traceState.missingTraces,
loading: traces.isPending || loadingInvestigations,
loading: (!activityReady && traces.isPending) || loadingInvestigations,
checking: traces.isFetching || list.isFetching || activity.isFetching,
ready: activityReady && connected && !error,
workers: data.workers,

View file

@ -0,0 +1,239 @@
"use client";
import { ArrowLeft, ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode, type RefObject } from "react";
import { useEventListener, useLocalStorage, useMediaQuery, useOnClickOutside, useWindowSize } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { ignoresLetterShortcut } from "@/components/view_logs/letterShortcut";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
export const PANEL_TRIGGER = { "data-side-panel-trigger": "" } as const;
const KEEPS_PANEL_OPEN =
"[data-side-panel-trigger], [role='dialog'], [role='menu'], [role='listbox'], [data-radix-popper-content-wrapper]";
const OWNS_ITS_KEYS = "[role='dialog'], [role='menu'], [role='listbox']";
const insideKeyOwner = (event: KeyboardEvent): boolean =>
event.target instanceof Element && event.target.closest(OWNS_ITS_KEYS) !== null;
function useDrawerWidth() {
const { width: viewport } = useWindowSize();
const [stored, setStored] = useLocalStorage<number | null>(WIDTH_KEY, null, {
serializer: String,
deserializer: (raw) => (Number.isFinite(Number(raw)) ? Number(raw) : null),
});
const width = clampDrawerWidth(stored ?? viewport * DEFAULT_FRACTION, viewport);
const update = useCallback((next: number) => setStored(clampDrawerWidth(next, window.innerWidth)), [setStored]);
return [width, update] as const;
}
function useExitPresence<T>(item: T | null, itemKey: (item: T) => string) {
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const [lastShown, setLastShown] = useState<T | null>(item);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (item !== null && item !== lastShown) setLastShown(item);
if (item !== null && exitedKey !== null) setExitedKey(null);
const shown = item ?? lastShown;
const closing = item === null && shown !== null;
const exited = closing && (reduceMotion || exitedKey === itemKey(shown));
const onExited = () => {
if (closing) setExitedKey(itemKey(shown));
};
return { shown: exited ? null : shown, closing, onExited };
}
function ResizeHandle({ noun, width, onResize }: { noun: string; width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(window.innerWidth - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${noun} panel`}
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
export interface SidePanelProps<T> {
item: T | null;
itemKey: (item: T) => string;
noun: string;
label: string;
testId?: string;
index: number;
total: number;
onStep: (delta: number) => void;
onClose: () => void;
fullScreen: boolean;
onFullScreenChange: (fullScreen: boolean) => void;
children: (item: T) => ReactNode;
}
export function SidePanel<T>({
item,
itemKey,
noun,
label,
testId,
index,
total,
onStep,
onClose,
fullScreen,
onFullScreenChange,
children,
}: SidePanelProps<T>) {
const [width, setWidth] = useDrawerWidth();
const panelRef = useRef<HTMLElement>(null);
const { shown, closing, onExited } = useExitPresence(item, itemKey);
useEventListener("keydown", (event) => {
if (item === null || ignoresLetterShortcut(event) || insideKeyOwner(event)) return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
} else if (event.key === "j") {
event.preventDefault();
onStep(1);
} else if (event.key === "k") {
event.preventDefault();
onStep(-1);
}
});
useOnClickOutside(panelRef as RefObject<HTMLElement>, (event) => {
if (item === null || (event instanceof MouseEvent && event.button !== 0)) return;
if (event.target instanceof Element && event.target.closest(KEEPS_PANEL_OPEN) !== null) return;
onClose();
});
if (shown === null) return null;
return (
<aside
ref={panelRef}
aria-label={label}
data-testid={testId}
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget) onExited();
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
>
{!fullScreen && <ResizeHandle noun={noun} width={width} onResize={setWidth} />}
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={onClose}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label={`Next ${noun} (J)`} disabled={index < 0 || index >= total - 1} onClick={() => onStep(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label={`Previous ${noun} (K)`} disabled={index <= 0} onClick={() => onStep(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
{index + 1} / {total}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<HeaderButton
label={fullScreen ? "Exit full screen" : "Enter full screen"}
onClick={() => onFullScreenChange(!fullScreen)}
>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
<HeaderButton label={`Close ${noun} (Esc)`} onClick={onClose}>
<X className="size-4" />
</HeaderButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">{children(shown)}</div>
</aside>
);
}
export function PanelBackLink({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="-ml-1 inline-flex max-w-full items-center gap-1 rounded px-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}

View file

@ -5,11 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import traceList from "./__fixtures__/trace_list.json";
import { LensPreviewContext } from "@/components/lens/LensPreviewButton";
import AgentTracesPage from "./AgentTracesPage";
import { AgentTracesSection, filterRuns, type TimeControls } from "./AgentTracesSection";
import { filterRuns } from "@/components/lens/runSearch/runQuery";
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
import type { TracePage, TraceSummary } from "./traceTypes";
vi.mock("../../networking", () => ({
@ -115,7 +116,8 @@ describe("AgentTracesSection", () => {
.mockResolvedValueOnce({ data: runs.slice(1, 2), next_cursor: "later" });
renderSection();
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "no-such-run" } });
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "no-such-run");
await waitFor(() => expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0));
act(() => mockAllIsIntersecting(true));
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledOnce();
@ -168,7 +170,6 @@ describe("AgentTracesSection", () => {
expect(screen.getByText(`Could not load runs: ${message}`)).toBeVisible();
expect(screen.queryByText(/Private token details/)).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledOnce();
expect(screen.getByTestId("runs-footer")).toHaveTextContent("Update failed");
});
it("renders the setup snippet when the proxy answers 501", async () => {
@ -203,10 +204,12 @@ describe("AgentTracesSection", () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] });
vi.mocked(apiClient.get).mockResolvedValue(traceList);
renderSection();
expect(await screen.findByText("No runs match these filters.")).toBeVisible();
expect(await screen.findByText("No runs in this time range")).toBeVisible();
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "sk-test", query: { start_ms: 0 } });
fireEvent.click(screen.getByRole("button", { name: "Set up tracing" }));
expect(await screen.findByRole("heading", { name: "Connect another agent" })).toBeVisible();
});
it("checks proxy readiness, waits for an agent, and confirms receipt using actual query results", async () => {
@ -265,7 +268,7 @@ describe("AgentTracesSection", () => {
await act(async () => {
void testQueryClient.invalidateQueries({ queryKey: ["agentTraces"] });
});
expect(await screen.findByText("Updating…")).toBeVisible();
await waitFor(() => expect(screen.getByRole("table", { name: "Agent runs" })).toHaveAttribute("aria-busy", "true"));
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.getByRole("complementary", { name: "Trace details" })).toBe(drawer);
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
@ -277,13 +280,13 @@ describe("AgentTracesSection", () => {
vi.mocked(apiClient.get).mockRejectedValue(new ApiError("History unavailable", 503, {}));
renderSection();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check earlier traces. History unavailable");
expect(screen.getByText("No runs match these filters.")).toBeVisible();
expect(screen.getByText("No runs in this time range")).toBeVisible();
expect(screen.queryByText(/Could not load runs/)).not.toBeInTheDocument();
vi.mocked(apiClient.get).mockResolvedValue(traceList);
fireEvent.click(screen.getByRole("button", { name: "Retry trace check" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByText("No runs match these filters.")).toBeVisible();
expect(screen.getByText("No runs in this time range")).toBeVisible();
expect(agentTraceListCall).toHaveBeenCalledTimes(1);
});
@ -306,7 +309,6 @@ describe("AgentTracesSection", () => {
expect(lead).toBeDefined();
const failed = rows.find((row) => row.textContent?.includes("acme-404")) as HTMLElement;
expect(within(failed).getByLabelText("2 errors")).toBeInTheDocument();
expect(screen.getByText(`${runs.length} runs`)).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Cost" })).toBeInTheDocument();
expect(within(failed).getByText("—")).toBeInTheDocument();
});
@ -323,22 +325,24 @@ describe("AgentTracesSection", () => {
});
it("filters by input text and by trace id", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderSection();
await screen.findAllByTestId("agent-trace-row");
const search = screen.getByLabelText("Search runs");
fireEvent.change(search, { target: { value: "acme-404" } });
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
const search = screen.getByRole("combobox", { name: "Search runs" });
await user.type(search, "acme-404");
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1));
const lead = runs.find((r) => r.name === "research_lead") as TraceSummary;
fireEvent.change(search, { target: { value: lead.trace_id.slice(0, 10) } });
const rows = screen.getAllByTestId("agent-trace-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("Should we store OTEL agent spans");
await user.clear(search);
await user.type(search, lead.trace_id.slice(0, 10));
await waitFor(() =>
expect(screen.getByTestId("agent-trace-row")).toHaveTextContent("Should we store OTEL agent spans"),
);
});
it("uses recorded agent names for the column and filter even when services are shared", async () => {
it("uses recorded agent names for the column, suggestions and filter even when services are shared", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({
...(traceList as TracePage),
data: [
@ -351,20 +355,19 @@ describe("AgentTracesSection", () => {
await screen.findAllByTestId("agent-trace-row");
expect(screen.getByRole("columnheader", { name: "Agent" })).toBeInTheDocument();
const agentFilter = screen.getByRole("combobox", { name: "Filter by agent" });
expect(agentFilter).toHaveTextContent("All agents");
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "agent:");
const suggestions = screen.getByRole("listbox", { name: "Search suggestions" });
expect(
within(suggestions)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["billing-agent", "research-agent", "review-agent"]);
await chooseSelectOption(user, agentFilter, "billing-agent");
const rows = screen.getAllByTestId("agent-trace-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("billing-agent");
expect(rows[0]).not.toHaveTextContent("shared-app");
await chooseSelectOption(user, agentFilter, "review-agent");
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
await chooseSelectOption(user, agentFilter, "All agents");
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
await user.click(within(suggestions).getByRole("option", { name: "review-agent" }));
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1));
const row = screen.getByTestId("agent-trace-row");
expect(row).toHaveTextContent("billing-agent");
expect(row).not.toHaveTextContent("shared-app");
});
it("shows each run's agent name with the logo of the SDK that produced it", async () => {
@ -492,11 +495,11 @@ describe("AgentTracesSection", () => {
expect(rows[1]).toHaveAttribute("aria-selected", "false");
});
it("reads the search, agent and status filters from the URL and writes changes back", async () => {
it("reads the query from the URL and writes typed changes back", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
const failed = filterRuns(runs, "", "all", "error");
const failed = filterRuns(runs, "status:error");
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
@ -506,18 +509,18 @@ describe("AgentTracesSection", () => {
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?status=error", onUrlUpdate },
{ searchParams: "?q=status:error", onUrlUpdate },
);
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length);
expect(screen.getByRole("combobox", { name: "Filter by status" })).toHaveTextContent("Failed");
expect(failed.length).toBeLessThan(runs.length);
const search = screen.getByRole("combobox", { name: "Search runs" });
expect(search).toHaveTextContent("status:error");
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: failed[0].trace_id } });
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe(failed[0].trace_id));
expect(lastUrl(onUrlUpdate).get("status")).toBe("error");
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "All status");
await waitFor(() => expect(lastUrl(onUrlUpdate).has("status")).toBe(false));
await user.clear(search);
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length));
await user.type(search, "-status:error");
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length - failed.length));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe("-status:error"));
});
it("plots every loaded run on the timeline", async () => {
@ -600,13 +603,13 @@ describe("AgentTracesPage", () => {
const trigger = screen.getByRole("button", { name: "Time range" });
expect(trigger).toHaveTextContent(/ to /);
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 1d");
fireEvent.click(trigger);
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" }));
expect(await screen.findByText("Total 7d")).toBeInTheDocument();
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
await waitFor(() => {
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
});
const live = screen.getByRole("button", { name: "Live" });
expect(live).toHaveAttribute("aria-pressed", "true");
@ -635,7 +638,7 @@ describe("AgentTracesPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
expect(await screen.findByText("No runs match these filters.")).toBeInTheDocument();
expect(await screen.findByText("No runs in this time range")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Time range" })).toBeInTheDocument();
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
});
@ -645,13 +648,15 @@ describe("AgentTracesPage", () => {
const onUrlUpdate = vi.fn();
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { searchParams: "?hours=168", onUrlUpdate });
await screen.findByTestId("runs-table");
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 7d");
const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0];
expect(endMs - startMs).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
expect(await screen.findByText("Total 1h")).toBeInTheDocument();
await waitFor(() => {
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeLessThanOrEqual(3600 * 1000 + 60_000);
});
await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1"));
});

View file

@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterRuns } from "./AgentTracesSection";
import traceList from "./__fixtures__/trace_list.json";
import type { TracePage, TraceSummary } from "./traceTypes";
const runs = (traceList as TracePage).data as TraceSummary[];
describe("filterRuns", () => {
it("status filter 'Failed' keeps only runs with errors", () => {
const failed = filterRuns(runs, "", "all", "error");
expect(failed.length).toBeGreaterThan(0);
expect(failed.every((r) => r.error_count > 0)).toBe(true);
const ok = filterRuns(runs, "", "all", "ok");
expect(ok.every((r) => r.error_count === 0)).toBe(true);
expect(failed.length + ok.length).toBe(runs.length);
});
});

View file

@ -3,12 +3,12 @@
import moment from "moment";
import { useMemo, useState } from "react";
import { HeaderActions } from "@/components/lens/HeaderActions";
import { filterRuns } from "@/components/lens/runSearch/runQuery";
import { RunsToolbar } from "@/components/lens/runSearch/RunsToolbar";
import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { RunDrawer } from "./RunDrawer";
import { ALL_AGENTS, RunsToolbar, type RunStatusFilter } from "./RunsToolbar";
import {
type TraceRef,
traceKey,
@ -18,32 +18,12 @@ import {
useZoomRouting,
} from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { previewText, traceAgentNames } from "./traceUtils";
import { TimeRangeControls } from "./TimeRangeControls";
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
import { ActiveDot } from "./ActiveDot";
import { TracingSetupCard } from "./TracingSetupCard";
import { useTracesLive } from "./tracesApi";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
/** Client-side search (input text or trace id) plus agent / status filters over the loaded runs. */
export function filterRuns(
runs: TraceSummary[],
query: string,
agent: string,
status: RunStatusFilter,
): TraceSummary[] {
const q = query.trim().toLowerCase();
return runs.filter((run) => {
const haystack = [run.trace_id, previewText(run.input_preview), run.name].map((s) => s.toLowerCase());
const matchesQuery = !q || haystack.some((text) => text.includes(q));
const matchesAgent = agent === ALL_AGENTS || traceAgentNames(run).includes(agent);
const failed = run.error_count > 0;
const matchesStatus = status === "all" || (status === "error" ? failed : !failed);
return matchesQuery && matchesAgent && matchesStatus;
});
}
const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] =>
runs.filter((run) => {
const t = moment(run.start_time).valueOf();
@ -96,7 +76,7 @@ function TraceHistoryError({ history }: { history: ReturnType<typeof useTraceAva
);
}
/** The Runs view: filters, the runs table and footer — or one run, in place, once a row is clicked. */
/** The Runs view: filters and the runs table — or one run, in place, once a row is clicked. */
export function AgentTracesSection({
accessToken,
isActive,
@ -110,7 +90,7 @@ export function AgentTracesSection({
}: AgentTracesSectionProps) {
const live = useTracesLive();
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
const { query, agent, status, setQuery, setAgent, setStatus } = useRunFilterRouting();
const { query, setQuery } = useRunFilterRouting();
const [showSetup, setShowSetup] = useState(false);
const [zoom, setZoom] = useZoomRouting();
const [rangeChanged, setRangeChanged] = useState(false);
@ -125,17 +105,13 @@ export function AgentTracesSection({
if (setup.disabledDetail == null) void history.refetch();
};
const agents = useMemo(() => Array.from(new Set(traces.traces.flatMap(traceAgentNames))).sort(), [traces.traces]);
// Relative ranges end "now" (the list query uses Date.now() too); round to the minute so the histogram is stable.
const endMs = isCustomDate ? moment(endTime).valueOf() : moment().endOf("minute").valueOf();
const range = useMemo(
() => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }),
[startTime, endTime, isCustomDate, endMs],
);
const filtered = useMemo(
() => filterRuns(traces.traces, query, agent, status),
[traces.traces, query, agent, status],
);
const filtered = useMemo(() => filterRuns(traces.traces, query), [traces.traces, query]);
const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]);
const changeRange = (hours: number, apply: (hours: number) => void) => {
@ -169,7 +145,7 @@ export function AgentTracesSection({
<button
type="button"
onClick={() => setShowSetup(false)}
className="mb-3 text-[13px] text-muted-foreground hover:text-foreground"
className="mb-3 text-sm text-muted-foreground hover:text-foreground"
>
← Back to traces
</button>
@ -184,10 +160,9 @@ export function AgentTracesSection({
};
return (
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden bg-card">
{checkHistory && <TraceHistoryError history={history} />}
<TracesReceived received={setup.received} />
<TracingSetupAction live={live} isActive={isActive} onClick={() => setShowSetup(true)} />
<RunDrawer
trace={openTrace}
runs={runs}
@ -197,15 +172,7 @@ export function AgentTracesSection({
fullScreen={fullScreen}
onFullScreenChange={setFullScreen}
/>
<RunsToolbar
query={query}
agent={agent}
status={status}
agents={agents}
onQueryChange={setQuery}
onAgentChange={setAgent}
onStatusChange={setStatus}
>
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces}>
{timeControls && (
<TimeRangeControls
range={zoom ?? range}
@ -233,30 +200,13 @@ export function AgentTracesSection({
onLoadMore={traces.loadMore}
onOpenTrace={toggleRun}
selectedKey={openTrace === null ? null : traceKey(openTrace)}
/>
<RunsFooter
count={runs.length}
zoom={zoom}
isFetching={traces.isFetching}
failed={!!traces.error}
onResetZoom={() => setZoom(null)}
rangeEmpty={traces.traces.length === 0}
onSetUpTracing={() => setShowSetup(true)}
/>
</div>
);
}
function TracingSetupAction({ live, isActive, onClick }: { live: boolean; isActive: boolean; onClick: () => void }) {
if (!live || !isActive) return null;
return (
<HeaderActions>
<Button variant="outline" size="sm" onClick={onClick} className="shrink-0 gap-1.5">
<ActiveDot />
Set up tracing
</Button>
</HeaderActions>
);
}
function TracesReceived({ received }: { received: boolean }) {
if (!received) return null;
return (
@ -265,39 +215,3 @@ function TracesReceived({ received }: { received: boolean }) {
</p>
);
}
function RunsFooter({
count,
zoom,
isFetching,
failed,
onResetZoom,
}: {
count: number;
zoom: TimeWindow | null;
isFetching: boolean;
failed: boolean;
onResetZoom: () => void;
}) {
const settled = failed ? "Update failed" : "Updated just now";
const status = isFetching ? "Updating…" : settled;
return (
<footer
data-testid="runs-footer"
className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground"
>
{count} {count === 1 ? "run" : "runs"}
{zoom && (
<button
type="button"
onClick={() => onResetZoom()}
aria-label="Clear time zoom"
className="ml-3 rounded border border-info/40 bg-info/10 px-1.5 text-info hover:bg-info/20"
>
{moment(zoom.startMs).format("MMM DD, HH:mm")} to {moment(zoom.endMs).format("MMM DD, HH:mm")} ×
</button>
)}
<span className="ml-auto">{status}</span>
</footer>
);
}

View file

@ -0,0 +1,69 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import traceList from "./__fixtures__/trace_list.json";
import { AgentTracesTable } from "./AgentTracesTable";
import type { TracePage, TraceSummary } from "./traceTypes";
const renderEmpty = (rangeEmpty: boolean) => {
const onSetUpTracing = vi.fn();
render(
<AgentTracesTable
traces={[]}
isLoading={false}
error={null}
hasMore={false}
onLoadMore={vi.fn()}
onOpenTrace={vi.fn()}
rangeEmpty={rangeEmpty}
onSetUpTracing={onSetUpTracing}
/>,
);
return onSetUpTracing;
};
describe("AgentTracesTable empty state", () => {
it.each([
{ rangeEmpty: true, message: "No runs in this time range" },
{ rangeEmpty: false, message: "No runs match these filters." },
])("offers tracing setup when rangeEmpty is $rangeEmpty", ({ rangeEmpty, message }) => {
const onSetUpTracing = renderEmpty(rangeEmpty);
expect(screen.getByText(message)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Set up tracing" }));
expect(onSetUpTracing).toHaveBeenCalledOnce();
});
});
describe("AgentTracesTable virtualization", () => {
const template = (traceList as TracePage).data[0] as TraceSummary;
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({
...template,
trace_id: `trace-${i}`,
input_preview: `question ${i}`,
}));
it("renders only the rows near the viewport as the list scrolls", () => {
renderWithProviders(
<AgentTracesTable
traces={manyRuns}
isLoading={false}
error={null}
hasMore={false}
onLoadMore={vi.fn()}
onOpenTrace={vi.fn()}
onSetUpTracing={vi.fn()}
/>,
);
expect(screen.getAllByTestId("agent-trace-row").length).toBeLessThan(50);
expect(screen.getByText("question 0")).toBeInTheDocument();
const scroller = screen.getByTestId("runs-table");
scroller.scrollTop = 36 * 480;
fireEvent.scroll(scroller);
expect(screen.getAllByTestId("agent-trace-row").length).toBeLessThan(50);
expect(screen.getByText("question 499")).toBeInTheDocument();
expect(screen.queryByText("question 0")).not.toBeInTheDocument();
});
});

View file

@ -1,9 +1,11 @@
"use client";
import { ArrowDown, ChevronRight } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { PANEL_TRIGGER } from "@/components/shared/SidePanel";
import { Button } from "@/components/ui/button";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
@ -24,6 +26,8 @@ interface AgentTracesTableProps {
onLoadMore: () => void;
onOpenTrace: (trace: TraceSummary) => void;
selectedKey?: string | null;
rangeEmpty?: boolean;
onSetUpTracing: () => void;
}
export const formatCost = (cost: number): string => {
@ -33,10 +37,13 @@ export const formatCost = (cost: number): string => {
};
const firstLine = (text: string): string => text.split("\n")[0] ?? text;
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
const TH = "px-3 font-medium";
const PREFETCH_MARGIN = "0px 0px 480px 0px";
const PLACEHOLDER_ROWS = [0, 1, 2];
const ROW_HEIGHT = 36;
const OVERSCAN_ROWS = 12;
const TH_NUM = "px-3 text-right font-medium";
const TD_NUM = "px-3 text-right font-mono tabular-nums text-muted-foreground";
@ -70,6 +77,106 @@ function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void })
);
}
function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) {
if (!rangeEmpty)
return (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No runs match these filters.</span>
<Button size="xs" variant="outline" onClick={onSetUpTracing}>
Set up tracing
</Button>
</div>
);
return (
<div className="flex flex-col items-center gap-1 py-16 text-center">
<p className="text-sm font-medium">No runs in this time range</p>
<p className="text-xs text-muted-foreground">Connect an agent to start sending traces.</p>
<Button size="sm" className="mt-3" onClick={onSetUpTracing}>
Set up tracing
</Button>
</div>
);
}
function RunRow({
run,
selected,
onOpen,
}: {
run: TraceSummary;
selected: boolean;
onOpen: (run: TraceSummary) => void;
}) {
return (
<tr
data-testid="agent-trace-row"
{...PANEL_TRIGGER}
onClick={() => onOpen(run)}
aria-selected={selected}
className={cn(
"h-9 cursor-pointer border-b border-border/60 text-xs transition-colors duration-150 motion-reduce:transition-none",
selected ? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]" : "hover:bg-trace-row-hover",
)}
>
<td
className="px-3 font-mono text-xs whitespace-nowrap tabular-nums text-muted-foreground"
title={formatActivityTimestamp(run.start_time)}
>
{formatRunTimestamp(run.start_time)}
</td>
<AgentCell run={run} />
<td className="px-3">
<div className="flex min-w-0 items-center gap-2">
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
<span className="truncate text-foreground">
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
</span>
{run.resolution_limited && (
<span
className="shrink-0 text-xs text-muted-foreground"
title="This run is too large to calculate all totals in this view"
>
Partial totals
</span>
)}
<span className="hidden shrink-0 font-mono text-xs text-muted-foreground 2xl:inline">{run.trace_id}</span>
</div>
</td>
<td className={TD_NUM}>{run.agent_count.toLocaleString()}</td>
<td className={TD_NUM}>{run.span_count.toLocaleString()}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{fmtMs(run.duration_ms)}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">
{run.spend == null ? "—" : formatCost(run.spend)}
</td>
<td className="px-3 text-right">
{run.error_count > 0 ? (
<StatusMark status="error" count={run.error_count} />
) : (
<span className="font-mono text-xs text-muted-foreground/60">0</span>
)}
</td>
<td>
<ChevronRight className="size-3 text-muted-foreground/60" />
</td>
</tr>
);
}
function useVirtualRows(traces: TraceSummary[], scroller: HTMLDivElement | null) {
const options = {
count: traces.length,
getScrollElement: () => scroller,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN_ROWS,
getItemKey: (index: number) => runKey(traces[index]),
};
const virtualizer = useVirtualizer(options);
const rows = virtualizer.getVirtualItems();
const padTop = rows[0]?.start ?? 0;
const padBottom = virtualizer.getTotalSize() - (rows.at(-1)?.end ?? 0);
return { rows, padTop, padBottom };
}
/** Devtool-dense runs list: one row per agent run, newest first. */
export function AgentTracesTable({
traces,
@ -81,6 +188,8 @@ export function AgentTracesTable({
onLoadMore,
onOpenTrace,
selectedKey = null,
rangeEmpty = false,
onSetUpTracing,
}: AgentTracesTableProps) {
const settled = !isLoading && !error;
const isEmpty = settled && !hasMore && traces.length === 0;
@ -91,6 +200,7 @@ export function AgentTracesTable({
useEffect(() => {
if (nearTail && autoContinue && !isFetching) onLoadMore();
}, [nearTail, autoContinue, isFetching, onLoadMore]);
const { rows, padTop, padBottom } = useVirtualRows(traces, scroller);
return (
<div ref={setScroller} className="min-h-0 flex-1 overflow-auto" data-testid="runs-table">
<table
@ -99,7 +209,7 @@ export function AgentTracesTable({
className="w-full min-w-[900px] table-fixed border-collapse text-left"
>
<thead className="sticky top-0 z-sticky bg-[color-mix(in_oklab,var(--muted)_40%,var(--card))]">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<tr className="h-8 border-b border-border text-xs tracking-wider text-muted-foreground uppercase">
<th className={`w-[170px] ${TH}`}>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
Time <ArrowDown className="size-2.5" />
@ -119,70 +229,18 @@ export function AgentTracesTable({
</tr>
</thead>
<tbody>
{traces.map((run) => (
<tr
key={run.trace_ref || run.trace_id}
data-testid="agent-trace-row"
onClick={() => onOpenTrace(run)}
aria-selected={selectedKey === (run.trace_ref || run.trace_id)}
className={cn(
"h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 motion-reduce:transition-none",
selectedKey === (run.trace_ref || run.trace_id)
? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]"
: "hover:bg-trace-row-hover",
)}
>
<td
className="px-3 font-mono text-[11px] whitespace-nowrap tabular-nums text-muted-foreground"
title={formatActivityTimestamp(run.start_time)}
>
{formatRunTimestamp(run.start_time)}
</td>
<AgentCell run={run} />
<td className="px-3">
<div className="flex min-w-0 items-center gap-2">
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
<span className="truncate text-foreground">
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
</span>
{run.resolution_limited && (
<span
className="shrink-0 text-[10px] text-muted-foreground"
title="This run is too large to calculate all totals in this view"
>
Partial totals
</span>
)}
<span className="hidden shrink-0 font-mono text-[10px] text-muted-foreground 2xl:inline">
{run.trace_id}
</span>
</div>
</td>
<td className={TD_NUM}>{run.agent_count.toLocaleString()}</td>
<td className={TD_NUM}>{run.span_count.toLocaleString()}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{fmtMs(run.duration_ms)}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">
{run.spend == null ? "—" : formatCost(run.spend)}
</td>
<td className="px-3 text-right">
{run.error_count > 0 ? (
<StatusMark status="error" count={run.error_count} />
) : (
<span className="font-mono text-[11px] text-muted-foreground/60">0</span>
)}
</td>
<td>
<ChevronRight className="size-3 text-muted-foreground/60" />
</td>
</tr>
{padTop > 0 && <tr aria-hidden style={{ height: padTop }} />}
{rows.map(({ index, key }) => (
<RunRow key={key} run={traces[index]} selected={selectedKey === key} onOpen={onOpenTrace} />
))}
{padBottom > 0 && <tr aria-hidden style={{ height: padBottom }} />}
{autoContinue &&
PLACEHOLDER_ROWS.map((row) => <PlaceholderRow key={row} rowRef={row === 0 ? tailRef : undefined} />)}
</tbody>
</table>
{isLoading && <div className="py-16 text-center text-[12px] text-muted-foreground">Loading runs…</div>}
{isLoading && <div className="py-16 text-center text-xs text-muted-foreground">Loading runs…</div>}
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-[12px] text-muted-foreground">
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
</span>
@ -194,16 +252,14 @@ export function AgentTracesTable({
</div>
)}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-[12px] text-muted-foreground">
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && (
<div className="py-16 text-center text-[12px] text-muted-foreground">No runs match these filters.</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</div>
);
}

View file

@ -14,7 +14,7 @@ interface AttributesDetailProps {
export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section aria-label={title} className="flex flex-col gap-2">
<h3 className="py-1 text-[13px] font-medium tracking-[-0.26px] text-trace-text">{title}</h3>
<h3 className="py-1 text-sm font-medium text-trace-text">{title}</h3>
<Card className="px-3 py-2.5">{children}</Card>
</section>
);
@ -38,7 +38,7 @@ export function AttributesDetail({ traceId, span, attributes, isLoading }: Attri
<KeyValueRows entries={attributeEntries} />
</DetailGroup>
)}
{isLoading && <div className="text-[13px] text-trace-duration">Loading attributes…</div>}
{isLoading && <div className="text-sm text-trace-duration">Loading attributes…</div>}
</div>
);
}

View file

@ -16,9 +16,8 @@ import { errorSource, parseJson, parseMessages, prettyPayload } from "./traceUti
const ERROR_SOURCE_LABEL: Record<ErrorSource, string> = { tool: "Tool", model: "Model", litellm: "LiteLLM" };
const TRACEBACK_MARKER = "Traceback (most recent call last):";
const STATUS_TEXT = "px-5 py-2 text-[13px] tracking-[-0.26px] text-muted-foreground";
const PAYLOAD_PRE =
"font-mono text-[13px] leading-[1.5] tracking-[-0.26px] break-words whitespace-pre-wrap text-foreground";
const STATUS_TEXT = "px-3 py-2 text-sm text-muted-foreground";
const PAYLOAD_PRE = "font-mono text-sm leading-normal break-words whitespace-pre-wrap text-foreground";
/** Exporters record `repr(exc)` + traceback with no separator; keep the exception line. */
export const errorHeadline = (error: string): string =>
@ -46,9 +45,9 @@ export function ErrorBlock({ span }: { span: Span }) {
return (
<section
aria-label="Error"
className="mx-3 mb-3 rounded-[4px] border-[0.67px] border-destructive/40 bg-destructive/5 px-3 py-2.5"
className="mx-3 mb-3 rounded-sm border border-destructive/40 bg-destructive/5 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-destructive">
<div className="flex items-center gap-2 text-sm leading-tight font-medium text-destructive">
<AlertTriangle className="size-3.5" />
{ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
</div>
@ -210,7 +209,7 @@ export function DetailContent({ accessToken, traceId, traceRef, span }: DetailCo
: parseMessages(detail?.input ?? "")?.length;
return (
<div className="flex flex-col px-2 pt-1 pb-4">
<div className="flex flex-col pt-1 pb-4">
<ErrorBlock span={span} />
{span.error && (
<DiagnosticContent
@ -239,9 +238,7 @@ export function DetailContent({ accessToken, traceId, traceRef, span }: DetailCo
</Section>
) : null}
{empty && span.status !== "error" && (
<div className="py-12 text-center text-[13px] tracking-[-0.26px] text-muted-foreground">
No content recorded for this span.
</div>
<div className="py-12 text-center text-sm text-muted-foreground">No content recorded for this span.</div>
)}
</div>
);

View file

@ -9,6 +9,7 @@ import { AttributesDetail } from "./AttributesDetail";
import { CopyButton } from "./CopyButton";
import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
import { IdChip } from "./IdChip";
import { PaneBar } from "./PaneBar";
import { RequestDetail } from "./RequestDetail";
import { SpanIcon } from "./SpanIcon";
import { useTracesApi } from "./tracesApi";
@ -47,7 +48,7 @@ function PaneHeader({
onClose: () => void;
}) {
return (
<div className="flex h-7 shrink-0 items-center justify-between gap-3 px-5">
<PaneBar className="justify-between gap-3">
<div className="flex min-w-0 items-center">
<span className="mr-2 shrink-0">
<SpanIcon type={type} model={model} error={failed} size="md" />
@ -64,20 +65,16 @@ function PaneHeader({
size="icon-xs"
onClick={onClose}
aria-label="Close details"
className="size-6 shrink-0 rounded-[4px] text-muted-foreground"
className="size-6 shrink-0 rounded-sm text-muted-foreground"
>
<PanelRightClose className="size-4" />
</Button>
</div>
</PaneBar>
);
}
function PaneFooter({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t bg-background px-5 py-2">
{children}
</div>
);
return <div className="flex h-10 shrink-0 items-center gap-3 border-t bg-background px-3">{children}</div>;
}
function Meta({ label, value }: { label: string; value: string }) {
@ -112,10 +109,7 @@ function SpanPane({
);
const tokens = span.input_tokens + span.output_tokens;
return (
<aside
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
aria-label="Span details"
>
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Span details">
<PaneHeader
type={span.type}
model={span.model}
@ -125,15 +119,15 @@ function SpanPane({
onClose={onClose}
/>
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
<div className="shrink-0 border-b px-5">
<TabsList variant="line" aria-label="Span detail sections" className="h-10 gap-4 px-0">
<PaneBar>
<TabsList variant="line" aria-label="Span detail sections" className="h-full gap-4 px-0">
{SPAN_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="flex-none px-0 text-xs">
{TAB_LABELS[id]}
</TabsTrigger>
))}
</TabsList>
</div>
</PaneBar>
<TabsContent value="content" className="min-h-0 overflow-auto pt-3">
<DetailContent accessToken={accessToken} traceId={traceId} traceRef={trace.summary.trace_ref} span={span} />
</TabsContent>
@ -163,8 +157,8 @@ function SpanPane({
function GroupMetric({ label, value }: { label: string; value: string }) {
return (
<div className="border-b border-border/60 py-3">
<div className="text-[13px] font-medium text-muted-foreground">{label}</div>
<div className="mt-1 text-[13px] text-foreground tabular-nums">{value}</div>
<div className="text-sm font-medium text-muted-foreground">{label}</div>
<div className="mt-1 text-sm text-foreground tabular-nums">{value}</div>
</div>
);
}
@ -186,10 +180,7 @@ function GroupPane({
const sample = (firstFailure ?? row.members[0]).span_id;
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, sample, trace.summary.trace_ref);
return (
<aside
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
aria-label="Group details"
>
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Group details">
<PaneHeader
type={row.type}
model={row.members[0]?.model ?? null}
@ -201,7 +192,7 @@ function GroupPane({
}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-auto px-5 py-3">
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
<div className="grid grid-cols-2 gap-x-5">
<GroupMetric label="Invocations" value={row.members.length.toLocaleString()} />
<GroupMetric label="Failed" value={row.failedCount.toLocaleString()} />
@ -211,9 +202,9 @@ function GroupPane({
<GroupMetric label="Type" value={row.type} />
</div>
{firstFailure?.error && (
<section className="mt-3 rounded-[4px] border border-destructive/30 px-3.5 py-3">
<div className="text-[12px] font-medium text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-[12px] leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
<section className="mt-3 rounded-sm border border-destructive/30 px-3.5 py-3">
<div className="text-xs font-medium text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-xs leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
</section>
)}
</div>
@ -228,7 +219,7 @@ function GroupPane({
export function DetailPane({ trace, row, accessToken, spanTab, onSpanTabChange, onClose }: DetailPaneProps) {
if (!row || row.kind === "load-more") {
return (
<div className="grid h-full place-items-center bg-background text-[13px] text-muted-foreground">
<div className="grid h-full place-items-center bg-background text-sm text-muted-foreground">
Select a span to inspect it.
</div>
);

View file

@ -35,7 +35,7 @@ function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
const [key, value] = entry;
const [open, setOpen] = useState(false);
const valueClass = cn(
"min-w-0 flex-1 break-words text-left text-[13px] leading-5 text-foreground",
"min-w-0 flex-1 break-words text-left text-sm leading-5 text-foreground",
mono || ID_KEY.test(key) ? "font-mono" : "font-sans",
);
@ -72,7 +72,7 @@ function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
<button {...toggle} className="flex shrink-0 cursor-pointer items-baseline gap-2.5 text-left">
{head}
</button>
<pre className={cn(valueClass, "leading-[1.5] break-words whitespace-pre-wrap")}>{value}</pre>
<pre className={cn(valueClass, "leading-normal break-words whitespace-pre-wrap")}>{value}</pre>
</>
) : (
<button {...toggle} className="flex min-w-0 flex-1 cursor-pointer items-baseline gap-2.5 text-left">

View file

@ -31,7 +31,7 @@ const markdownComponents: Components = {
<pre
className={cn(
BLOCK_GAP,
"max-w-full overflow-x-auto rounded-[4px] bg-muted/40 p-2.5 font-mono text-[13px] leading-[1.5] tracking-normal",
"max-w-full overflow-x-auto rounded-sm bg-muted/40 p-2.5 font-mono text-sm leading-normal tracking-normal",
)}
{...props}
/>
@ -55,7 +55,7 @@ export function Card({ children, className }: { children: React.ReactNode; class
return <section className={cn("rounded-md border border-border bg-background", className)}>{children}</section>;
}
const TILE = "grid size-4 shrink-0 place-items-center rounded-[2px] p-0.5";
const TILE = "grid size-4 shrink-0 place-items-center rounded-sm p-0.5";
function RoleTile({ failed = false }: { role: string; failed?: boolean }) {
return <Wrench className={cn("size-3.5 shrink-0", failed ? "text-destructive" : "text-muted-foreground")} />;
@ -83,8 +83,8 @@ const HEADER =
"group/header flex min-w-0 items-center gap-2 border border-border bg-background py-2 pr-2 pl-3 transition-colors hover:bg-muted/30";
const LABEL = "min-w-0 truncate text-xs leading-5 font-medium text-foreground";
const CARD_COPY =
"ml-auto size-5 shrink-0 rounded-[3px] text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
const INLINE_COPY = "size-4 shrink-0 rounded-[3px] text-muted-foreground";
"ml-auto size-5 shrink-0 rounded-sm text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
const INLINE_COPY = "size-4 shrink-0 rounded-sm text-muted-foreground";
export function ToolCallBlock({ call }: { call: TraceToolCall }) {
const argEntries = objectEntries(call.args);
@ -111,7 +111,7 @@ export function MessageCard({ message }: { message: TraceMessage; model: string
const expanded = open && hasBody;
return (
<article className="rounded-md">
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-[4px] border-b-0" : "rounded-[4px]")}>
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-sm border-b-0" : "rounded-sm")}>
<FoldTile label={label} open={open} onToggle={() => setOpen((v) => !v)} />
<span className={LABEL}>{label}</span>
<CopyButton value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
@ -142,7 +142,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
const tone = failed ? "text-destructive" : "text-muted-foreground";
return (
<article className="flex flex-col">
<div className={cn(HEADER, open ? "rounded-t-[4px]" : "rounded-[4px]")}>
<div className={cn(HEADER, open ? "rounded-t-sm" : "rounded-sm")}>
<RoleTile role="tool" failed={failed} />
<span className="flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5">
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
@ -157,17 +157,17 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
className={cn("flex min-w-0 cursor-pointer items-center gap-1 text-left", tone)}
>
<FoldChevron open={open} className="size-3 shrink-0" />
{!open && <span className="min-w-0 truncate text-[13px] leading-[1.2]">{result}</span>}
{!open && <span className="min-w-0 truncate text-sm leading-tight">{result}</span>}
</button>
) : (
<span className={cn("min-w-0 break-words text-[13px] leading-5", tone)}>{result || "No output"}</span>
<span className={cn("min-w-0 break-words text-sm leading-5", tone)}>{result || "No output"}</span>
)}
<CopyButton value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
</div>
{open && (
<pre
className={cn(
"max-h-80 overflow-auto rounded-b-[4px] border-[0.67px] border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-[12px] leading-[1.5] break-words whitespace-pre-wrap",
"max-h-80 overflow-auto rounded-b-sm border border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-xs leading-normal break-words whitespace-pre-wrap",
tone,
)}
>
@ -197,10 +197,10 @@ export function Section({
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background py-1 pr-3 pl-2 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background px-3 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
>
<FoldChevron open={open} className="size-3 shrink-0 text-foreground" />
<span className="text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-foreground">{title}</span>{" "}
<span className="text-sm leading-tight font-medium text-foreground">{title}</span>{" "}
{count !== undefined && (
<span className="text-xs font-normal text-muted-foreground">
{count} {count === 1 ? "message" : "messages"}

View file

@ -0,0 +1,5 @@
import { cn } from "@/lib/cva.config";
export function PaneBar({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex h-10 shrink-0 items-center gap-2 border-b px-3", className)}>{children}</div>;
}

View file

@ -30,7 +30,7 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
const logNotFound = lookupDone && logQuery.data === null;
if (span.type !== "llm") {
return <div className="py-16 text-center text-[13px] text-trace-duration">This span is not a model request.</div>;
return <div className="py-16 text-center text-sm text-trace-duration">This span is not a model request.</div>;
}
const usage: KeyValue[] = [
@ -57,15 +57,15 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
<Button
variant="outline"
size="xs"
className="self-start border-trace-border text-[13px] text-trace-text-2 shadow-trace-xs"
className="self-start border-trace-border text-sm text-trace-text-2 shadow-trace-xs"
onClick={() => setDrawerOpen(true)}
>
Open request log <ArrowUpRight className="size-3.5" />
</Button>
{logNotFound && <p className="text-[13px] text-trace-duration">No request log found for this call.</p>}
{logNotFound && <p className="text-sm text-trace-duration">No request log found for this call.</p>}
</div>
) : (
<p className="text-[13px] text-trace-duration">Not linked to a LiteLLM request</p>
<p className="text-sm text-trace-duration">Not linked to a LiteLLM request</p>
)}
</DetailGroup>
<LogDetailsDrawer

View file

@ -2,7 +2,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { clampDrawerWidth, RunDrawer } from "./RunDrawer";
import { clampDrawerWidth, PANEL_TRIGGER } from "@/components/shared/SidePanel";
import { RunDrawer } from "./RunDrawer";
import { type RunSelection, traceRefOf, useOpenTraceRouting } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
@ -62,11 +63,15 @@ const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const mockReducedMotion = (reduce: boolean) =>
vi
.spyOn(window, "matchMedia")
.mockImplementation(
(query: string) => ({ matches: reduce && query.includes("reduce"), media: query }) as MediaQueryList,
);
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: reduce && query.includes("reduce"),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
describe("clampDrawerWidth", () => {
it("keeps at least 700px and a 100px strip of list on wide screens", () => {
@ -156,6 +161,70 @@ describe("RunDrawer", () => {
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it("leaves J, K and Escape to an open menu or listbox instead of stepping or closing the trace", () => {
const runs = [run("a"), run("b"), run("c")];
const onSelect = vi.fn();
renderWithProviders(
<>
<div role="menu">
<button type="button">menu item</button>
</div>
<div role="listbox">
<button type="button">option</button>
</div>
<RunDrawer
trace={traceRefOf(runs[1])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>
</>,
);
for (const target of [screen.getByText("menu item"), screen.getByText("option")]) {
for (const key of ["j", "k", "Escape"]) fireEvent.keyDown(target, { key });
}
expect(onSelect).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: "j" });
expect(onSelect).toHaveBeenCalledExactlyOnceWith(traceRefOf(runs[2]));
});
it("closes on a press outside the panel but not inside it, on a trigger row, or in a dialog", () => {
const runs = [run("a")];
const onSelect = vi.fn();
renderWithProviders(
<>
<button type="button">outside</button>
<table>
<tbody>
<tr {...PANEL_TRIGGER}>
<td>row</td>
</tr>
</tbody>
</table>
<div role="dialog">dialog</div>
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>
</>,
);
fireEvent.mouseDown(screen.getByTestId("run-view"));
fireEvent.mouseDown(screen.getByText("row"));
fireEvent.mouseDown(screen.getByText("dialog"));
fireEvent.mouseDown(screen.getByText("outside"), { button: 2 });
expect(onSelect).not.toHaveBeenCalled();
fireEvent.mouseDown(screen.getByText("outside"));
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it("unmounts right away on close when the user prefers reduced motion", () => {
mockReducedMotion(true);
const runs = [run("a")];

View file

@ -1,143 +1,12 @@
"use client";
import { ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";
import { cn } from "@/lib/cva.config";
import { SidePanel } from "@/components/shared/SidePanel";
import { RunView } from "./TraceDrawer";
import { type RunSelection, type TraceRef, traceKey, traceRefOf } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { ignoresLetterShortcut } from "../letterShortcut";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
const viewportWidth = (): number => (typeof window === "undefined" ? 1440 : window.innerWidth);
/** At least 700px when the screen allows, never wider than the screen minus a 100px strip of list (or the full screen). */
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
const prefersReducedMotion = (): boolean =>
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
const readStoredWidth = (): number | null => {
try {
const raw = window.localStorage.getItem(WIDTH_KEY);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
};
const storeWidth = (width: number): void => {
try {
window.localStorage.setItem(WIDTH_KEY, String(width));
} catch {
return;
}
};
function useDrawerWidth() {
const [width, setWidth] = useState(() =>
clampDrawerWidth(readStoredWidth() ?? viewportWidth() * DEFAULT_FRACTION, viewportWidth()),
);
const update = useCallback((next: number) => {
const clamped = clampDrawerWidth(next, viewportWidth());
setWidth(clamped);
storeWidth(clamped);
}, []);
useEffect(() => {
const onResize = () => setWidth((w) => clampDrawerWidth(w, viewportWidth()));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return [width, update] as const;
}
function ResizeHandle({ width, onResize }: { width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(viewportWidth() - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize trace panel"
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
function FullScreenButton({ fullScreen, onToggle }: { fullScreen: boolean; onToggle: () => void }) {
return (
<HeaderButton label={fullScreen ? "Exit full screen" : "Enter full screen"} onClick={onToggle}>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
);
}
interface RunDrawerProps {
trace: TraceRef | null;
@ -159,15 +28,6 @@ export function RunDrawer({
fullScreen,
onFullScreenChange,
}: RunDrawerProps) {
const [width, setWidth] = useDrawerWidth();
const [lastShown, setLastShown] = useState<TraceRef | null>(trace);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (trace !== null && trace !== lastShown) setLastShown(trace);
const shown = trace ?? lastShown;
const closing = trace === null && shown !== null;
if (trace !== null && exitedKey !== null) setExitedKey(null);
if (closing && exitedKey !== traceKey(shown) && prefersReducedMotion()) setExitedKey(traceKey(shown));
const index = trace === null ? -1 : runs.findIndex((run) => traceKey(traceRefOf(run)) === traceKey(trace));
const step = useCallback(
(delta: number) => {
@ -176,75 +36,31 @@ export function RunDrawer({
},
[runs, index, onSelect],
);
useEffect(() => {
if (trace === null) return;
const onKeyDown = (event: KeyboardEvent) => {
if (ignoresLetterShortcut(event)) return;
if (event.key === "Escape") {
event.preventDefault();
onSelect(null);
} else if (event.key === "j") {
event.preventDefault();
step(1);
} else if (event.key === "k") {
event.preventDefault();
step(-1);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [trace, step, onSelect]);
if (shown === null || (closing && exitedKey === traceKey(shown))) return null;
const close = useCallback(() => onSelect(null), [onSelect]);
return (
<aside
aria-label="Trace details"
data-testid="run-drawer"
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (closing && event.target === event.currentTarget) setExitedKey(traceKey(shown));
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
<SidePanel
item={trace}
itemKey={traceKey}
noun="trace"
label="Trace details"
testId="run-drawer"
index={index}
total={runs.length}
onStep={step}
onClose={close}
fullScreen={fullScreen}
onFullScreenChange={onFullScreenChange}
>
{!fullScreen && <ResizeHandle width={width} onResize={setWidth} />}
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={() => onSelect(null)}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label="Next trace (J)" disabled={index < 0 || index >= runs.length - 1} onClick={() => step(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label="Previous trace (K)" disabled={index <= 0} onClick={() => step(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
{index + 1} / {runs.length}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<FullScreenButton fullScreen={fullScreen} onToggle={() => onFullScreenChange(!fullScreen)} />
<HeaderButton label="Close trace (Esc)" onClick={() => onSelect(null)}>
<X className="size-4" />
</HeaderButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{(shown) => (
<RunView
key={traceKey(shown)}
traceId={shown.traceId}
traceRef={shown.traceRef}
selection={selection}
accessToken={accessToken}
onBack={() => onSelect(null)}
onBack={close}
embedded
/>
</div>
</aside>
)}
</SidePanel>
);
}

View file

@ -1,89 +0,0 @@
"use client";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export type RunStatusFilter = "all" | "ok" | "error";
export const ALL_AGENTS = "all";
interface RunsToolbarProps {
query: string;
agent: string;
status: RunStatusFilter;
agents: string[];
onQueryChange: (value: string) => void;
onAgentChange: (value: string) => void;
onStatusChange: (value: RunStatusFilter) => void;
/** Extra controls (time range, live tail) rendered on the right. */
children?: React.ReactNode;
}
const STATUS_ITEMS: { value: RunStatusFilter; label: string }[] = [
{ value: "all", label: "All status" },
{ value: "ok", label: "Succeeded" },
{ value: "error", label: "Failed" },
];
/** Search + agent / status filters for the Runs table. Filtering is client-side over the loaded page. */
export function RunsToolbar({
query,
agent,
status,
agents,
onQueryChange,
onAgentChange,
onStatusChange,
children,
}: RunsToolbarProps) {
const agentItems = [{ value: ALL_AGENTS, label: "All agents" }, ...agents.map((s) => ({ value: s, label: s }))];
return (
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-2">
<div className="relative w-full max-w-[380px]">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search input or trace ID"
aria-label="Search runs"
className="h-7 pl-8 text-[12px]"
/>
</div>
<Select
items={agentItems}
value={agent}
onValueChange={(value: string | null) => value !== null && onAgentChange(value)}
>
<SelectTrigger size="sm" className="h-7 min-w-[130px] text-[12px]" aria-label="Filter by agent">
<SelectValue />
</SelectTrigger>
<SelectContent>
{agentItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
items={STATUS_ITEMS}
value={status}
onValueChange={(value: RunStatusFilter | null) => value !== null && onStatusChange(value)}
>
<SelectTrigger size="sm" className="h-7 min-w-[110px] text-[12px]" aria-label="Filter by status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -125,7 +125,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
</span>
)}
</div>
<span className="text-[12px] text-trace-duration">
<span className="text-xs text-trace-duration">
{TYPE_LABEL[facts.type]}
{facts.model ? ` · ${facts.model}` : ""}
</span>
@ -150,7 +150,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
<FactSection title="Tags">
<ul className="flex flex-wrap gap-1">
{facts.tags.map((tag) => (
<li key={tag} className="rounded-[3px] bg-trace-tag px-1 py-0.5 text-trace-duration">
<li key={tag} className="rounded-sm bg-trace-tag px-1 py-0.5 text-trace-duration">
{tag}
</li>
))}
@ -158,7 +158,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
</FactSection>
)}
{facts.error && (
<div className="rounded-[4px] bg-destructive/10 px-2 py-1.5 font-mono text-[12px] break-words text-destructive">
<div className="rounded-sm bg-destructive/10 px-2 py-1.5 font-mono text-xs break-words text-destructive">
{errorHeadline(facts.error)}
</div>
)}
@ -183,7 +183,7 @@ export function SpanHoverCard({ facts, traceStartMs, children }: SpanHoverCardPr
sideOffset={0}
alignOffset={0}
data-testid="span-hover-card"
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-[6px] border border-trace-border bg-trace-surface p-3 text-[13px] leading-[1.2] tracking-[-0.26px] text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-md border border-trace-border bg-trace-surface p-3 text-sm leading-tight text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
>
<HoverCardBody facts={facts} traceStartMs={traceStartMs} />
</HoverCardContent>

View file

@ -21,8 +21,8 @@ import { useSpanProvider } from "./spanProvider";
import type { SpanType } from "./traceTypes";
const SIZE = {
sm: "size-4 rounded-[2px]",
card: "size-[18px] rounded-[3px]",
sm: "size-4 rounded-sm",
card: "size-[18px] rounded-sm",
md: "size-5 rounded-full",
lg: "size-5 rounded-full",
} as const;

View file

@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "./Collapse";
import { PaneBar } from "./PaneBar";
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import { SpanIcon } from "./SpanIcon";
import type { GroupRowData, SpanRowData, TreeRow } from "./traceTree";
@ -50,9 +51,9 @@ const rowClass = (selected: boolean): string =>
selected ? "border-l-primary bg-accent" : "border-l-transparent hover:bg-muted/60",
);
const LINE = "border-dashed border-border [border-width:0] [border-left-width:1px]";
const ELBOW = "border-dashed border-border [border-width:0] [border-left-width:1px] [border-bottom-width:1px]";
const NAME = "line-clamp-2 break-words text-[13px] leading-[1.2] font-medium";
const LINE = "border-l border-dashed border-border";
const ELBOW = "border-b border-l border-dashed border-border";
const NAME = "line-clamp-2 break-words text-sm leading-tight font-medium";
const META = "text-xs leading-4 text-muted-foreground tabular-nums";
/** One 20px column per ancestor: pass-through rails for open branches, an elbow into this row's tile. */
@ -99,7 +100,7 @@ function Caret({ open, onToggle, label }: { open: boolean; onToggle: () => void;
role="button"
tabIndex={-1}
aria-label={label}
className="grid size-6 shrink-0 place-items-center self-start rounded-[4px] p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
className="grid size-6 shrink-0 place-items-center self-start rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
onClick={(event) => {
event.stopPropagation();
onToggle();
@ -295,79 +296,78 @@ export function SpanTree({
return (
<section className="flex h-full min-h-0 min-w-0 flex-col border-r border-border bg-card" aria-label="Run spans">
<div className="shrink-0 space-y-3 border-b px-3 py-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
Steps{" "}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{summary.span_count.toLocaleString()}
</span>
<PaneBar className="justify-between">
<span className="text-sm font-medium">
Steps{" "}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{filtering && `${rows.length.toLocaleString()} of `}
{summary.span_count.toLocaleString()}
</span>
<div className="flex items-center gap-1">
{onOpenDetails && (
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
<PanelRightOpen className="size-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
Hide framework spans
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</span>
<div className="flex items-center gap-1">
{onOpenDetails && (
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
<PanelRightOpen className="size-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
Hide framework spans
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-2.5 left-2.5 size-3.5 text-muted-foreground" />
</PaneBar>
<PaneBar>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search steps"
placeholder="Search steps"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="h-8 pr-8 pl-8 text-xs shadow-none md:text-xs"
className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
/>
{query && (
<Button
variant="ghost"
size="icon-xs"
aria-label="Clear step search"
className="absolute top-1 right-1"
className="absolute top-1/2 right-0.5 -translate-y-1/2"
onClick={() => onQueryChange("")}
>
<X className="size-3" />
</Button>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant={!errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={!errorsOnly}
onClick={() => onErrorsOnlyChange(false)}
>
All steps
</Button>
<Button
variant={errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={errorsOnly}
onClick={() => onErrorsOnlyChange(true)}
>
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
</Button>
{filtering && (
<span className="ml-auto text-xs text-muted-foreground" role="status">
{rows.length} found
</span>
)}
</div>
</div>
<Button
variant={!errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={!errorsOnly}
onClick={() => onErrorsOnlyChange(false)}
>
All
</Button>
<Button
variant={errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={errorsOnly}
onClick={() => onErrorsOnlyChange(true)}
>
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
</Button>
{filtering && (
<span role="status" className="sr-only">
{rows.length} found
</span>
)}
</PaneBar>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto py-2">
{rows.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
@ -385,7 +385,7 @@ export function SpanTree({
key={row.id}
type="button"
onClick={() => onLoadMore(row.groupId)}
className={cn(rowClass(false), "h-8 text-[13px] tracking-[-0.26px] text-muted-foreground")}
className={cn(rowClass(false), "h-8 text-sm text-muted-foreground")}
>
<Gutters depth={row.depth} guide={guides[i]} />
<span className="ml-0.5 flex items-center gap-2">
@ -413,7 +413,7 @@ export function SpanTree({
})}
</div>
</div>
<div className="flex min-h-8 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
{embedded ? (
<>
<span className="whitespace-nowrap">
@ -441,7 +441,7 @@ export function SpanTree({
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded-[3px] border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground">
<kbd className="rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground">
{children}
</kbd>
);

View file

@ -11,7 +11,7 @@ export function StatusMark({ status, count, subtle = false }: StatusMarkProps) {
if (status === "error") {
return (
<span
className="inline-flex items-center gap-1 text-[11px] text-destructive"
className="inline-flex items-center gap-1 text-xs text-destructive"
aria-label={`${count ?? 1} error${count === 1 ? "" : "s"}`}
>
<AlertCircle className="size-3" />

View file

@ -27,7 +27,7 @@ const RANGE_LABEL_FORMAT = "MMM D, h:mm A";
export const rangeLabel = (range: TimeWindow): string =>
`${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`;
const SEGMENT = "inline-flex h-7 items-center gap-1.5 px-2.5 text-[13px] outline-none focus-visible:bg-accent";
const SEGMENT = "inline-flex h-7 items-center gap-1.5 px-2.5 text-sm outline-none focus-visible:bg-accent";
interface TimeRangeControlsProps {
range: TimeWindow;
@ -79,7 +79,7 @@ export function TimeRangeControls({
onValueChange={(value: string) => onRangeHoursChange(Number(value))}
>
{RANGE_PRESETS.map((preset) => (
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-[13px]">
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-sm">
{preset.label}
</DropdownMenuRadioItem>
))}

View file

@ -133,6 +133,36 @@ describe("RunView", () => {
expect(screen.queryByRole("button", { name: "Back to runs" })).not.toBeInTheDocument();
});
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
const user = userEvent.setup();
let resolveSwarm: (trace: Trace) => void = () => {};
vi.mocked(agentTraceCall).mockImplementation((_token, traceId) =>
traceId === swarm.summary.trace_id
? new Promise<Trace>((resolve) => {
resolveSwarm = resolve;
})
: Promise.resolve(research),
);
const props = { accessToken: "sk-test", onBack: vi.fn(), embedded: true };
const { rerender } = renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} {...props} />);
const root = rootSpanId(research);
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
await user.keyboard("{ArrowDown}");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
act(() => resolveSwarm(swarm));
await waitFor(() =>
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(swarm.summary)),
);
expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "false");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", initialRunSelection(swarm).selectedId);
});
it("moves the selection with J / K and closes the detail pane with Esc", async () => {
const user = userEvent.setup();
renderRun(research);
@ -271,6 +301,15 @@ describe("RunView", () => {
expect(onBack).toHaveBeenCalledTimes(1);
});
it("loads the run on retry after a failed load", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceCall).mockRejectedValueOnce(new Error("Traces are temporarily unavailable"));
renderRun(research);
await user.click(await screen.findByRole("button", { name: "Retry" }));
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
});
it("finds a step beyond a folded group's first page and reveals it after search clears", async () => {
const user = userEvent.setup();
const root = research.spans.find((span) => span.parent_span_id === null)!;

View file

@ -1,9 +1,11 @@
"use client";
import { type TraceHandoff, useTracesApi } from "./tracesApi";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, Copy } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Suspense, useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useEventListener, useTimeout } from "usehooks-ts";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -18,7 +20,7 @@ import { SpanIcon } from "./SpanIcon";
import { SpanTree } from "./SpanTree";
import { TraceConversation } from "./TraceConversation";
import { FrameworkLogo, traceFramework } from "./TraceFramework";
import type { RunSelection } from "./traceRouting";
import { type RunSelection, traceKey } from "./traceRouting";
import type { SpanTreeState, TreeRow } from "./traceTree";
import type { Trace } from "./traceTypes";
import {
@ -80,16 +82,12 @@ const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
function CopyForAgent({ handoff }: { handoff: TraceHandoff }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 1600);
return () => window.clearTimeout(timeout);
}, [copied]);
useTimeout(() => setCopied(false), copied ? 1600 : null);
return (
<Button
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1.5 rounded-md text-[12px] shadow-none"
className="h-7 shrink-0 gap-1.5 rounded-md text-xs shadow-none"
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
@ -192,9 +190,10 @@ interface RunBodyProps {
accessToken: string;
selection: RunSelection;
embedded: boolean;
stale: boolean;
}
function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
function RunBody({ trace, accessToken, selection, embedded, stale }: RunBodyProps) {
const spanKeys = embedded ? EMBEDDED_SPAN_KEYS : SPAN_KEYS;
const { view, selectSpan, setView, stepQuery: query, setStepQuery: setQuery, errorsOnly, setErrorsOnly } = selection;
const [initial] = useState(() => initialRunSelection(trace, selection.spanId ?? undefined));
@ -249,14 +248,14 @@ function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
[],
);
useEffect(() => {
if (view !== "steps") return;
const setRowExpanded = (row: TreeRow, expand: boolean) => {
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
};
const onKeyDown = (event: KeyboardEvent) => {
if (ignoreStepKey(event)) return;
const setRowExpanded = (row: TreeRow, expand: boolean) => {
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
};
useEventListener(
"keydown",
(event) => {
if (stale || view !== "steps" || ignoreStepKey(event)) return;
const index = rows.findIndex((row) => row.id === selectedRow?.id);
const row = rows[index];
if (event.key === "Escape" && detailOpen) {
@ -278,10 +277,10 @@ function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
} else if (event.key === "ArrowRight" && row) {
setRowExpanded(row, true);
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [rows, selectedRow, detailOpen, select, toggleSpan, toggleGroup, spanKeys, view]);
},
undefined,
true,
);
if (view === "conversation")
return (
@ -364,15 +363,86 @@ interface RunViewProps {
embedded?: boolean;
}
function selectedSpanMissing(trace: Trace | undefined, spanId: string | null): boolean {
return Boolean(spanId && trace && !trace.spans.some((span) => span.span_id === spanId));
function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
}
export function RunView({ traceId, traceRef, selection, accessToken, onBack, embedded = false }: RunViewProps) {
function RunLoading({ embedded }: { embedded: boolean }) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
return (
<div className="p-6 text-xs" data-testid="run-view-error">
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" /> Back to traces
</button>
<h1 className="mb-2 text-sm font-medium">Could not load trace</h1>
<span className="text-muted-foreground">{error instanceof Error ? error.message : "Unknown error"}</span>
<Button variant="outline" size="sm" className="ml-3" onClick={onRetry}>
Retry
</Button>
</div>
);
}
/** Keeps the shown run on screen, inert, while the next one loads. */
export function RunView(props: RunViewProps) {
const traceId = useDeferredValue(props.traceId);
const traceRef = useDeferredValue(props.traceRef);
const switching = traceId !== props.traceId || traceRef !== props.traceRef;
const shownKey = traceKey({ traceId, traceRef });
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[shownKey]}
fallbackRender={({ error, resetErrorBoundary }) => (
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
)}
>
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function LoadedRun({
traceId,
traceRef,
selection,
accessToken,
onBack,
embedded = false,
switching,
}: RunViewProps & { switching: boolean }) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const queryKey = ["agentTrace", traceId, traceRef, accessToken];
const traceQueryOptions = {
queryKey: ["agentTrace", traceId, traceRef, accessToken],
queryKey,
queryFn: ({ pageParam }: { pageParam: string | null }) => traces.trace(traceId, traceRef, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined,
@ -382,14 +452,13 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
refetchOnMount: false,
retry: false,
};
const traceQuery = useInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey: traceQueryOptions.queryKey, exact: true });
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
const trace = useMemo(() => {
const pages = traceQuery.data?.pages;
if (!pages?.length) return undefined;
return { ...pages[0], spans: pages.flatMap((page) => page.spans) };
const [first, ...rest] = traceQuery.data.pages;
return { ...first, spans: [first, ...rest].flatMap((page) => page.spans) };
}, [traceQuery.data]);
const seekingSpan = selectedSpanMissing(trace, selection.spanId);
const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId);
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
const canSeek = seekingSpan && hasNextPage;
useEffect(() => {
@ -397,41 +466,6 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
}, [canSeek, isFetching, isError, fetchNextPage]);
const pageAction = isError ? "Retry" : "Load more steps";
if (traceQuery.isLoading) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
if (!trace) {
return (
<div className="p-6 text-[12px]" data-testid="run-view-error">
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" /> Back to traces
</button>
<h1 className="mb-2 text-[13px] font-medium">Could not load trace</h1>
<span className="text-muted-foreground">{traceQuery.error?.message ?? "Unknown error"}</span>
<Button variant="outline" size="sm" className="ml-3" onClick={() => void refreshTrace()}>
Retry
</Button>
</div>
);
}
return (
<Tabs
value={selection.view}
@ -439,7 +473,10 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
className={cn(
"@container/trace flex flex-1 flex-col gap-0 overflow-hidden bg-background",
embedded ? "min-h-0" : "min-h-[560px] border-y border-border",
switching && "opacity-60 transition-opacity delay-150 duration-150 motion-reduce:transition-none",
)}
aria-busy={switching}
inert={switching}
data-testid="run-view"
>
<RunHeader
@ -476,6 +513,7 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
accessToken={accessToken}
selection={selection}
embedded={embedded}
stale={switching}
/>
</Tabs>
);

View file

@ -1,11 +1,13 @@
"use client";
import { X } from "lucide-react";
import moment from "moment";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useMediaQuery, useResizeObserver } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { FIELD_COLS, FIELD_ROWS, agentDotColor, agoLabel, columnDots, isReceiving } from "./lensField";
import { FIELD_COLS, FIELD_ROWS, columnDots } from "./lensField";
import type { TraceSummary } from "./traceTypes";
import { traceAgentNames } from "./traceUtils";
@ -136,9 +138,10 @@ function drawField(canvas: HTMLCanvasElement, { buckets, max, band, hover, progr
}
function useRiseIn(): number {
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const [progress, setProgress] = useState(0);
useEffect(() => {
if (typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
if (reduceMotion) {
setProgress(1);
return;
}
@ -149,7 +152,7 @@ function useRiseIn(): number {
if (t < 1) frame = requestAnimationFrame(rise);
});
return () => cancelAnimationFrame(frame);
}, []);
}, [reduceMotion]);
return progress;
}
@ -166,17 +169,13 @@ function DotField({
}) {
const canvas = useRef<HTMLCanvasElement>(null);
const progress = useRiseIn();
const { width, height } = useResizeObserver({ ref: canvas as RefObject<HTMLCanvasElement> });
useEffect(() => {
const node = canvas.current;
if (!node) return;
const frame: FieldFrame = { buckets, max, band, hover, progress };
const paint = () => drawField(node, frame);
paint();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(paint);
observer.observe(node);
return () => observer.disconnect();
}, [buckets, max, band, hover, progress]);
if (canvas.current) {
const fieldFrame = { buckets, max, band, hover, progress };
drawField(canvas.current, fieldFrame);
}
}, [buckets, max, band, hover, progress, width, height]);
return (
<canvas
ref={canvas}
@ -191,74 +190,6 @@ function BucketBar({ bucket }: { bucket: Bucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.runs} />;
}
function useNow(intervalMs: number): number {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(timer);
}, [intervalMs]);
return now;
}
function LivePulse({ live }: { live: boolean }) {
return (
<span aria-hidden="true" className="relative flex size-2">
{live && (
<span className="absolute inline-flex size-full rounded-full bg-[#3b5bfd] opacity-60 motion-safe:animate-ping" />
)}
<span
className={cn("relative inline-flex size-2 rounded-full", live ? "bg-[#3b5bfd]" : "bg-muted-foreground/50")}
/>
</span>
);
}
function latestStart(runs: readonly TraceSummary[]): number | null {
return runs.reduce<number | null>((latest, run) => {
const t = moment(run.start_time).valueOf();
return latest === null || t > latest ? t : latest;
}, null);
}
function FieldHeader({ runs, range }: { runs: readonly TraceSummary[]; range: TimeWindow }) {
const now = useNow(1000);
const agents = Array.from(new Set(runs.flatMap(traceAgentNames)));
const failed = runs.filter((run) => run.error_count > 0).length;
const lastMs = latestStart(runs);
const live = isReceiving(lastMs, now);
return (
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 font-mono text-[11px] leading-none">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground tabular-nums">
<span className="flex items-center gap-2" role="status" data-testid="traces-last-seen">
<LivePulse live={live} />
<span className={cn("font-semibold", live ? "text-[#0011b3] dark:text-[#8b9bff]" : "text-foreground")}>
{live ? "receiving" : "idle"}
</span>
{lastMs !== null && <span>last trace {agoLabel(lastMs, now)}</span>}
</span>
<span>
<span className="font-semibold text-foreground">{runs.length.toLocaleString()}</span>{" "}
{runs.length === 1 ? "run" : "runs"} from{" "}
<span className="font-semibold text-foreground">{agents.length}</span>{" "}
{agents.length === 1 ? "agent" : "agents"}
{failed > 0 && <span className="text-[#e5484d]"> · {failed} failed</span>}
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-muted-foreground">
{agents.slice(0, 6).map((agent) => (
<span key={agent} className="flex items-center gap-1.5">
<span className="size-[6px] rounded-full" style={{ backgroundColor: agentDotColor(agent) }} />
{agent}
</span>
))}
<span className="rounded border border-border bg-muted/50 px-1.5 py-px">
Total {formatSpan(range.endMs - range.startMs)}
</span>
</div>
</div>
);
}
function NowEdge() {
return (
<>
@ -277,7 +208,7 @@ function NowEdge() {
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
return (
<div
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-[11px] text-popover-foreground shadow-md"
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
style={{ left: pct(Math.min(0.8, index / BUCKETS)) }}
role="tooltip"
>
@ -288,7 +219,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
{bucket.runs} {bucket.runs === 1 ? "run" : "runs"}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
</div>
<div className="text-[#0011b3] dark:text-[#8b9bff]">drag to zoom</div>
<div className="text-info">drag to zoom</div>
</div>
);
}
@ -351,8 +282,7 @@ function SelectionBracket({
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
const tick =
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
const edgeLabel =
"pointer-events-none absolute -bottom-5 font-mono text-[10px] whitespace-nowrap text-[#0011b3] tabular-nums dark:text-[#8b9bff]";
const edgeLabel = "pointer-events-none absolute -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
return (
<>
<div
@ -372,7 +302,7 @@ function SelectionBracket({
onPointerDown={onHandleDown("resize-hi")}
/>
{widthPx >= MIN_DURATION_LABEL_PX && (
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-[11px] font-semibold text-[#0011b3] dark:text-[#8b9bff]">
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-xs font-semibold text-info">
{formatSpan(window.endMs - window.startMs)}
</span>
)}
@ -404,7 +334,7 @@ function TickAxis({ range }: { range: TimeWindow }) {
style={{ left: pct(t), transform: tickShift(t) }}
>
<span className="h-1 w-px bg-border" />
<span className="font-mono text-[10px] whitespace-nowrap text-muted-foreground tabular-nums">
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
</span>
</div>
@ -421,18 +351,10 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null);
const areaRef = useRef<HTMLDivElement>(null);
const [stripWidth, setStripWidth] = useState(0);
useEffect(() => {
const el = areaRef.current;
if (!el) return;
const measure = () => setStripWidth(el.getBoundingClientRect().width);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
const { width: stripWidth = 0 } = useResizeObserver({
ref: areaRef as RefObject<HTMLDivElement>,
box: "border-box",
});
const committed = bandForWindow(buckets, selection);
const band = drag ? draft : committed;
@ -486,12 +408,11 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
tabIndex={0}
onKeyDown={onKeyDown}
>
<FieldHeader runs={runs} range={range} />
<div
ref={areaRef}
role="presentation"
data-testid="timeline-area"
className="relative mt-2 flex cursor-crosshair touch-none items-end"
className="relative flex cursor-crosshair touch-none items-end"
style={{ height: FIELD_HEIGHT }}
onPointerDown={(e) => begin("select", e)}
onPointerMove={onMove}
@ -525,6 +446,16 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
<TickAxis range={range} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
{selection && (
<button
type="button"
onClick={() => onSelect(null)}
aria-label="Clear time zoom"
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
);
}

View file

@ -2,6 +2,7 @@
import { ArrowRight, ArrowUpRight, Check, Copy, KeyRound, Loader2, Send } from "lucide-react";
import { useState } from "react";
import { useTimeout } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { LensPreviewButton } from "@/components/lens/LensPreviewButton";
@ -99,12 +100,8 @@ function CodeBlock({
wrap?: boolean;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
if (await copyToClipboard(code)) {
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
}
};
useTimeout(() => setCopied(false), copied ? COPIED_RESET_MS : null);
const copy = async () => setCopied(await copyToClipboard(code));
return (
<div className="overflow-hidden rounded-md border border-border bg-muted/30">
<div className="flex h-9 items-center border-b border-border px-3">
@ -131,7 +128,7 @@ function CodeBlock({
}
function FileLabel({ children }: { children: React.ReactNode }) {
return <span className="text-[12.5px] text-foreground">{children}</span>;
return <span className="text-xs text-foreground">{children}</span>;
}
function LineTabs<T extends string>({
@ -155,7 +152,7 @@ function LineTabs<T extends string>({
aria-selected={value === option}
onClick={() => onChange(option)}
className={cn(
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-[12.5px]",
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-xs",
value === option
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
@ -351,18 +348,14 @@ function TracingKey({
function EndpointValue({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
if (await copyToClipboard(value)) {
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
}
};
useTimeout(() => setCopied(false), copied ? COPIED_RESET_MS : null);
const copy = async () => setCopied(await copyToClipboard(value));
return (
<button
type="button"
onClick={() => void copy()}
aria-label={`Copy ${value}`}
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-[12.5px] text-foreground"
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-xs text-foreground"
>
<span className="break-all">{value}</span>
{copied ? (
@ -387,9 +380,9 @@ function Endpoints({ proxyUrl, children }: { proxyUrl: string; children?: React.
<dl className="mt-3 grid gap-x-6 gap-y-3 rounded-md border bg-muted/30 p-4 md:grid-cols-2">
{otlpEndpoints(proxyUrl).map(([label, value, copyable]) => (
<div key={label} className={cn("min-w-0", label === "Traces endpoint" && "md:col-span-2")}>
<dt className="text-[12px] text-muted-foreground">{label}</dt>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5">
{copyable ? <EndpointValue value={value} /> : <span className="text-[12.5px]">{value}</span>}
{copyable ? <EndpointValue value={value} /> : <span className="text-xs">{value}</span>}
</dd>
</div>
))}
@ -456,11 +449,9 @@ function CodingAgentSetup({ proxyUrl, guide, model }: { proxyUrl: string; guide:
const [codingAgent, setCodingAgent] = useState<CodingAgent>("Claude Code");
const [copied, setCopied] = useState<string | null>(null);
const command = codingAgentCommand(codingAgent, codingAgentPrompt(proxyUrl, guide, model));
useTimeout(() => setCopied(null), copied === null ? null : COPIED_RESET_MS);
const copy = async () => {
if (await copyToClipboard(command)) {
setCopied(command);
window.setTimeout(() => setCopied(null), COPIED_RESET_MS);
}
if (await copyToClipboard(command)) setCopied(command);
};
return (
<section className="mt-6" aria-labelledby="connect-project">

View file

@ -6,7 +6,6 @@ import {
FIELD_ROWS,
UNNAMED_AGENT_COLOR,
agoLabel,
isReceiving,
agentDotColor,
columnDots,
columnTop,
@ -102,13 +101,3 @@ describe("agoLabel", () => {
expect(agoLabel(now + 10_000, now)).toBe("just now");
});
});
describe("isReceiving", () => {
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
it("is live only when a trace landed in the last five minutes", () => {
expect(isReceiving(now - 4 * 60_000, now)).toBe(true);
expect(isReceiving(now - 6 * 60_000, now)).toBe(false);
expect(isReceiving(null, now)).toBe(false);
});
});

View file

@ -59,9 +59,3 @@ export function agoLabel(thenMs: number, nowMs: number): string {
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export const RECEIVING_WINDOW_MS = 5 * 60 * 1000;
export function isReceiving(lastMs: number | null, nowMs: number): boolean {
return lastMs !== null && nowMs - lastMs < RECEIVING_WINDOW_MS;
}

View file

@ -9,7 +9,6 @@ import {
} from "nuqs";
import { useCallback, useState } from "react";
import { ALL_AGENTS, type RunStatusFilter } from "./RunsToolbar";
import { RANGE_PRESETS } from "./TimeRangeControls";
import type { TimeWindow } from "./TracesTimeline";
import type { TraceSummary } from "./traceTypes";
@ -20,7 +19,6 @@ export type TraceView = (typeof TRACE_VIEWS)[number];
export const SPAN_TABS = ["content", "request", "attributes"] as const;
export type SpanTab = (typeof SPAN_TABS)[number];
const STATUS_FILTERS = ["all", "ok", "error"] as const satisfies readonly RunStatusFilter[];
const RANGE_HOURS = RANGE_PRESETS.map((preset) => preset.hours);
type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
@ -61,8 +59,6 @@ export const OPEN_TRACE_PARSERS = {
export const RUN_FILTER_PARSERS = {
q: parseAsString.withDefault(""),
agent: parseAsString.withDefault(ALL_AGENTS),
status: parseAsStringLiteral(STATUS_FILTERS).withDefault("all"),
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
from: parseAsInteger,
to: parseAsInteger,
@ -143,25 +139,9 @@ export function useLocalRunSelection(initialSpanId: string | null): RunSelection
return { spanId, view, spanTab, stepQuery, errorsOnly, selectSpan, setView, setSpanTab, setStepQuery, setErrorsOnly };
}
export interface RunFilterRouting {
query: string;
agent: string;
status: RunStatusFilter;
setQuery: (query: string) => void;
setAgent: (agent: string) => void;
setStatus: (status: RunStatusFilter) => void;
}
export function useRunFilterRouting(): RunFilterRouting {
const [{ q, agent, status }, setParams] = useQueryStates(RUN_FILTER_PARSERS);
return {
query: q,
agent,
status,
setQuery: useCallback((q: string) => void setParams({ q }), [setParams]),
setAgent: useCallback((agent: string) => void setParams({ agent }), [setParams]),
setStatus: useCallback((status: RunStatusFilter) => void setParams({ status }), [setParams]),
};
export function useRunFilterRouting(): { query: string; setQuery: (query: string) => void } {
const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q);
return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) };
}
export function useRangeHoursRouting(): [number, (hours: number) => void] {

View file

@ -61,6 +61,16 @@ describe("createApiClient", () => {
expect(init.body).toBe(JSON.stringify({ model_name: "gpt" }));
});
it("reports a non-JSON success body as an ApiError naming the path instead of a parse error", async () => {
const html = new Response("<!DOCTYPE html><html></html>", { headers: { "Content-Type": "text/html" } });
const client = createApiClient({ getBaseUrl: () => "", fetchImpl: vi.fn(async () => html) });
const error = await client.get("/lens").catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toBe("Expected JSON from /lens but the server returned text/html");
});
it("throws ApiError with the derived message and invokes onError on a non-2xx response", async () => {
const fetchImpl = vi.fn(async () => errorResponse(403, { error: { message: "no access" } }));
const onError = vi.fn();

View file

@ -184,7 +184,13 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
const response = await fetchChecked(method, path, options);
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
const type = response.headers.get("content-type") ?? "an unknown content type";
throw new ApiError(`Expected JSON from ${path} but the server returned ${type}`, response.status, text);
}
}
async function getBlob(path: string, options: RequestOptions = {}): Promise<Blob> {

View file

@ -0,0 +1,36 @@
import { RuleTester } from "eslint";
import rule from "../../scripts/eslint-rules/no-arbitrary-design-value.mjs";
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const arbitrary = (code: string, token: string) => ({ code, errors: [{ messageId: "arbitrary", data: { token } }] });
ruleTester.run("no-arbitrary-design-value", rule as never, {
valid: [
'const c = "text-xs leading-tight tracking-wider rounded-sm border";',
'const c = "grid-cols-[minmax(0,1fr)_auto] w-[180px] left-[calc(50%-0.5px)]";',
'const c = "data-[side=right]:sm:max-w-[90vw] [&>p]:mb-0";',
'const c = "ease-[cubic-bezier(0.4,0,0.2,1)]";',
'const el = <div className="text-sm text-muted-foreground" />;',
],
invalid: [
arbitrary('const c = "flex text-[13px]";', "text-[13px]"),
arbitrary('const c = "text-[#e5484d]";', "text-[#e5484d]"),
arbitrary('const c = "tracking-[-0.26px]";', "tracking-[-0.26px]"),
arbitrary('const c = "leading-[1.2]";', "leading-[1.2]"),
arbitrary('const c = "rounded-[4px]";', "rounded-[4px]"),
arbitrary('const c = "rounded-t-[4px]";', "rounded-t-[4px]"),
arbitrary('const c = "border-[0.67px]";', "border-[0.67px]"),
arbitrary('const c = "[border-left-width:1px]";', "[border-left-width:1px]"),
arbitrary('const c = "dark:text-[#8b9bff]";', "dark:text-[#8b9bff]"),
arbitrary('const c = "data-[side=top]:text-[11px]";', "data-[side=top]:text-[11px]"),
arbitrary("const c = `px-2 ${x} text-[10px]`;", "text-[10px]"),
arbitrary('const el = <div className="text-[12px]" />;', "text-[12px]"),
],
});

View file

@ -227,3 +227,33 @@ if (typeof window !== "undefined") {
disconnect() {}
};
}
// jsdom lacks the layout and input APIs ProseMirror editors use. Browsers report the selection as an edit's
// target range; layout calls only need a value of the right shape, since tests do not assert on positions.
if (typeof InputEvent !== "undefined" && !("getTargetRanges" in InputEvent.prototype)) {
Object.defineProperty(InputEvent.prototype, "getTargetRanges", {
configurable: true,
value: () => {
const selection = document.getSelection();
return selection && selection.rangeCount > 0 ? [selection.getRangeAt(0)] : [];
},
});
}
if (typeof Range !== "undefined" && !("getClientRects" in Range.prototype)) {
Object.defineProperties(Range.prototype, {
getClientRects: { configurable: true, value: () => [] },
getBoundingClientRect: { configurable: true, value: () => new DOMRect() },
});
}
// The virtualized runs list renders no rows in a zero-height viewport, and jsdom reports 0 for every offsetHeight.
if (typeof HTMLElement !== "undefined") {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get(this: HTMLElement) {
return this.dataset.testid === "runs-table" ? 720 : 0;
},
});
}
if (typeof document !== "undefined" && !("elementFromPoint" in document)) {
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => null });
}