diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4a82d2f2..2c37b3b2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -50,7 +50,6 @@ body: - CLI - Control Plane UI - Runs / reports / evidence - - Playground - Providers - Harness SDK / existing drivers - Documentation / build / tooling diff --git a/.github/skills/frontend-architecture/references/design-rules.md b/.github/skills/frontend-architecture/references/design-rules.md index 3a6f4576..6b1fee50 100644 --- a/.github/skills/frontend-architecture/references/design-rules.md +++ b/.github/skills/frontend-architecture/references/design-rules.md @@ -12,7 +12,7 @@ Before choosing a visual direction, identify: - Existing visual and interaction conventions that must remain coherent. - Real content, data density, and exceptional states. -For operational tools such as the FSQ Playground, prefer a quiet, work-focused interface optimized for scanning, comparison, repeated action, and clear status. Do not turn an application workspace into a marketing page. +For operational tools such as the FSQ Control Plane, prefer a quiet, work-focused interface optimized for scanning, comparison, repeated action, and clear status. Do not turn an application workspace into a marketing page. ## Design Before Implementation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a3490d5..54eb16e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,6 @@ jobs: generated = ( Path("fsq_agent/adapters/control_plane/static"), - Path("fsq_agent/adapters/control_plane/playground/static"), Path("fsq_agent/resources"), ) present = [str(path) for path in generated if path.exists()] @@ -110,7 +109,6 @@ jobs: generated = ( Path("fsq_agent/adapters/control_plane/static"), - Path("fsq_agent/adapters/control_plane/playground/static"), Path("fsq_agent/resources"), ) present = [str(path) for path in generated if path.exists()] @@ -229,13 +227,10 @@ jobs: required_prefixes = ( "fsq_agent/adapters/control_plane/static/", - "fsq_agent/adapters/control_plane/playground/static/", ) required_files = ( "fsq_agent/adapters/control_plane/static/control-plane/index.html", "fsq_agent/adapters/control_plane/static/entry-assets.json", - "fsq_agent/adapters/control_plane/playground/static/playground/index.html", - "fsq_agent/adapters/control_plane/playground/static/entry-assets.json", "fsq_agent/resources/config.android.yaml", "fsq_agent/resources/config.web.yaml", "fsq_agent/resources/config.windows.yaml", diff --git a/CHANGELOG.md b/CHANGELOG.md index c9adb6fe..f96c8908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes are documented here. FSQ follows Semantic Versioning after p - Initial open-source release preparation. +### Removed + +- Removed the legacy Playground browser application, standalone HTTP server, Python APIs, and packaged frontend assets. Use `fsq ui` for the supported Control Plane browser workflows; raw YAML dynamic execution, lifecycle-hook editing, completed-Run loading, and automatic Goal Case publication are not carried forward. + ## [0.1.0] - Unreleased ### Added diff --git a/ROADMAP.md b/ROADMAP.md index f6af3b59..19d32933 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ This roadmap communicates direction rather than a release commitment. Scope and Make the repository trustworthy and practical for external contributors. -- Establish CI for linting, tests, package builds, and Playground builds. +- Establish CI for linting, tests, package builds, and Control Plane builds. - Complete contributor-facing Issue and pull request workflows. - Improve installation, quick-start, architecture, and troubleshooting documentation. - Define the changelog, semantic versioning, and automated release process. @@ -48,7 +48,7 @@ Target a five-minute path from installation to a first successful automation run - Publish preconfigured Web and Android container images. - Add Codespaces or equivalent cloud development setup. - Add environment diagnostics and harness scaffolding commands. -- Improve interactive recording and Playground workflows. +- Improve interactive recording and Control Plane workflows. ## 2027 Q1-Q2: Ecosystem @@ -57,7 +57,7 @@ Make FSQ useful as both a tool and a platform. - Publish an official GitHub Action and CI integration guidance. - Build a curated registry of reusable FSQ cases and skills. - Add a compatibility matrix generated from tests and run evidence. -- Expand Playground into a case authoring and contribution workflow. +- Expand Control Plane into a case authoring and contribution workflow. - Provide an embeddable Python SDK and progress toward a stable 1.0 release. - Explore a VS Code extension for YAML authoring, evidence inspection, and run management. diff --git a/SPEC.md b/SPEC.md index 14e837ad..7e22e906 100644 --- a/SPEC.md +++ b/SPEC.md @@ -30,7 +30,7 @@ The public Case workflows are `fsq case create` and `fsq case test`. Case creati ## Recorded Case Artifacts -AI-participating Case operations may record a replayable trace as a Run-local candidate `*.fsq.yaml`. The agent persists normalized capability events and Execution coordinates conversion of replayable non-observation results according to `ReplayPolicy` metadata. Generated Goal Cases use the originating Run id as `name` and the normalized Goal as `description`, identically for valid and draft recordings across entry workflows. Generated Cases never mutate source Cases, and secrets are never persisted. Publication into `cases.dir` is selected by the entry workflow: CLI Goal-based Case creation and Playground Goal execution automatically publish validated recordings under the Run id, while Control Plane Explore keeps the recording Run-local until the user explicitly saves it under a chosen Case name. Suggestion-enabled existing-Case testing never publishes into `cases.dir`. +AI-participating Case operations may record a replayable trace as a Run-local candidate `*.fsq.yaml`. The agent persists normalized capability events and Execution coordinates conversion of replayable non-observation results according to `ReplayPolicy` metadata. Generated Goal Cases use the originating Run id as `name` and the normalized Goal as `description`, identically for valid and draft recordings across entry workflows. Generated Cases never mutate source Cases, and secrets are never persisted. CLI Goal-based Case creation automatically publishes validated recordings under the Run id, while Control Plane Explore keeps the recording Run-local until the user explicitly saves it under a chosen Case name. Suggestion-enabled existing-Case testing never publishes into `cases.dir`. Recorded strict cases may contain runtime-secret text input references using `textType: runtimeSecret` and `waitMs` replay aliases. Strict execution bootstraps the active platform capability registry before YAML parsing, treats missing `textType` on text-entry commands as literal text for case compatibility, resolves runtime-secret text values in memory before external text-entry actions begin, and resolves `waitMs` through the registry to the inherited `wait_ms` CommonTool capability. @@ -70,7 +70,7 @@ Every new Run has a versioned `run.json` inside its direct platform Run director Shared platform rules: -- `harness.platform` selects exactly one active platform for normal dynamic, strict, and playground execution. +- `harness.platform` selects exactly one active platform for normal dynamic and strict execution. - Public CLI entry points select the active platform with `--platform android|web|windows|macos` where platform context is needed; config loading maps that platform id to the corresponding repository-owned `config..yaml` preset before validation. Public CLI commands do not expose workspace selection and use the exact registered current directory with canonical `.fsq` platform configuration. Provider configuration is separate from `fsq init`. - Entry layers build a platform-selected capability registry: inherited CommonTool capabilities plus only the active platform's PlatformTool capabilities. - `StepRunner`, `StepSequenceRunner`, evidence, recording, report generation, and FSQ parsing stay platform-neutral and consume capability metadata rather than platform action-name branches. @@ -90,7 +90,7 @@ Web platform block: - Platform id: `web`. - Backend: `playwright`. - Runtime settings include browser channel, environment-backed browser executable path, headless mode, optional base URL, and optional viewport fields when specified by module specs. -- Browser lifecycle is explicit through `start_browser`/`startBrowser` and `close_browser`/`closeBrowser`. Runtime, CLI, FSQ parsing, StepRunner, StepSequenceRunner, and playground entry paths must not auto-inject lifecycle commands or launch a browser as a driver-construction side effect. +- Browser lifecycle is explicit through `start_browser`/`startBrowser` and `close_browser`/`closeBrowser`. Runtime, CLI, Control Plane, FSQ parsing, StepRunner, and StepSequenceRunner must not auto-inject lifecycle commands or launch a browser as a driver-construction side effect. - `startBrowser` is idempotent and reuses the active browser/page when one is already started. `closeBrowser` is idempotent, closes the active browser/page when present, resets driver-owned state, and permits a later `startBrowser` in the same task. - Web page-dependent actions, including `navigateTo`, require an active browser/page and must fail clearly when invoked before `startBrowser`; `navigateTo` must not implicitly start the browser. - Explicit observation capability: `ui_snapshot` with alias `uiSnapshot`; Web must not expose Android `ui_tree`/`uiTree` naming. Automatic runner evidence captures `screenshot` plus normalized `ui_snapshot` using Web page/accessibility snapshot content. @@ -148,21 +148,20 @@ Loader diagnostics such as missing optional skills or missing optional knowledge | agent | fsq_agent/agent/SPEC.md | Orchestrates dynamic goal/reference execution through OpenAI Agents SDK, AgentTool exposure, active-platform capability exposure, verification, replayable event metadata, and report generation. | | execution | fsq_agent/execution/SPEC.md | Coordinates transport-neutral dynamic and deterministic runs, Case lifecycle ordering, cancellation/teardown, and Run-local candidate Case recording. | | application | fsq_agent/application/SPEC.md | Provides transport-neutral Workspace, Case, Run, Provider, and Environment operations through resource-owned modules, with shared Request, Result, Event, and Error contracts organized under `application/contracts`. | -| adapters | fsq_agent/adapters/SPEC.md | Owns CLI, Control Plane, Playground, and coding-agent external protocol adaptation while depending inward on Application and public runtime contracts. | +| adapters | fsq_agent/adapters/SPEC.md | Owns CLI, Control Plane, and coding-agent external protocol adaptation while depending inward on Application and public runtime contracts. | | adapters.coding_agent | fsq_agent/adapters/coding_agent/SPEC.md | Implements Agent runtime protocols through OpenAI Agents SDK tool, session, stream, and result adaptation. | -| playground | fsq_agent/playground/SPEC.md | Serves the local browser playground for active-platform runtime status, Android session setup where applicable, dynamic goal/raw-case execution, strict YAML execution, loading existing run results, screenshots, replay video preview, and report lookup. | | control_plane | fsq_agent/control_plane/SPEC.md | Adapts Application operations to local HTTP/SSE/static delivery, cancellation transport, and browser evidence projection. | | cli | fsq_agent/cli/SPEC.md | Adapts the public `fsq` command tree to Application operations with human, JSON, and JSONL output and stable exit categories. | | frontend | frontend/SPEC.md | Owns the repository npm/Vite workspace, browser dependency and build policy, generated-asset boundary, and navigation to independently owned frontend application modules. | ## Frontend Build Boundary -- The repository root npm project owns browser-source dependency resolution and Vite compilation for repository web pages. It uses one lock file and a multi-page Vite configuration so independently owned page entries build to distinct output paths. -- `frontend/SPEC.md` owns the frontend workspace contract and links to child application specs without repeating their behavior. `frontend/playground/SPEC.md` and `frontend/control-plane/SPEC.md` own their authored browser applications; the corresponding Python modules own HTTP contracts and production static serving. -- New frontend application modules use Vite, React, and TypeScript/TSX unless their confirmed module SPEC records a concrete exception. The current `frontend/playground` module is a documented Vite-built vanilla JavaScript application and remains governed by its current module SPEC. +- The repository root npm project owns browser-source dependency resolution and Vite compilation for repository web pages. It uses one lock file and a multi-page Vite configuration for the Control Plane entry. +- `frontend/SPEC.md` owns the frontend workspace contract and links to `frontend/control-plane/SPEC.md` without repeating application behavior; the corresponding Python module owns HTTP contracts and production static serving. +- New frontend application modules use Vite, React, and TypeScript/TSX unless their confirmed module SPEC records a concrete exception. - `ts-ebml` is an exact npm dependency consumed through an ES module import. Third-party browser bundles and Vite-generated assets are not tracked in Git. -- Vite-generated Playground and Control Plane assets live with their canonical owners under `fsq_agent/adapters/control_plane/playground/static` and `fsq_agent/adapters/control_plane/static`. Their HTML entry points, JavaScript, CSS, entry-asset manifests, and referenced generated assets are included in both wheel and source distribution. Release builds run the npm build before Python distribution construction. An installed distribution is self-contained and does not require Node.js or network access to serve these frontends at runtime. -- Frontend development may use the Vite development server with API and streaming requests proxied to the corresponding Python server. Production and installed-wheel usage serve each generated entry and its APIs from its owning Python process. +- Vite-generated Control Plane assets live under `fsq_agent/adapters/control_plane/static`. Its HTML entry point, JavaScript, CSS, entry-asset manifest, and referenced generated assets are included in both wheel and source distribution. Release builds run the npm build before Python distribution construction. An installed distribution is self-contained and does not require Node.js or network access to serve the frontend at runtime. +- Frontend development may use the Vite development server with API and streaming requests proxied to the Control Plane Python server. Production and installed-wheel usage serve the generated entry and its APIs from one Python process. ## Architecture Diagram @@ -173,12 +172,9 @@ flowchart TD CLI --> Application[application] CLI --> ControlPlane ControlPlane --> Application - ControlPlane --> Playground[adapters/control_plane/playground] ControlPlane --> Execution[execution] CLI --> CodingAgent[adapters/coding_agent] ControlPlane --> CodingAgent - Playground --> Execution - Playground --> CodingAgent Application --> Agent[agent] Application --> Execution Application --> Core[core] @@ -229,11 +225,7 @@ flowchart TD Capabilities[capabilities] --> Models Core --> Capabilities Core -->|PlatformRuntimeService compatibility export| Environments - Frontend[frontend] --> FrontendPlayground[frontend/playground] Frontend --> FrontendControlPlane[frontend/control-plane] - FrontendPlayground --> Playground - FrontendPlayground --> StaticAssets[generated static assets] - Playground --> StaticAssets FrontendControlPlane --> ControlPlane FrontendControlPlane --> ControlPlaneStatic[generated Control Plane static assets] ControlPlane --> ControlPlaneStatic @@ -255,10 +247,10 @@ flowchart TD - Internal Python implementation files are prefixed with `_`. - Shared data structures and exceptions live only in the `models` module. Capability declaration decorators, catalog-backed platform validation, and decorated-method discovery live only in the `capabilities` module. - Module imports must follow the DAG in the architecture diagram. -- Transport implementation and package data live under `adapters`. The installed scripts target `fsq_agent.adapters.cli:main`. Legacy `fsq_agent.cli`, `fsq_agent.control_plane`, and `fsq_agent.playground` packages preserve only their documented public entry symbols as compatibility exports; old private transport submodule paths are unsupported and absent. +- Transport implementation and package data live under `adapters`. The installed scripts target `fsq_agent.adapters.cli:main`. Legacy `fsq_agent.cli` and `fsq_agent.control_plane` packages preserve only their documented public entry symbols as compatibility exports; old private transport submodule paths are unsupported and absent. - Package-root execution helpers and old Agent SDK implementation paths are absent. Repository code imports canonical `execution`, `adapters.coding_agent`, `case_dsl`, Drivers, Harnesses, Environments, and public Core subpackages directly. -- Package-private composition helpers at the `fsq_agent` package root may compose public module APIs for shared entry-layer capability bootstrap, registry-metadata-based provider requirement detection, strict lifecycle orchestration, and dynamic-run recording used by CLI, Playground, and Control Plane. Provider requirement detection compares the active platform registry with and without provider-backed capabilities and resolves executable steps through the registry snapshot rather than branching on action names. These helpers must remain private, must not expose public module contracts, and must not be imported by `models`, `capabilities`, `tools`, `fsq`, `core`, `providers`, or `report`. -- `capabilities` may import `models` only among project modules. It must not import `tools`, `core`, `agent`, `cli`, `fsq`, `providers`, `report`, `playground`, SDK objects, concrete drivers, or backend runtime types. +- Package-private composition helpers at the `fsq_agent` package root may compose public module APIs for shared entry-layer capability bootstrap, registry-metadata-based provider requirement detection, strict lifecycle orchestration, and dynamic-run recording used by CLI and Control Plane. Provider requirement detection compares the active platform registry with and without provider-backed capabilities and resolves executable steps through the registry snapshot rather than branching on action names. These helpers must remain private, must not expose public module contracts, and must not be imported by `models`, `capabilities`, `tools`, `fsq`, `core`, `providers`, or `report`. +- `capabilities` may import `models` only among project modules. It must not import `tools`, `core`, `agent`, `cli`, `fsq`, `providers`, `report`, SDK objects, concrete drivers, or backend runtime types. - Provider construction lives in `providers`; `core` must use provider-neutral protocols and must not import provider/runtime modules. - Dynamic-only local helper utilities live as AgentTools in `tools`; recordable CommonTool and PlatformTool capabilities live in `core`, with CommonTool bodies in platform tool providers and backend PlatformTool bodies on concrete drivers. CommonTools and PlatformTools declare executable metadata through `capabilities`. All recordable capabilities must be registered before strict YAML parsing or SDK capability exposure, and platform registries must contain only inherited CommonTools plus the active platform's PlatformTools. AgentTools must not be registered for strict replay. - Replay, sensitivity, evidence, and tool-origin behavior must come from capability metadata and normalized `StepRunner` results, not hard-coded tool-name sets. @@ -268,6 +260,6 @@ flowchart TD - Use the lowest architecture level that keeps the module clear, testable, and changeable. - `models`, `capabilities`, `tools`, `case_dsl`, `report`, `knowledge`, `skills`, `config`, `providers`, and `observation` default to Level 2 Simple Package unless a module SPEC records a stronger need. -- `core`, `agent`, `execution`, `application`, `adapters`, `cli`, `playground`, and `control_plane` use Level 3 because they coordinate execution flows, external SDKs, harnesses, providers, persistence, shared application operations, or transport entry points. +- `core`, `agent`, `execution`, `application`, `adapters`, `cli`, and `control_plane` use Level 3 because they coordinate execution flows, external SDKs, harnesses, providers, persistence, shared application operations, or transport entry points. - Public APIs must be exported from module `__init__.py` files, and internal implementation modules must remain private across module boundaries. Modules that have adopted the stricter public API boundary must not export concrete implementation-selection classes, helper functions, decorators, or discovery utilities unless their module SPEC records an explicit exception. Public factories should own construction/selection of private implementations when a caller only needs a protocol or service contract. - Do not introduce Repository, Unit of Work, Clean Architecture, or DDD patterns unless a confirmed SPEC records the concrete reason. diff --git a/docs/assets/fsq-agent-exploration-flow.mmd b/docs/assets/fsq-agent-exploration-flow.mmd index f3e20ed5..33454e73 100644 --- a/docs/assets/fsq-agent-exploration-flow.mmd +++ b/docs/assets/fsq-agent-exploration-flow.mmd @@ -1,6 +1,6 @@ sequenceDiagram participant User as User / Team - participant Entry as CLI / API / Playground + participant Entry as CLI / API / Control Plane participant Planner as Model-Agnostic Planner participant Model as Model Provider participant K as Knowledge System diff --git a/docs/assets/fsq-agent-module-map.mmd b/docs/assets/fsq-agent-module-map.mmd index b637b981..3641b543 100644 --- a/docs/assets/fsq-agent-module-map.mmd +++ b/docs/assets/fsq-agent-module-map.mmd @@ -1,7 +1,7 @@ flowchart LR subgraph Entry[Entry Modules] CLI[CLI / API] - PLAY[Playground / Debug UI] + UI[Control Plane] end subgraph Testcase[Testcase Modules] @@ -35,7 +35,7 @@ flowchart LR CLI --> FSQYAML CLI --> GOAL - PLAY --> DEBUG + UI --> DEBUG FSQYAML --> NORMALIZER --> RUNNER --> ACTIONIR GOAL --> PLANNER MODEL <--> PLANNER diff --git a/docs/fsq-agent-architecture-deep-dive.md b/docs/fsq-agent-architecture-deep-dive.md index 965385a7..be8789fb 100644 --- a/docs/fsq-agent-architecture-deep-dive.md +++ b/docs/fsq-agent-architecture-deep-dive.md @@ -91,7 +91,7 @@ Midscene uses a similar layered shape: entry integrations such as CLI, Playwrigh flowchart LR subgraph Entry[Entry Modules] CLI[CLI / API] - PLAY[Playground / Debug UI] + UI[Control Plane] end subgraph Testcase[Testcase Modules] @@ -125,7 +125,7 @@ flowchart LR CLI --> FSQYAML CLI --> GOAL - PLAY --> DEBUG + UI --> DEBUG FSQYAML --> NORMALIZER --> RUNNER --> ACTIONIR GOAL --> PLANNER MODEL <--> PLANNER @@ -188,7 +188,7 @@ The regression flow must not depend on a planner model. This is the main differe ```mermaid sequenceDiagram participant User as User / Team - participant Entry as CLI / API / Playground + participant Entry as CLI / API / Control Plane participant Planner as Model-Agnostic Planner participant Model as Model Provider participant K as Knowledge System diff --git a/docs/release-acceptance-checklist.md b/docs/release-acceptance-checklist.md index a5c1c66d..ca5e1342 100644 --- a/docs/release-acceptance-checklist.md +++ b/docs/release-acceptance-checklist.md @@ -43,7 +43,7 @@ The release workflow runs the equivalent automated gate before publication, incl The CI package job is the executable contract for archive inspection. Confirm that it checks: - exactly one wheel and one sdist; -- Control Plane and Playground HTML, JavaScript, CSS, and `entry-assets.json` in both archives; +- Control Plane HTML, JavaScript, CSS, and `entry-assets.json` in both archives; - repository platform presets, default prompt templates, and configured skills in both archives; - default dependencies for uiautomator2, Playwright, pywinauto, Pillow, and Appium Python Client; - no platform extras; diff --git a/docs/step-runner-architecture.md b/docs/step-runner-architecture.md index 79a426c0..a80adb07 100644 --- a/docs/step-runner-architecture.md +++ b/docs/step-runner-architecture.md @@ -423,7 +423,7 @@ Planner output / FSQ step terminal progress debug timeline json report - live playground + Control Plane live timeline CI reporter 这比让 Runner 直接 import 所有模块干净得多。 diff --git a/frontend/SPEC.md b/frontend/SPEC.md index 15866349..1f6a55b7 100644 --- a/frontend/SPEC.md +++ b/frontend/SPEC.md @@ -11,15 +11,14 @@ This parent module does not own child application behavior or Python HTTP/runtim - The root `package.json` and `package-lock.json` own exact npm dependencies and the single lock-file dependency graph. - Supported Node.js versions are `^20.19.0 || >=22.12.0`. - Vite is the frontend development and production build tool. -- `ts-ebml` is the current browser runtime dependency used by the Playground and Control Plane child modules for seekable WebM replay generation. +- `ts-ebml` is the current browser runtime dependency used by the Control Plane child module for seekable WebM replay generation. - `react-markdown` is the current browser runtime dependency used by the Control Plane child module for safe Markdown preview without raw HTML support. -- The workspace consumes Python Playground and Control Plane HTTP origins only through Vite development proxy configuration and browser HTTP requests. Frontend source does not import Python modules. +- The workspace consumes the Python Control Plane HTTP origin only through Vite development proxy configuration and browser HTTP requests. Frontend source does not import Python modules. ## Public Interface -- `npm run dev` starts the Vite development server for frontend entries and proxies configured Playground API paths to `FSQ_PLAYGROUND_API_ORIGIN`, defaulting to `http://127.0.0.1:8878`. -- `npm run build` compiles all configured page entries through the untracked `.frontend-dist` staging directory and distributes each entry into its owning Python package static directory. -- `frontend/playground/SPEC.md` defines the Playground browser application's public behavior and source boundary. +- `npm run dev` starts the Vite development server for the Control Plane entry and proxies `/api/control-plane` to `FSQ_CONTROL_PLANE_API_ORIGIN`, defaulting to `http://127.0.0.1:8879`. +- `npm run build` compiles the configured Control Plane page entry through the untracked `.frontend-dist` staging directory and distributes it into its owning Python package static directory. - `frontend/control-plane/SPEC.md` defines the Control Plane browser application's public behavior and source boundary. ## Internal Structure @@ -27,10 +26,8 @@ This parent module does not own child application behavior or Python HTTP/runtim - `../package.json`: Root npm metadata, scripts, Node.js compatibility, and exact direct dependency versions. - `../package-lock.json`: Complete locked npm dependency graph. - `../vite.config.js`: Multi-page entry configuration, development API proxying, and production output mapping. -- `playground/`: Independently specified Playground browser application. - `control-plane/`: Independently specified Control Plane browser application. - `../.frontend-dist/`: Untracked temporary Vite build staging. -- `../fsq_agent/adapters/control_plane/playground/static/`: Untracked generated Playground build output consumed by canonical Python packaging and production static serving; it is not authored frontend source. - `../fsq_agent/adapters/control_plane/static/`: Untracked generated Control Plane build output consumed by canonical Python packaging and production static serving; it is not authored frontend source. ## Frontend Architecture diff --git a/frontend/control-plane/SPEC.md b/frontend/control-plane/SPEC.md index 361dee21..d9c3f9a8 100644 --- a/frontend/control-plane/SPEC.md +++ b/frontend/control-plane/SPEC.md @@ -17,7 +17,7 @@ The entry does not own backend validation, Provider/workspace persistence or aut - `react-markdown` renders workspace Markdown previews with raw HTML disabled and no raw-HTML plugin. - `/api/control-plane/*` is the only backend contract consumed by this entry. -The entry must not import Playground source, Python-generated static assets, backend implementation files, or another frontend entry's private code. It does not introduce Redux, React Query, a client router, Tailwind, CSS-in-JS, or a component framework. +The entry must not import Python-generated static assets, backend implementation files, or another frontend entry's private code. It does not introduce Redux, React Query, a client router, Tailwind, CSS-in-JS, or a component framework. ## Public Behavior @@ -220,7 +220,7 @@ Current Devices ownership: - `src/features/devices/components/ScreenView.tsx`, `UiSnapshotView.tsx`, and `RunLogsView.tsx`: current/selected-step evidence presentation; Logs owns structured-row message disclosure, sticky-table semantics, and log scroll following. - `src/features/devices/components/ReplayVideoView.tsx`: terminal replay resolution, generation state, stored-video playback, and browser-resource cleanup. - `src/features/devices/components/StepEvidenceView.tsx`: selected-step screenshot comparison and UI-snapshot diff presentation. -- `src/features/devices/replay/`: frame timing, Canvas/MediaRecorder capture, seekable WebM conversion, and diff helpers that remain independent from Playground source. +- `src/features/devices/replay/`: frame timing, Canvas/MediaRecorder capture, seekable WebM conversion, and diff helpers. - `src/features/devices/hooks/useDeviceWorkspace.ts`: page state, one-time launch-intent validation/consumption, and discovery/run commands. - `src/features/devices/hooks/useRunStream.ts`: sequence, SSE reconnect, and snapshot fallback. - `src/styles/`: entry tokens and Devices-specific styles that do not override shell structure. @@ -264,7 +264,7 @@ The application owns the Config request used to derive Overview's read-only safe - Development requests under `/api/control-plane` proxy to the local Control Plane Python server and preserve unbuffered SSE. - Production output is generated under `fsq_agent/adapters/control_plane/static`, remains untracked, and is included in the Python wheel beside the canonical Control Plane adapter. - Installed-wheel use serves the generated entry and API from one Python process and requires no Node.js/network access. -- Static route fallback is limited to the Control Plane entry and never falls back to Playground. +- Static route fallback is limited to the Control Plane entry. - The root package manifest and lock file own all frontend dependencies. No nested package project or second lock file exists. ## Error Handling @@ -303,7 +303,7 @@ Empty states direct the user to create/select a workspace, add a configured plat - Config tests cover malformed-response rejection, loading/empty/configured/unavailable states, complete Azure save and key visibility, dirty-state discard behavior, provider replacement preservation, immediate GitHub device-flow start, authorization/model-discovery polling, no-default model selection, offered-model save eligibility, discovery error/empty retry, save success/failure, expiration/cancel cleanup, saved-only Test connection eligibility/results, dialog keyboard/focus behavior, and secret-safe presentation. - Devices tests cover shared workspace selection, ordinary entry with no implicit platform selection, available Workspace replay-intent platform/mode/case initialization, active-task precedence, missing/unavailable platform fallback, missing/non-selectable case fallback, valid configured-platform options, compound stale-request protection, derived start eligibility, workspace/platform Explore/Strict payloads, active workspace/platform/target locks, flat sequence-ordered timeline events, one-line overflow disclosure, active-run-only Jump visibility, exact bottom scrolling, immediate Jump dismissal/focus transfer, terminal Action selection, selected-step artifact states, screenshot comparison, UI Tree diff, replay generation/reuse/error cleanup, range-video playback, timeline/cancel/terminal/new-run behavior, stream resume/fallback, sticky Logs structure, per-row full event disclosure, tabs, accessible names, live announcements, and focus behavior. - Browser verification covers Overview default with separate global AI and current-Workspace regions, safe Workspace identity, three-step flow, no selected Workspace, only-unavailable platforms, Provider configured/unconfigured/error states, desktop and narrow keyboard order, and navigation into Config, Workspace, and Devices; expanded Workspace group with path-free entries; multi-platform create, compact banner, configuration tabs, add/edit, and file-browser workflows; record-new-case navigation with workspace preservation and no platform; eligible top-level/nested FSQ replay navigation with explicit workspace/platform/Strict/case initialization and no automatic start; ineligible-file metadata without replay; stale-case empty selection; Markdown preview; selected-Workspace desktop title alignment and compact Configure presentation, edge-to-edge geometry, fixed structural dimensions, independent desktop scrolling, and omitted controls at 1440×900 and 1024×768; selected-Workspace stacked title and content flow without overlap or page-level horizontal overflow at 390×844; Devices workspace/platform selection plus viewport containment and independent scrolling at 1440×900 and 1280×720; keyboard-only navigation/disclosures/tree/tabs, Action selection, Show run replay, and immediate Jump dismissal; secret cleanup/visibility; long-path/content wrapping; Before/After comparison with original image edges; UI Tree diff including XML structured rendering; generated-video playback/seeking; constrained screenshot/replay media; sticky Logs; all four platform forms/readiness presentations; and at least one available platform's Explore/Strict progress, evidence, cancellation, and terminal behavior. Layout changes require reviewed desktop and narrow screenshots plus a clean browser console. -- Build/package verification proves both Vite entries are generated, existing Playground remains functional, and an isolated wheel starts Control Plane without Node.js. +- Build/package verification proves the Control Plane Vite entry is generated and an isolated wheel starts Control Plane without Node.js. ## Current Invariants @@ -325,4 +325,3 @@ Empty states direct the user to create/select a workspace, add a configured plat - Compound workspace-platform request generations prevent stale responses from changing the selected context. - Terminal selected-Action evidence loading is non-destructive: changing Action selection does not clear the current selected-Action Screen/UI Tree presentation before the next Action's artifacts resolve, and stale artifact reads cannot replace the current selection. - No large evidence bytes are carried in SSE. -- Existing Playground source and behavior remain independent. diff --git a/frontend/playground/SPEC.md b/frontend/playground/SPEC.md deleted file mode 100644 index 9ff04e4f..00000000 --- a/frontend/playground/SPEC.md +++ /dev/null @@ -1,99 +0,0 @@ -# Module: frontend/playground - -## Purpose - -Own the authored browser application for the local FSQ-Agent Playground. The application presents runtime status for the server's fixed CLI-selected workspace/platform, platform session setup where applicable, dynamic and strict execution controls, YAML views and lifecycle editing, progress, screenshots, replay video, reports, existing-run loading, and completed-step artifacts. - -This module owns browser-local state, rendering, interaction, and browser-side replay generation. It consumes the HTTP contracts owned by `fsq_agent/playground/SPEC.md` and does not own Python execution, filesystem safety, route semantics, production static serving, or wheel packaging. - -## Dependencies - -- Parent workspace: Uses the npm, Vite, lock-file, and generated-asset contracts in `frontend/SPEC.md`. -- Python Playground API: Consumes the documented JSON, Server-Sent Events, screenshot, replay, report, YAML, and step-artifact endpoints without importing Python modules. -- `ts-ebml`: Imported as an ES module for browser-side seekable WebM generation. -- Browser platform APIs: Uses `fetch`, `EventSource`, DOM events, media elements, local storage, blobs, Canvas 2D with `captureStream`, `MediaRecorder`, `Image`, and `FileReader`. - -## Public Interface - -- Source entry: `index.html` loads `playground.js` as an ES module and `playground.css` as the authored stylesheet. -- Run modes: Goal, YAML, and Strict YAML preserve their corresponding server execution semantics. -- Runtime context: Displays server-provided workspace/platform metadata where supplied and does not offer workspace or platform switching. -- Workspace views: Source YAML, Generated YAML, Progress, Preview, and Report expose mode-appropriate state without changing execution semantics. -- Session controls: Android session setup is visible only when the active platform requires it. -- Existing-run loading: Goal mode can load one server-validated completed run from the fixed selected platform run root and reuse the normal Progress, Report, replay, generated-YAML, and step-artifact surfaces. -- Input lifecycle editing: Source YAML exposes case-level `onCaseStart` and `onCaseComplete` action drafts while metadata and case steps remain read-only. -- Artifact navigation: Completed Strict Input and Generated YAML step cards can select step artifacts in Preview; selecting the case title returns to replay preview. - -The browser consumes endpoint contracts from `fsq_agent/playground/SPEC.md`. Endpoint validation, response safety, execution ordering, path resolution, and persistence semantics remain owned by that Python module. - -## Data And State Flow - -- A centralized browser state tracks the active request, run mode, per-mode view snapshots, progress sequence, selected artifacts, lifecycle drafts, replay generation, finalization, and loaded-run state. -- Each run mode preserves its own YAML content and active view, Progress history, active Preview/Report tab, report content, replay state, and step-artifact preview when the user switches modes. Transient YAML-region and Progress-item selection styling is cleared when a mode is restored. -- Live progress prefers Server-Sent Events and falls back to sequence-based polling; events append without rebuilding prior history. -- Execution completion resolves the run id, report, generated YAML, replay frames/video, and step-artifact availability independently so one unavailable surface does not invalidate the others. -- Lifecycle edits remain browser-local drafts until Save sends structured hooks with the source revision. Discard restores the last server snapshot. -- Browser replay generation converts persisted screenshot frames to WebM, uploads the result through the server contract, and then uses the stored range-capable video for playback and seeking. - -## Internal Structure - -- `index.html`: Semantic application shell, controls, tabs, panels, media surfaces, accessible names, and the browser entry declaration. -- `playground.js`: Current application controller, API client, centralized state, rendering, execution/progress coordination, YAML presentation, artifact views, panel resizing, replay generation, and event wiring. -- `lifecycle-editor-model.js`: Pure clone, add, update, delete, reorder, and validation operations for lifecycle action drafts. -- `playground.css`: Workspace layout, responsive behavior, visual states, focus treatment, artifact comparison, YAML presentation, and editor styling. - -## Frontend Architecture - -- Architecture level: Legacy stateful single-page application, documented as an exception to the normalized React application levels. -- Runtime boundary: Browser-only authored source compiled by Vite. -- State boundary: `playground.js` owns centralized mutable application and per-mode state; `lifecycle-editor-model.js` owns pure lifecycle draft operations. -- Integration boundary: A shared browser API helper and `EventSource` calls consume only the Python Playground transport surface. -- Dependency direction: `playground.js` may import `lifecycle-editor-model.js` and `ts-ebml`; authored frontend source does not import generated assets or Python modules. -- Current framework: Vanilla JavaScript, HTML, and CSS. React and TypeScript/TSX are not current dependencies or source formats for this module. - -## Error Handling - -- Failed JSON requests surface the server's concise `error` value when present and otherwise show the HTTP status. -- Status, YAML, load-run, report, replay, and artifact failures stay local to their relevant surface when the rest of the completed run remains usable. -- Starting execution or changing/reloading the YAML path while lifecycle drafts are dirty requires Save or Discard; Clear discards the draft. -- Client lifecycle validation appears when Save is attempted. Empty action values and unsupported action types are rejected before the request. -- Lifecycle revision conflicts require the source to be reloaded and do not silently overwrite disk changes. -- Controls that can conflict with execution, completion/replay finalization, or an active save are disabled and guarded in event handlers. -- A run with no persisted progress, generated YAML, replay, report, or artifacts presents a concise empty or unavailable state rather than synthetic content. - -## Verification Scope - -- Verification covers mode switching, per-mode state preservation, execution and cancellation controls, SSE-to-polling progress behavior, lifecycle draft editing, existing-run loading, report and YAML presentation, replay generation/playback, and step-artifact navigation. -- Browser-source verification covers labeled native controls, keyboard-operated main workspace panel resizing and form submission, main-control focus treatment, and non-overlapping narrow-viewport behavior. It does not represent pointer-only artifact navigation or artifact-region resizers as keyboard-operable. -- Integration verification covers the documented JSON and SSE endpoints, binary replay upload/playback, HTTP range seeking, safe generated-YAML rendering, and the distinction between dynamic raw YAML and strict execution. -- Build verification uses the parent workspace's locked install and Vite build and confirms no committed vendor bundle or global EBML dependency is required. - -## Current Invariants - -### Progress And Workspace State - -- The desktop UI uses a persisted resizable two-column workspace: mode-specific YAML/Progress views on the left and Preview/Report on the right. Starting execution clears the Preview surface but preserves the active Preview/Report tab until later artifact or replay navigation selects Preview; progress continues independently. -- Left-side tab availability and defaults follow run mode: Goal exposes Progress and Generated YAML, YAML exposes Progress, Source YAML, and Generated YAML, and Strict YAML exposes Source YAML and Progress. Each mode preserves its own view state, and generated recording content does not force tab selection. -- Strict execution uses server-provided current-step metadata for Source YAML highlighting without synthesizing tool-call events from core lifecycle events. Dynamic YAML does not mutate Input step state. Run-mode and artifact navigation changes are blocked while execution or replay finalization is active. -- Generated YAML and loaded-run YAML remain read-only structured presentations and do not render lifecycle editor sections. Ordinary command cards render under a `Case steps` heading. Live Source YAML renders case-level `onCaseStart` before Case steps and `onCaseComplete` after them. Only lifecycle action rows are editable; metadata and Case steps remain read-only. -- Input lifecycle drafts contain flat ordered `Before case` and `After case` action lists. `runCase` and `runShell` may repeat. Save and Discard are document-level controls; Save validates dirty drafts when invoked, and Discard restores the last server snapshot. -- Saving lifecycle drafts sends structured hook data plus the loaded source revision. Empty lifecycle sections remove their keys. Unedited files are never rewritten by the browser. -- Load Run expands an inline run-directory form in Goal mode. A successful load creates a completed-run view without an active request and independently loads persisted Progress, Report, replay, Generated YAML, and step-artifact availability through existing endpoints. -- Failed existing-run validation preserves the displayed result and reports the error in the inline form. Cancel preserves the result and collapses the form. Clear removes loaded-run state and restores the empty workspace. - -### Preview And Artifacts - -- Completed runs use persisted screenshots as replay-video input and show available replay video in Preview with native playback and seeking controls. Replay generation is automatic rather than a separate user command. -- Completed Strict Source YAML and Generated YAML step cards open their artifacts in Preview; dynamic Source YAML cards do not. -- Screenshots precede structured artifacts, absent artifact kinds do not create empty regions, and selecting the case title restores the completed replay-video view. -- Before/After screenshots default to a centered, complete side-by-side comparison on a neutral backdrop. Screenshot and UI snapshot regions resize independently and use scrolling for overflow. -- Before/after normalized `ui_snapshot` artifacts may render as a read-only full-content diff with line-level and paired inline change highlighting. Explicit Android `uiTree` and Web/desktop `uiSnapshot` artifacts remain supported. -- The named main workspace panel separator supports keyboard resizing. Artifact-region height resizers and artifact selection from Progress or structured YAML are current pointer-only legacy interactions. -- Lifecycle editing uses compact, unnested action rows with an action menu, single-line value, and icon controls with tooltips and accessible labels. Controls remain usable without overlap in the resizable desktop layout and narrow viewport. - -### Ownership And Distribution - -- Authored browser source and browser dependencies are owned by the frontend workspace; generated bundles are untracked package data. -- The Python Playground server owns all endpoint semantics and production static serving. Browser code treats server responses as transport contracts and does not reproduce server validation or filesystem policy. -- Workspace/platform selection belongs to CLI startup; browser code neither infers nor changes it. -- The module remains vanilla JavaScript until a confirmed module SPEC changes its framework and source-language contract. \ No newline at end of file diff --git a/frontend/playground/index.html b/frontend/playground/index.html deleted file mode 100644 index 9961fdee..00000000 --- a/frontend/playground/index.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - FSQ-Agent Playground - - - - -
-
-
-
-

FSQ-Agent Playground

-

Connecting...

-
- -
- -
-
-

Execution

- -
- -
- - - -
-
-

No YAML loaded.

- -
-
- - -
- - - -
-

Run

-
- - - -
-
- - - -
-
-
- - - -
-
-
-
- - -
-
-
-
- - - Android screenshot preview -

Screenshot preview will appear here.

-
- -
-
- - - \ No newline at end of file diff --git a/frontend/playground/lifecycle-editor-model.js b/frontend/playground/lifecycle-editor-model.js deleted file mode 100644 index 11ed0232..00000000 --- a/frontend/playground/lifecycle-editor-model.js +++ /dev/null @@ -1,66 +0,0 @@ - function empty() { - return { onCaseStart: [], onCaseComplete: [] }; - } - - function clone(value) { - return value ? JSON.parse(JSON.stringify(value)) : empty(); - } - - function actions(draft, field) { - const value = draft || empty(); - return Array.isArray(value[field]) ? value[field] : []; - } - - function change(draft, operation) { - const next = clone(draft); - operation(next); - return next; - } - - function addAction(draft, field, action = 'runCase') { - return change(draft, (next) => { - actions(next, field).push({ action, value: '' }); - }); - } - - function updateAction(draft, field, actionIndex, key, value) { - return change(draft, (next) => { - actions(next, field)[actionIndex][key] = value; - }); - } - - function deleteAction(draft, field, actionIndex) { - return change(draft, (next) => actions(next, field).splice(actionIndex, 1)); - } - - function moveAction(draft, field, actionIndex, delta) { - return change(draft, (next) => { - const items = actions(next, field); - const destination = actionIndex + delta; - if (destination < 0 || destination >= items.length) return; - [items[actionIndex], items[destination]] = [items[destination], items[actionIndex]]; - }); - } - - function validationError(draft) { - for (const field of ['onCaseStart', 'onCaseComplete']) { - for (const action of actions(draft, field)) { - if (!['runCase', 'runShell'].includes(action.action)) return 'Lifecycle action type is invalid.'; - if (!String(action.value || '').trim()) return 'Lifecycle action values cannot be empty.'; - } - } - return ''; - } - - const LifecycleEditorModel = { - empty, - clone, - actions, - addAction, - updateAction, - deleteAction, - moveAction, - validationError, - }; - - export default LifecycleEditorModel; diff --git a/frontend/playground/playground.css b/frontend/playground/playground.css deleted file mode 100644 index 3afe780d..00000000 --- a/frontend/playground/playground.css +++ /dev/null @@ -1,1719 +0,0 @@ -:root { - color-scheme: light dark; - font-family: "Segoe UI", Arial, sans-serif; - --control-panel-width: clamp(320px, 32vw, 520px); - background: #f6f7f9; - color: #15171a; -} - -.progress-item-with-artifacts { - cursor: pointer; -} - -body { - margin: 0; - height: 100vh; - overflow: hidden; -} - -.toast { - position: fixed; - top: 18px; - right: 18px; - z-index: 1000; - max-width: min(360px, calc(100vw - 36px)); - padding: 10px 14px; - border: 1px solid #86c89a; - border-radius: 6px; - background: #f0fdf4; - color: #166534; - box-shadow: 0 8px 24px rgb(15 23 42 / 18%); - font-size: 13px; - font-weight: 650; -} - -[hidden] { - display: none !important; -} - -button, -select, -textarea { - font: inherit; -} - -button { - border: 1px solid #b8c0cc; - border-radius: 6px; - background: #ffffff; - color: #15171a; - padding: 8px 12px; - cursor: pointer; -} - -button.primary { - background: #0f6cbd; - color: #ffffff; - border-color: #0f6cbd; -} - -button.secondary-button { - min-height: 34px; - padding: 6px 10px; - background: #f8fafc; - color: #334155; - border-color: #cbd5e1; -} - -button.secondary-button:hover:not(:disabled) { - background: #eef2f7; -} - -#run-selected { - min-width: 80px; -} - -button:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.shell { - display: grid; - grid-template-columns: minmax(300px, var(--control-panel-width)) 8px minmax(0, 1fr); - gap: 8px; - height: 100vh; - padding: 12px; - box-sizing: border-box; - overflow: hidden; -} - -.panel { - background: #ffffff; - border: 1px solid #d8dee8; - border-radius: 8px; - min-height: 0; - overflow: hidden; -} - -.control-panel { - display: flex; - flex-direction: column; - min-height: 0; - overflow: auto; -} - -.panel-resizer { - position: relative; - align-self: stretch; - min-height: 0; - border: 0; - border-radius: 6px; - background: transparent; - cursor: col-resize; - outline: none; -} - -.panel-resizer::before { - content: ""; - position: absolute; - top: 8px; - bottom: 8px; - left: 50%; - width: 2px; - border-radius: 999px; - background: #d8dee8; - transform: translateX(-50%); -} - -.panel-resizer:hover::before, -.panel-resizer:focus-visible::before, -.shell.resizing-panels .panel-resizer::before { - background: #0f6cbd; -} - -.shell.resizing-panels { - cursor: col-resize; - user-select: none; -} - -.preview-panel { - display: flex; - flex-direction: column; - height: calc(100vh - 24px); - min-height: 0; -} - -.topbar, -.preview-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 16px; - border-bottom: 1px solid #e5e9f0; -} - -.preview-tabs { - display: inline-flex; - flex-wrap: wrap; - gap: 18px; - margin-bottom: 6px; - border-bottom: 1px solid #d8dee8; -} - -.tab-button { - position: relative; - min-height: 30px; - padding: 0 0 7px; - border: 0; - border-radius: 0; - background: transparent; - color: #5c6573; - font-weight: 600; -} - -.tab-button.active { - background: transparent; - color: #0f4c81; -} - -.tab-button.active::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: -1px; - height: 3px; - border-radius: 999px 999px 0 0; - background: #0f6cbd; -} - -.tab-button:hover { - color: #0f4c81; -} - -h1, -h2, -p { - margin: 0; -} - -h1 { - font-size: 20px; -} - -.status-pill { - display: inline-flex; - align-items: center; - gap: 7px; - min-height: 24px; - margin-top: 7px; - padding: 3px 10px; - border: 1px solid #cbd5e1; - border-radius: 999px; - background: #f8fafc; - color: #475569; - font-size: 12px; - font-weight: 650; - line-height: 1; -} - -.status-pill::before { - content: ""; - width: 7px; - height: 7px; - border-radius: 999px; - background: currentColor; -} - -.status-ready { - border-color: #bbf7d0; - background: #f0fdf4; - color: #15803d; -} - -.status-running { - border-color: #bfdbfe; - background: #eff6ff; - color: #1d4ed8; -} - -.status-connecting { - border-color: #fde68a; - background: #fffbeb; - color: #b45309; -} - -.status-error { - border-color: #fecaca; - background: #fef2f2; - color: #b91c1c; -} - -h2 { - font-size: 15px; - margin-bottom: 10px; -} - -.section { - padding: 16px; - border-bottom: 1px solid #edf0f5; -} - -.section-heading-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} - -.section-heading-row h2 { - margin-bottom: 0; -} - -.load-run-toggle { - min-height: 30px; - padding: 5px 10px; -} - -.load-run-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 8px; -} - -.load-run-form input { - min-width: 0; - margin: 0; -} - -.load-run-actions { - display: inline-flex; - gap: 6px; -} - -.load-run-actions button { - min-height: 36px; - padding: 7px 11px; -} - -.load-run-form .yaml-status { - grid-column: 1 / -1; - margin: 0; -} - -@media (max-width: 680px) { - .load-run-form { - grid-template-columns: minmax(0, 1fr); - } - - .load-run-actions { - justify-content: flex-end; - } -} - -.execution-section { - display: flex; - flex-direction: column; - flex: 1 1 auto; - gap: 10px; - min-height: 320px; -} - -.yaml-tabs { - display: inline-flex; - flex-wrap: wrap; - gap: 18px; - width: fit-content; - margin-bottom: 2px; - border-bottom: 1px solid #d8dee8; -} - -.yaml-tab-button { - position: relative; - min-height: 30px; - padding: 0 0 7px; - border: 0; - border-radius: 0; - background: transparent; - color: #5c6573; - font-weight: 600; -} - -.yaml-tab-button.active { - color: #0f4c81; -} - -.yaml-tab-button.active::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: -1px; - height: 3px; - border-radius: 999px 999px 0 0; - background: #0f6cbd; -} - -.yaml-tab-button:hover { - color: #0f4c81; -} - -.yaml-pane { - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.yaml-path-row { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 8px; - min-width: 0; -} - -.yaml-path-row input { - margin-bottom: 0; -} - -.yaml-status { - min-height: 18px; - margin: 8px 0; - color: #475569; - font-size: 12px; - overflow-wrap: anywhere; -} - -.yaml-status.yaml-status-error { - color: #b91c1c; -} - -.yaml-status.yaml-status-success { - color: #15803d; -} - -.yaml-viewer { - flex: 1 1 auto; - min-height: 150px; - overflow: auto; - border: 1px solid #d8dee8; - border-radius: 6px; - background: #f8fafc; - color: #1f2937; - font-size: 12px; - line-height: 1.45; -} - -.yaml-empty { - padding: 12px; - color: #64748b; -} - -.yaml-lifecycle-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 10px; - border: 1px solid #d8dee8; - border-bottom: 0; - background: #ffffff; -} - -.yaml-lifecycle-dirty { - color: #92400e; - font-size: 12px; - font-weight: 650; -} - -.yaml-lifecycle-toolbar-actions, -.yaml-lifecycle-action-controls { - display: flex; - align-items: center; - gap: 5px; -} - -.yaml-lifecycle-toolbar-actions { - margin-left: auto; -} - -.yaml-lifecycle-section { - scroll-margin-top: 56px; - padding: 10px; - border-bottom: 1px solid #d8dee8; - background: #f8fafc; -} - -.yaml-lifecycle-section[data-lifecycle-field="onCaseComplete"] { - border-top: 1px solid #d8dee8; -} - -.yaml-case-steps-section { - background: #f8fafc; -} - -.yaml-case-steps-heading { - padding: 10px 10px 0; -} - -.yaml-lifecycle-heading, -.yaml-lifecycle-action-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.yaml-lifecycle-heading h3 { - margin: 0; - color: #334155; - font-size: 13px; - letter-spacing: 0; -} - -.yaml-lifecycle-list { - display: grid; - gap: 8px; - margin-top: 8px; -} - -.yaml-lifecycle-action-row { - display: grid; - grid-template-columns: auto minmax(92px, 118px) minmax(0, 1fr) auto; - align-items: center; - gap: 7px; - padding: 7px 8px; - border: 1px solid #cbd5e1; - border-radius: 6px; - background: #ffffff; -} - -.yaml-lifecycle-action-row select, -.yaml-lifecycle-action-row input { - height: 32px; - min-width: 0; - margin: 0; - padding: 4px 7px; - border: 1px solid #cbd5e1; - border-radius: 5px; - background: #ffffff; - color: #1f2937; - font: inherit; - line-height: 1.2; -} - -.yaml-lifecycle-icon-button { - width: 28px; - height: 28px; - padding: 0; - border: 1px solid #cbd5e1; - border-radius: 5px; - background: #ffffff; - color: #334155; - font-size: 16px; - line-height: 1; -} - -.yaml-lifecycle-icon-button:hover:not(:disabled) { - border-color: #7aa7d9; - background: #eaf3ff; -} - -.yaml-lifecycle-icon-button:disabled { - opacity: 0.35; -} - -.yaml-lifecycle-empty { - padding: 8px 0 0; - color: #64748b; - font-size: 12px; -} - -@media (max-width: 760px) { - .yaml-lifecycle-action-row { - grid-template-columns: 1fr; - } - - .yaml-lifecycle-action-controls { - justify-content: flex-end; - } -} - -.yaml-case-summary { - border-bottom: 1px solid #e5e9f0; - background: #ffffff; - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.yaml-case-summary:hover { - border-bottom-color: #cbdcf0; - background: #f1f7ff; -} - -.yaml-case-summary.yaml-region-selected { - border-bottom-color: #a9c6e8; - background: #eaf3ff; - box-shadow: inset 3px 0 0 #93c5fd; -} - -.yaml-case-title-row { - position: sticky; - top: 0; - z-index: 1; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 7px; - padding: 12px; - border-bottom: 1px solid #d8e8fb; - background: #eef6ff; - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.yaml-case-summary:hover .yaml-case-title-row, -.yaml-case-title-row:hover { - border-bottom-color: #bdd4ee; - background: #e5f1ff; -} - -.yaml-case-summary.yaml-region-selected .yaml-case-title-row, -.yaml-case-title-row.yaml-region-selected { - border-bottom-color: #9fc2ea; - background: #d7eaff; -} - -.yaml-case-title { - display: -webkit-box; - min-width: 0; - overflow: hidden; - color: #0f4c81; - font-size: 15px; - font-weight: 750; - overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; -} - -.yaml-chip { - display: inline-flex; - align-items: center; - min-height: 20px; - max-width: 100%; - padding: 1px 7px; - border: 1px solid #bfdbfe; - border-radius: 999px; - background: #eff6ff; - color: #1d4ed8; - font-size: 11px; - font-weight: 650; - overflow-wrap: anywhere; -} - -.yaml-chip-muted { - border-color: #cbd5e1; - background: #f8fafc; - color: #475569; -} - -.yaml-tags { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 10px 12px 0; -} - -.yaml-metadata-grid { - display: grid; - grid-template-columns: 1fr; - gap: 6px; - padding: 10px 12px 12px; -} - -.yaml-metadata-item { - display: grid; - grid-template-columns: 78px minmax(0, 1fr); - gap: 8px; - margin: 0 -6px; - padding: 4px 6px; - border-radius: 5px; - transition: background-color 120ms ease; -} - -.yaml-metadata-item:hover { - background: #f1f5f9; -} - -.yaml-metadata-item.yaml-region-selected { - background: #e8f1fb; -} - -.yaml-metadata-label { - color: #64748b; - font-weight: 650; -} - -.yaml-metadata-value { - min-width: 0; - color: #1f2937; - overflow-wrap: anywhere; -} - -.yaml-step-list { - display: grid; - gap: 8px; - padding: 10px; -} - -.yaml-step-card { - border: 1px solid #d8dee8; - border-radius: 6px; - background: #ffffff; - cursor: pointer; - overflow: hidden; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.yaml-step-card:hover, -.yaml-step-card:focus-within { - border-color: #93b4d8; - background: #eef6ff; -} - -.yaml-step-card.yaml-region-selected { - border-color: #7aa7d9; - background: #e4f1ff; - box-shadow: inset 3px 0 0 #60a5fa; -} - -.yaml-step-card.yaml-step-card-active { - border-color: #3b82f6; - background: #dbeafe; - box-shadow: inset 3px 0 0 #2563eb; -} - -.yaml-step-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 7px; - padding: 8px 10px; - border-bottom: 1px solid #edf0f5; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.yaml-step-card:hover .yaml-step-header, -.yaml-step-card:focus-within .yaml-step-header { - border-bottom-color: #cfe2f7; - background: #ddecff; -} - -.yaml-step-card.yaml-region-selected .yaml-step-header { - border-bottom-color: #bdd7f3; - background: #d7eaff; -} - -.yaml-step-card.yaml-step-card-active .yaml-step-header { - border-bottom-color: #93c5fd; - background: #bfdbfe; -} - -.yaml-step-index { - color: #1d4ed8; - font-family: Consolas, monospace; - font-size: 13px; - font-weight: 700; -} - -.yaml-step-card.yaml-region-selected .yaml-step-index, -.yaml-step-card.yaml-step-card-active .yaml-step-index { - color: #0f3f8a; -} - -.yaml-action-name { - display: inline-flex; - align-items: center; - width: fit-content; - max-width: 100%; - min-height: 22px; - min-width: 0; - padding: 2px 8px; - border: 1px solid #cbd5e1; - border-radius: 6px; - background: #f8fafc; - color: #0f172a; - font-family: Consolas, monospace; - font-size: 13px; - font-weight: 700; - overflow-wrap: anywhere; -} - -.yaml-action-name-setup { - border-color: #bbf7d0; - background: #f0fdf4; - color: #166534; -} - -.yaml-action-name-teardown { - border-color: #fecaca; - background: #fef2f2; - color: #991b1b; -} - -.yaml-action-name-assertion { - border-color: #fde68a; - background: #fffbeb; - color: #92400e; -} - -.yaml-action-name-observation { - border-color: #bae6fd; - background: #f0f9ff; - color: #075985; -} - -.yaml-action-name-action { - border-color: #bfdbfe; - background: #eff6ff; - color: #1e40af; -} - -.yaml-param-list { - display: grid; - gap: 1px; - padding: 8px 10px 10px; -} - -.yaml-param-row { - display: grid; - grid-template-columns: minmax(72px, 34%) minmax(0, 1fr); - gap: 8px; - padding: 3px 6px; - border-radius: 5px; - transition: background-color 120ms ease; -} - -.yaml-param-row:hover { - background: #eef4fb; -} - -.yaml-param-row.yaml-region-selected { - background: #e4eef9; -} - -.yaml-param-nested { - margin-left: 12px; - padding-left: 10px; - border-left: 2px solid #e2e8f0; -} - -.yaml-param-row-nested { - grid-template-columns: minmax(86px, 38%) minmax(0, 1fr); -} - -.yaml-param-row-nested .yaml-param-key { - color: #64748b; - font-weight: 600; -} - -.yaml-param-key { - color: #475569; - font-family: Consolas, monospace; - font-weight: 650; - overflow-wrap: anywhere; -} - -.yaml-param-value { - min-width: 0; - color: #111827; - overflow-wrap: anywhere; -} - -.yaml-param-value-object, -.yaml-param-value-list { - font-family: Consolas, monospace; - font-size: 11px; -} - -.yaml-param-value-null { - color: #64748b; - font-style: italic; -} - -.yaml-param-value-secret { - color: #7c2d12; - font-family: Consolas, monospace; - font-weight: 700; -} - -.session-row { - display: grid; - grid-template-columns: 1fr; - gap: 8px; -} - -select, -input, -textarea { - width: 100%; - box-sizing: border-box; - border: 1px solid #b8c0cc; - border-radius: 6px; - padding: 8px; - background: #ffffff; - color: #15171a; -} - -input { - margin-bottom: 8px; -} - -.run-mode-row { - display: flex; - gap: 14px; - margin-bottom: 10px; -} - -.run-mode-row label { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.run-mode-row input { - width: auto; - margin: 0; -} - -.run-input-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: start; -} - -.run-input-row textarea { - grid-column: 1 / -1; -} - -.run-input-row .yaml-path-row { - grid-column: 1 / -1; -} - -.run-input-row #run-selected { - grid-column: 1 / -1; - justify-self: end; - align-self: start; -} - -textarea { - resize: vertical; - min-height: 120px; - margin-bottom: 8px; -} - -.muted { - color: #5c6573; - font-size: 13px; - margin-top: 8px; -} - -.progress-run-id { - flex: 0 0 auto; - margin-bottom: 8px; - border: 1px solid #b8c0cc; - border-radius: 6px; - background: #eef6ff; - color: #0f4c81; - padding: 8px 10px; - font-family: Consolas, monospace; - font-size: 13px; - font-weight: 650; - overflow-wrap: anywhere; - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.progress-run-id:hover, -.progress-run-id:focus-visible { - border-color: #93b4d8; - background: #ddecff; -} - -.progress-run-id.progress-run-id-selected { - border-color: #7aa7d9; - background: #dbeafe; - box-shadow: inset 3px 0 0 #60a5fa; -} - -.progress-log { - flex: 1; - min-height: 0; - overflow: auto; - background: #f8fafc; - color: #1f2937; - border: 1px solid #d8dee8; - border-radius: 6px; - padding: 10px; - font-family: Consolas, monospace; - font-size: 13px; -} - -.progress-item { - display: grid; - grid-template-columns: 42px 14px 1fr; - align-items: start; - gap: 8px; - margin: 0 -6px; - padding: 6px; - border-bottom: 1px solid #e5e9f0; - border-radius: 5px; - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.progress-item:hover, -.progress-item:focus-visible { - background: #eef6ff; -} - -.progress-item.progress-item-selected { - background: #e4f1ff; - box-shadow: inset 3px 0 0 #60a5fa; -} - -.progress-item.progress-item-active { - background: #dbeafe; - box-shadow: inset 3px 0 0 #2563eb; -} - -.progress-number { - color: #1d4ed8; - font-variant-numeric: tabular-nums; -} - -.progress-text { - min-width: 0; - overflow-wrap: anywhere; - display: grid; - gap: 3px; -} - -.progress-title-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - min-width: 0; -} - -.progress-title { - color: #111827; - font-weight: 650; - min-width: 0; - overflow-wrap: anywhere; -} - -.progress-message { - color: #4b5563; - font-size: 12px; - line-height: 1.35; -} - -.progress-tool { - display: inline-flex; - width: fit-content; - max-width: 180px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding: 1px 7px; - border-radius: 999px; - background: #dbeafe; - color: #1e40af; - font-size: 11px; - font-weight: 600; -} - -.progress-status-dot { - width: 9px; - height: 9px; - border-radius: 999px; - margin-top: 4px; - background: #9ca3af; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08); -} - -.progress-status-success { - background: #22c55e; -} - -.progress-status-failed { - background: #ef4444; -} - -.progress-detail { - grid-column: 3; - margin: 2px 0 6px; -} - -.progress-detail summary { - color: #1e3a8a; - cursor: pointer; - font-weight: 600; - margin-bottom: 4px; -} - -.progress-detail pre { - max-height: min(52vh, 520px); - overflow: auto; - margin: 0; - padding: 8px; - border: 1px solid #cbd5e1; - border-radius: 4px; - background: #e2e8f0; - color: #111827; - white-space: pre-wrap; - word-break: break-word; -} - -.preview-frame { - position: relative; - flex: 1; - min-height: 360px; - display: grid; - place-items: center; - background: #f8fafc; -} - -.step-artifact-preview { - align-self: stretch; - justify-self: stretch; - height: 100%; - min-height: 0; - overflow: auto; - box-sizing: border-box; - background: #f8fafc; - color: #1f2937; -} - -.step-artifact-preview[hidden] { - display: none; -} - -.step-artifact-shell { - display: flex; - flex-direction: column; - gap: 12px; - box-sizing: border-box; - min-height: 100%; - padding: 16px; -} - -.step-artifact-title { - position: sticky; - top: 0; - z-index: 2; - flex: none; - margin: -16px -16px 0; - padding: 14px 16px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - color: #0f172a; - font-size: 16px; - font-weight: 750; -} - -.step-artifact-body { - display: flex; - flex-direction: column; - gap: 24px; -} - -.step-artifact-region { - min-height: 120px; - overflow: auto; - box-sizing: border-box; - padding: 12px; - border: 1px solid #e2e8f0; - border-radius: 8px; - background: #ffffff; -} - -.step-artifact-region-screenshots { - position: relative; - display: flex; - flex-direction: column; - height: min(80vh, 820px); - min-height: 340px; - overflow: hidden; -} - -.step-artifact-screenshot-scroll { - display: flex; - flex: 1; - min-height: 0; - margin-bottom: 8px; - overflow-x: scroll; - overflow-y: hidden; - scrollbar-gutter: stable; -} - -.step-artifact-screenshot-scroll > .step-artifact-section { - flex: 0 0 100%; - width: 100%; - box-sizing: border-box; -} - -.step-artifact-screenshot-border-resizer { - position: absolute; - z-index: 3; - right: 0; - bottom: 0; - left: 0; - height: 10px; - cursor: row-resize; -} - -.step-artifact-screenshot-border-resizer:hover { - border-bottom: 2px solid #94a3b8; -} - -.step-artifact-region-screenshots .step-artifact-section { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; -} - -.step-artifact-region-screenshots .step-artifact-screenshot-row { - flex: 1; - min-height: 0; - align-items: stretch; - grid-template-rows: minmax(0, 1fr); -} - -.step-artifact-region-screenshots .step-artifact-image-card { - display: flex; - min-height: 0; - flex-direction: column; -} - -.step-artifact-region-screenshots .step-artifact-image-card img { - flex: 1; - min-height: 0; - max-height: 100%; - object-fit: contain; -} - -.step-artifact-region-screenshots.step-artifact-region-user-sized .step-artifact-section { - flex-basis: auto; - width: max-content; - min-width: 100%; -} - -.step-artifact-region-screenshots.step-artifact-region-user-sized .step-artifact-screenshot-row { - width: max-content; - min-width: 100%; - justify-content: safe center; -} - -.step-artifact-region-screenshots.step-artifact-region-user-sized .step-artifact-screenshot-row.step-artifact-compare { - grid-template-columns: var(--shot-card-width) 42px var(--shot-card-width); -} - -.step-artifact-region-screenshots.step-artifact-region-user-sized .step-artifact-screenshot-row:not(.step-artifact-compare) { - grid-template-columns: var(--shot-card-width); -} - -.step-artifact-region-text { - position: relative; - display: flex; - flex-direction: column; - height: min(68vh, 620px); - min-height: 220px; - overflow: hidden; -} - -.step-artifact-region-text > .step-artifact-section { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; -} - -.step-artifact-region-text .step-artifact-diff-card { - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; -} - -.step-artifact-region-text .step-artifact-diff-body { - flex: 1; - min-height: 0; - height: auto; -} - -.step-artifact-resizer { - flex: none; - height: 12px; - position: relative; - cursor: row-resize; -} - -.step-artifact-resizer::before { - content: ""; - position: absolute; - left: 50%; - top: 50%; - width: 46px; - height: 4px; - border-radius: 999px; - background: #cbd5e1; - transform: translate(-50%, -50%); -} - -.step-artifact-resizer:hover::before { - background: #94a3b8; -} - -.step-artifact-ui-tree-border-resizer { - position: absolute; - z-index: 3; - top: 0; - right: 0; - left: 0; - height: 10px; - cursor: row-resize; -} - -.step-artifact-ui-tree-border-resizer:hover { - border-top: 2px solid #94a3b8; -} - -.step-artifact-ui-tree-bottom-border-resizer { - position: absolute; - z-index: 3; - right: 0; - bottom: 0; - left: 0; - height: 10px; - cursor: row-resize; -} - -.step-artifact-ui-tree-bottom-border-resizer:hover { - border-bottom: 2px solid #94a3b8; -} - -body.step-artifact-resizing { - cursor: row-resize; - user-select: none; -} - -.step-artifact-subtitle, -.step-artifact-empty, -.step-artifact-error { - color: #64748b; - font-size: 13px; -} - -.step-artifact-error { - color: #b91c1c; -} - -.step-artifact-section { - display: grid; - gap: 10px; -} - -.step-artifact-section-title { - color: #334155; - font-size: 15px; - font-weight: 700; -} - -.step-artifact-screenshot-row { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 10px; - align-items: center; -} - -.step-artifact-screenshot-row.step-artifact-compare { - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); -} - -.step-artifact-image-card, -.step-artifact-text-card { - min-width: 0; - border: 1px solid #d8dee8; - border-radius: 6px; - background: #f8fafc; - overflow: hidden; -} - -.step-artifact-image-card { - margin: 0; - background: #eef2f7; -} - -.step-artifact-image-label, -.step-artifact-text-label { - padding: 7px 9px; - border-bottom: 1px solid #edf0f5; - background: #eef6ff; - color: #334155; - font-family: "Segoe UI", Arial, sans-serif; - font-size: 12px; - font-weight: 800; -} - -.step-artifact-image-card img { - display: block; - width: 100%; - max-height: min(42vh, 440px); - object-fit: contain; - background: #eef2f7; -} - -.step-artifact-connector { - position: relative; - align-self: center; - justify-self: center; - width: 42px; - height: 18px; -} - -.step-artifact-connector::before { - content: ""; - position: absolute; - top: 50%; - left: 2px; - right: 7px; - height: 1px; - border-radius: 999px; - background: #94a3b8; - transform: translateY(-50%); -} - -.step-artifact-connector::after { - content: ""; - position: absolute; - top: 50%; - right: 3px; - width: 8px; - height: 8px; - border-top: 1.5px solid #64748b; - border-right: 1.5px solid #64748b; - transform: translateY(-50%) rotate(45deg); -} - -.step-artifact-text-card pre { - max-height: min(34vh, 340px); - overflow: auto; - margin: 0; - padding: 12px 14px; - border-left: 3px solid #bfdbfe; - background: #f8fbff; - color: #0f172a; - white-space: pre; - word-break: normal; - font-family: Consolas, monospace; - font-size: 12px; - line-height: 1.55; - tab-size: 2; -} - -.step-artifact-text-card pre.step-artifact-xml { - border-left-color: #86efac; - background: #f7fdf9; - color: #14532d; - line-height: 1.5; -} - -.step-artifact-diff-card { - min-width: 0; - border: 1px solid #d8dee8; - border-radius: 6px; - background: #ffffff; - overflow: hidden; -} - -.step-artifact-diff-body { - display: flex; - flex-direction: column; - height: min(60vh, 540px); - min-height: 200px; - overflow: hidden; - background: #f8fafc; - font-family: Consolas, monospace; - font-size: 12px; - line-height: 1.5; -} - -.step-artifact-diff-empty { - padding: 2px 8px; - color: #475569; -} - -.step-artifact-diff-split { - display: flex; - flex: 1 1 auto; - min-width: 0; - align-items: stretch; -} - -.step-artifact-diff-scroll { - display: flex; - align-items: stretch; - flex: 1; - min-height: 0; -} - -.step-artifact-diff-overview { - position: relative; - flex: 0 0 14px; - border-left: 1px solid #d8dee8; - background: #f1f5f9; - cursor: pointer; -} - -.step-artifact-diff-overview-marker { - position: absolute; - left: 2px; - right: 2px; - min-height: 3px; - margin: 0; - padding: 0; - border: 0; - border-radius: 1px; - cursor: pointer; -} - -.step-artifact-diff-overview-added { - background: #22c55e; -} - -.step-artifact-diff-overview-removed { - background: #ef4444; -} - -.step-artifact-diff-pane { - flex: 1 1 50%; - min-width: 0; - min-height: 0; - overflow: auto; -} - -.step-artifact-diff-pane + .step-artifact-diff-pane { - border-left: 1px solid #d8dee8; -} - -.step-artifact-diff-column { - width: max-content; - min-width: 100%; -} - -.step-artifact-diff-row { - display: flex; - white-space: pre; -} - -.step-artifact-diff-headrow { - position: sticky; - top: 0; - z-index: 1; -} - -.step-artifact-diff-number { - flex: 0 0 44px; - padding: 2px 8px; - border-right: 1px solid #e5e9f0; - color: #64748b; - text-align: right; - user-select: none; -} - -.step-artifact-diff-text { - flex: 1 0 auto; - padding: 2px 8px; -} - -.diff-pane-after .step-artifact-diff-line-added .step-artifact-diff-number, -.diff-pane-after .step-artifact-diff-line-added .step-artifact-diff-text { - background: #dcfce7; -} - -.diff-pane-before .step-artifact-diff-line-removed .step-artifact-diff-number, -.diff-pane-before .step-artifact-diff-line-removed .step-artifact-diff-text { - background: #fee2e2; -} - -.diff-pane-before .step-artifact-diff-line-removed .step-artifact-diff-inline-change { - background: #fca5a5; -} - -.diff-pane-after .step-artifact-diff-line-added .step-artifact-diff-inline-change { - background: #86efac; -} - -.step-artifact-diff-inline-change { - border-radius: 2px; - font: inherit; -} - -.step-artifact-diff-line-added { - color: #14532d; -} - -.step-artifact-diff-line-removed { - color: #7f1d1d; -} - -.step-artifact-diff-line-context { - color: #475569; -} - -.step-artifact-diff-line-omitted { - color: #64748b; - background: #f1f5f9; -} - -.tab-pane { - min-height: 0; -} - -.progress-pane { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - box-sizing: border-box; - padding: 16px; - background: #ffffff; -} - -.execution-section .progress-pane { - padding: 0; - background: transparent; -} - -.report-pane { - flex: 1; - overflow: hidden; - background: #f8fafc; -} - -.report-content { - height: 100%; - overflow: auto; - box-sizing: border-box; - margin: 0; - padding: 20px 24px; - color: #1f2937; - word-break: break-word; - font-family: "Segoe UI", Arial, sans-serif; - font-size: 14px; - line-height: 1.55; -} - -.report-content h1, -.report-content h2, -.report-content h3, -.report-content h4 { - color: #0f172a; - margin: 16px 0 8px; -} - -.report-content h1 { - font-size: 22px; -} - -.report-content h2 { - font-size: 18px; -} - -.report-content p { - margin: 8px 0; -} - -.report-content ul { - margin: 8px 0; - padding-left: 22px; -} - -.report-content li { - margin: 4px 0; -} - -.report-content code { - border-radius: 4px; - background: #e2e8f0; - color: #0f172a; - padding: 1px 5px; - font-family: Consolas, monospace; - font-size: 13px; -} - -.report-content pre { - overflow: auto; - border: 1px solid #cbd5e1; - border-radius: 6px; - background: #eef2f7; - padding: 12px; -} - -.report-content pre code { - background: transparent; - padding: 0; -} - -#screenshot, -#replay-video { - display: none; - max-width: 100%; - max-height: calc(100vh - 150px); - object-fit: contain; -} - -#preview-empty { - color: #64748b; -} - -@media (max-width: 900px) { - .shell { - grid-template-columns: 1fr; - height: auto; - min-height: 100vh; - overflow: visible; - } - - body { - height: auto; - overflow: auto; - } - - .panel-resizer { - display: none; - } - - .preview-panel { - min-height: min(680px, 82vh); - height: auto; - } - - .progress-pane, - .report-pane { - min-height: min(520px, 68vh); - } - -} diff --git a/frontend/playground/playground.js b/frontend/playground/playground.js deleted file mode 100644 index accc39e4..00000000 --- a/frontend/playground/playground.js +++ /dev/null @@ -1,3378 +0,0 @@ -import EBML from 'ts-ebml/dist/EBML.js'; -import LifecycleEditorModel from './lifecycle-editor-model.js'; - -const { Decoder, Reader, tools: ebmlTools } = EBML; - -const state = { - currentRequestId: null, - progressTimer: null, - progressStream: null, - progressScrollScheduled: false, - replayRequestId: null, - previewToken: null, - pendingReplayVideoCleanup: null, - replayVideoInFlight: false, - progressSequence: 0, - lastProgressSequence: 0, - progressDetailOpenState: new Map(), - selectedProgressItem: null, - activeProgressItem: null, - activeProgressItemClearTimer: null, - selectedProgressRunId: false, - finishingRun: false, - loadedRunId: null, - loadedRunAvailability: null, - loadRunInFlight: false, - activeRunMode: 'goal', - modeStates: { - goal: createRunModeState(), - yaml: createRunModeState(), - 'strict-yaml': createRunModeState(), - }, - yamlActiveView: 'input', - yamlInputContent: '', - yamlRecordedContent: '', - yamlInputDisplay: null, - yamlInputRevision: '', - yamlInputEditable: false, - yamlLifecycleSnapshot: null, - yamlLifecycleDraft: null, - yamlLifecycleDirty: false, - yamlLifecycleSaving: false, - yamlRecordedDisplay: null, - yamlInputLastPreviewPath: '', - selectedYamlRegion: null, - selectedYamlStepCard: null, - selectedYamlCaseSummary: null, - selectedYamlCaseTitle: null, - activeYamlStepCard: null, - activeYamlStepClearTimer: null, - stepArtifactPreviewActive: false, - stepArtifactStepKey: null, - currentExecutionMode: null, - platformId: null, - platformLabel: null, - toastTimer: null, -}; - -function createRunModeState() { - return { - caseYamlValue: '', - progressHtml: '', - progressRunIdText: '', - progressRunIdValue: '', - progressRunIdHidden: true, - progressSequence: 0, - lastProgressSequence: 0, - progressDetailOpenEntries: [], - yamlActiveView: '', - yamlInputContent: '', - yamlInputDisplay: null, - yamlInputRevision: '', - yamlInputEditable: false, - yamlLifecycleSnapshot: null, - yamlLifecycleDraft: null, - yamlLifecycleDirty: false, - yamlInputLastPreviewPath: '', - yamlInputStatusText: '', - yamlInputStatusClassName: 'yaml-status yaml-status-neutral', - yamlInputStatusHidden: true, - yamlInputHtml: '', - yamlRecordedContent: '', - yamlRecordedDisplay: null, - yamlRecordedStatusText: '', - yamlRecordedStatusClassName: 'yaml-status yaml-status-neutral', - yamlRecordedStatusHidden: true, - yamlRecordedHtml: '', - rightActiveTab: 'preview', - reportHtml: '', - replayRequestId: null, - previewToken: null, - replayVideoSrc: '', - replayVideoHidden: true, - replayVideoDisplay: 'none', - screenshotSrc: '', - screenshotDisplay: 'none', - previewEmptyText: '', - previewEmptyDisplay: '', - stepArtifactPreviewActive: false, - stepArtifactStepKey: null, - stepArtifactHidden: true, - stepArtifactNodes: [], - }; -} - -const REPLAY_FAST_SAME_EVENT_DELAY_MS = 250; -const REPLAY_FAST_ACTION_DELAY_MS = 900; -const REPLAY_FAST_MAX_DELAY_MS = 1500; -const REPLAY_FAST_FALLBACK_DELAY_MS = 500; -const REPLAY_FAST_FINAL_FRAME_HOLD_MS = 700; -const REPLAY_FAST_TIME_SCALE = 10; -const PROGRESS_POLL_INTERVAL_MS = 750; -const CONTROL_PANEL_WIDTH_STORAGE_KEY = 'fsqPlayground.controlPanelWidth'; -const CONTROL_PANEL_MIN_WIDTH = 300; -const CONTROL_PANEL_MAX_WIDTH = 720; -const PREVIEW_PANEL_MIN_WIDTH = 360; -const YAML_SELECTABLE_REGION_SELECTOR = '.yaml-param-row, .yaml-metadata-item, .yaml-step-card, .yaml-case-title-row, .yaml-case-summary'; -const YAML_STEP_CENTER_TOLERANCE_RATIO = 0.15; - -const els = { - shell: document.querySelector('.shell'), - toast: document.getElementById('toast'), - controlPanel: document.querySelector('.control-panel'), - panelResizer: document.getElementById('panel-resizer'), - status: document.getElementById('server-status'), - refresh: document.getElementById('refresh'), - sessionSection: document.getElementById('session-section'), - deviceSelect: document.getElementById('device-select'), - sessionMessage: document.getElementById('session-message'), - goal: document.getElementById('goal'), - caseYaml: document.getElementById('case-yaml'), - yamlPathRow: document.getElementById('yaml-path-row'), - executionSection: document.getElementById('execution-section'), - loadRunToggle: document.getElementById('load-run-toggle'), - loadRunForm: document.getElementById('load-run-form'), - loadRunPath: document.getElementById('load-run-path'), - loadRunSubmit: document.getElementById('load-run-submit'), - loadRunCancel: document.getElementById('load-run-cancel'), - loadRunStatus: document.getElementById('load-run-status'), - yamlTabs: document.querySelector('.yaml-tabs'), - yamlInputTab: document.getElementById('yaml-input-tab'), - yamlRecordedTab: document.getElementById('yaml-recorded-tab'), - yamlProgressTab: document.getElementById('yaml-progress-tab'), - yamlInputPane: document.getElementById('yaml-input-pane'), - yamlRecordedPane: document.getElementById('yaml-recorded-pane'), - yamlInputStatus: document.getElementById('yaml-input-status'), - yamlLifecycleToolbar: document.getElementById('yaml-lifecycle-toolbar'), - yamlLifecycleDirty: document.getElementById('yaml-lifecycle-dirty'), - yamlLifecycleSave: document.getElementById('yaml-lifecycle-save'), - yamlLifecycleDiscard: document.getElementById('yaml-lifecycle-discard'), - yamlRecordedStatus: document.getElementById('yaml-recorded-status'), - yamlInputViewer: document.getElementById('yaml-input-viewer'), - yamlRecordedViewer: document.getElementById('yaml-recorded-viewer'), - runSelected: document.getElementById('run-selected'), - runModeInputs: Array.from(document.querySelectorAll('input[name="run-mode"]')), - progressRunId: document.getElementById('progress-run-id'), - progress: document.getElementById('progress'), - replayVideo: document.getElementById('replay-video'), - screenshot: document.getElementById('screenshot'), - previewEmpty: document.getElementById('preview-empty'), - previewTab: document.getElementById('preview-tab'), - reportTab: document.getElementById('report-tab'), - previewPane: document.getElementById('preview-pane'), - progressPane: document.getElementById('progress-pane'), - reportPane: document.getElementById('report-pane'), - reportContent: document.getElementById('report-content'), - stepArtifactPreview: document.getElementById('step-artifact-preview'), -}; - -async function api(path, options = {}) { - const response = await fetch(path, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || `${response.status} ${response.statusText}`); - } - return data; -} - -async function refreshAll() { - await refreshRuntime(); - await refreshStatus(); - if (platformRequiresSession()) { - await refreshSetup(); - const sessionReady = await autoCreateSessionIfPossible({ silent: true }); - if (sessionReady) await refreshStatus(); - } -} - -function clearPage() { - if (state.currentRequestId) return; - stopProgressUpdates(); - resetRunModeStates(); - state.replayRequestId = null; - state.previewToken = null; - state.currentRequestId = null; - state.progressSequence = 0; - state.lastProgressSequence = 0; - state.progressDetailOpenState.clear(); - state.currentExecutionMode = null; - state.finishingRun = false; - state.loadedRunId = null; - state.loadedRunAvailability = null; - clearStepArtifactPreview(); - resetLoadRunForm({ collapse: true }); - hideToast(); - - els.goal.value = ''; - els.caseYaml.value = ''; - clearYamlInput(); - clearRecordedYaml(); - clearRunId(); - clearActiveProgressItem(); - clearSelectedProgressItem(); - els.progress.innerHTML = ''; - els.reportContent.textContent = ''; - clearPreview(); - setRunButtonIdle(); - - const goalMode = els.runModeInputs.find((input) => input.value === 'goal'); - if (goalMode) goalMode.checked = true; - updateRunMode(); - showRightTab('preview'); - refreshStatus(); -} - -async function refreshStatus() { - try { - const status = await api('/status'); - setServerStatus(status.busy ? 'Running' : 'Ready', status.busy ? 'running' : 'ready'); - els.deviceSelect.disabled = Boolean(state.currentRequestId || state.finishingRun || status.busy); - if (state.currentRequestId) { - setRunButtonCancel(); - } else if (state.finishingRun) { - setRunButtonCancel({ disabled: true }); - } else { - setRunButtonIdle({ disabled: Boolean(status.busy) }); - } - setLoadRunControlsDisabled(Boolean(status.busy)); - if (platformRequiresSession() && status.session?.connected) { - els.sessionMessage.textContent = `Connected to ${status.session.displayName || status.session.deviceId}`; - } else if (platformRequiresSession()) { - els.sessionMessage.textContent = 'No active session.'; - } - } catch (error) { - setServerStatus(error.message, 'error'); - } -} - -function setServerStatus(text, status) { - els.status.textContent = text; - els.status.className = `status-pill status-${status}`; -} - -async function refreshSetup() { - try { - const setup = await api('/session/setup'); - const options = setup.targets || []; - els.deviceSelect.innerHTML = ''; - for (const target of options) { - const option = document.createElement('option'); - option.value = target.id; - option.textContent = target.description ? `${target.label} (${target.description})` : target.label; - if (target.isDefault || setup.fields?.[0]?.defaultValue === target.id) option.selected = true; - els.deviceSelect.appendChild(option); - } - if (setup.notice) { - els.sessionMessage.textContent = `${setup.notice.message}: ${setup.notice.description || ''}`; - } - } catch (error) { - els.sessionMessage.textContent = error.message; - } -} - -async function refreshRuntime() { - try { - const runtime = await api('/runtime-info'); - state.platformId = runtime.platformId || null; - state.platformLabel = runtime.interface?.type || (runtime.platformId ? capitalize(runtime.platformId) : null); - updateSessionVisibility(); - return runtime; - } catch (error) { - setServerStatus(error.message, 'error'); - return null; - } -} - -function platformRequiresSession() { - return state.platformId === 'android'; -} - -function updateSessionVisibility() { - els.sessionSection.hidden = !platformRequiresSession(); - if (!platformRequiresSession()) els.sessionMessage.textContent = ''; -} - -function capitalize(value) { - return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; -} - -async function autoCreateSessionIfPossible({ silent = false } = {}) { - try { - const session = await api('/session'); - if (session.connected) return true; - const result = await api('/session/auto', { method: 'POST', body: JSON.stringify({}) }); - if (!silent) { - els.sessionMessage.textContent = `Connected to ${result.session.displayName || result.session.deviceId}`; - } else if (result.session?.connected) { - els.sessionMessage.textContent = `Connected to ${result.session.displayName || result.session.deviceId}`; - } - return true; - } catch (error) { - els.sessionMessage.textContent = error.message; - return false; - } -} - -async function ensureSession() { - if (!platformRequiresSession()) return true; - try { - const session = await api('/session'); - if (session.connected) return true; - } catch (error) { - els.sessionMessage.textContent = error.message; - return false; - } - if (await autoCreateSessionIfPossible()) return true; - if (els.deviceSelect.value) { - els.sessionMessage.textContent = 'Automatic session selection is required before running.'; - } - return false; -} - -async function runGoal() { - const goal = els.goal.value.trim(); - if (!goal) return; - await startExecution({ goal }); -} - -async function runYaml() { - const caseYamlPath = els.caseYaml.value.trim(); - if (!caseYamlPath) return; - if (currentRunMode() === 'strict-yaml') { - await startExecution({ strictCaseYamlPath: caseYamlPath }); - return; - } - await startExecution({ caseYamlPath }); -} - -async function runSelected() { - if (state.currentRequestId) { - await cancelExecution(); - return; - } - if (hasUnsavedLifecycleDraft()) { - showYamlView('input'); - setYamlInputStatus('Save or discard lifecycle changes before running.', 'error'); - return; - } - if (currentRunMode() === 'yaml' || currentRunMode() === 'strict-yaml') { - await runYaml(); - return; - } - await runGoal(); -} - -function currentRunMode() { - return els.runModeInputs.find((input) => input.checked)?.value || 'goal'; -} - -function currentModeState(mode = state.activeRunMode) { - return state.modeStates[mode] || state.modeStates.goal; -} - -function resetRunModeStates() { - state.modeStates = { - goal: createRunModeState(), - yaml: createRunModeState(), - 'strict-yaml': createRunModeState(), - }; -} - -function saveRunModeState(mode = state.activeRunMode) { - const modeState = currentModeState(mode); - captureProgressDetailState(); - modeState.caseYamlValue = els.caseYaml.value; - modeState.progressHtml = els.progress.innerHTML; - modeState.progressRunIdText = els.progressRunId.textContent; - modeState.progressRunIdValue = els.progressRunId.dataset.runId || ''; - modeState.progressRunIdHidden = els.progressRunId.hidden; - modeState.progressSequence = state.progressSequence; - modeState.lastProgressSequence = state.lastProgressSequence; - modeState.progressDetailOpenEntries = Array.from(state.progressDetailOpenState.entries()); - modeState.yamlActiveView = state.yamlActiveView; - modeState.yamlInputContent = state.yamlInputContent; - modeState.yamlInputDisplay = state.yamlInputDisplay; - modeState.yamlInputRevision = state.yamlInputRevision; - modeState.yamlInputEditable = state.yamlInputEditable; - modeState.yamlLifecycleSnapshot = cloneLifecycle(state.yamlLifecycleSnapshot); - modeState.yamlLifecycleDraft = cloneLifecycle(state.yamlLifecycleDraft); - modeState.yamlLifecycleDirty = state.yamlLifecycleDirty; - modeState.yamlInputLastPreviewPath = state.yamlInputLastPreviewPath; - modeState.yamlInputStatusText = els.yamlInputStatus.textContent; - modeState.yamlInputStatusClassName = els.yamlInputStatus.className; - modeState.yamlInputStatusHidden = els.yamlInputStatus.hidden; - modeState.yamlInputHtml = els.yamlInputViewer.innerHTML; - modeState.yamlRecordedContent = state.yamlRecordedContent; - modeState.yamlRecordedDisplay = state.yamlRecordedDisplay; - modeState.yamlRecordedStatusText = els.yamlRecordedStatus.textContent; - modeState.yamlRecordedStatusClassName = els.yamlRecordedStatus.className; - modeState.yamlRecordedStatusHidden = els.yamlRecordedStatus.hidden; - modeState.yamlRecordedHtml = els.yamlRecordedViewer.innerHTML; - modeState.rightActiveTab = els.reportTab.classList.contains('active') ? 'report' : 'preview'; - modeState.reportHtml = els.reportContent.innerHTML; - modeState.replayRequestId = state.replayRequestId; - modeState.previewToken = state.previewToken; - modeState.replayVideoSrc = els.replayVideo.getAttribute('src') || ''; - modeState.replayVideoHidden = els.replayVideo.hidden; - modeState.replayVideoDisplay = els.replayVideo.style.display; - modeState.screenshotSrc = els.screenshot.getAttribute('src') || ''; - modeState.screenshotDisplay = els.screenshot.style.display; - modeState.previewEmptyText = els.previewEmpty.textContent; - modeState.previewEmptyDisplay = els.previewEmpty.style.display; - modeState.stepArtifactPreviewActive = state.stepArtifactPreviewActive; - modeState.stepArtifactStepKey = state.stepArtifactStepKey; - modeState.stepArtifactHidden = els.stepArtifactPreview.hidden; - modeState.stepArtifactNodes = Array.from(els.stepArtifactPreview.childNodes); -} - -function restoreRunModeState(mode = state.activeRunMode) { - const modeState = currentModeState(mode); - els.caseYaml.value = modeState.caseYamlValue; - els.progress.innerHTML = modeState.progressHtml; - els.progressRunId.textContent = modeState.progressRunIdText; - if (modeState.progressRunIdValue) { - els.progressRunId.dataset.runId = modeState.progressRunIdValue; - } else { - delete els.progressRunId.dataset.runId; - } - els.progressRunId.hidden = modeState.progressRunIdHidden; - state.progressSequence = modeState.progressSequence; - state.lastProgressSequence = modeState.lastProgressSequence; - state.progressDetailOpenState = new Map(modeState.progressDetailOpenEntries); - state.yamlInputContent = modeState.yamlInputContent; - state.yamlInputDisplay = modeState.yamlInputDisplay; - state.yamlInputRevision = modeState.yamlInputRevision; - state.yamlInputEditable = modeState.yamlInputEditable; - state.yamlLifecycleSnapshot = cloneLifecycle(modeState.yamlLifecycleSnapshot); - state.yamlLifecycleDraft = cloneLifecycle(modeState.yamlLifecycleDraft); - state.yamlLifecycleDirty = modeState.yamlLifecycleDirty; - state.yamlInputLastPreviewPath = modeState.yamlInputLastPreviewPath; - els.yamlInputStatus.textContent = modeState.yamlInputStatusText; - els.yamlInputStatus.className = modeState.yamlInputStatusClassName; - els.yamlInputStatus.hidden = modeState.yamlInputStatusHidden; - renderYamlDisplay(els.yamlInputViewer, state.yamlInputDisplay, 'No YAML loaded.', { lifecycleEditor: state.yamlInputEditable }); - state.yamlRecordedContent = modeState.yamlRecordedContent; - state.yamlRecordedDisplay = modeState.yamlRecordedDisplay; - els.yamlRecordedStatus.textContent = modeState.yamlRecordedStatusText; - els.yamlRecordedStatus.className = modeState.yamlRecordedStatusClassName; - els.yamlRecordedStatus.hidden = modeState.yamlRecordedStatusHidden; - els.yamlRecordedViewer.innerHTML = modeState.yamlRecordedHtml; - state.yamlActiveView = modeState.yamlActiveView || defaultYamlViewForMode(mode); - stripTransientModeClasses(); - bindProgressDetailToggles(); - clearSelectedYamlRegion(); - clearActiveYamlStepCard(); - clearSelectedProgressRunId(); - clearSelectedProgressItem(); - clearActiveProgressItem(); - cancelPendingReplayVideoReadyWait(); - els.replayVideo.pause(); - els.reportContent.innerHTML = modeState.reportHtml; - state.replayRequestId = modeState.replayRequestId; - state.previewToken = modeState.previewToken; - if (modeState.replayVideoSrc) { - els.replayVideo.src = modeState.replayVideoSrc; - } else { - els.replayVideo.removeAttribute('src'); - } - els.replayVideo.hidden = modeState.replayVideoHidden; - els.replayVideo.style.display = modeState.replayVideoDisplay; - if (modeState.screenshotSrc) { - els.screenshot.src = modeState.screenshotSrc; - } else { - els.screenshot.removeAttribute('src'); - } - els.screenshot.style.display = modeState.screenshotDisplay; - els.previewEmpty.textContent = modeState.previewEmptyText; - els.previewEmpty.style.display = modeState.previewEmptyDisplay; - state.stepArtifactPreviewActive = modeState.stepArtifactPreviewActive; - state.stepArtifactStepKey = modeState.stepArtifactStepKey; - els.stepArtifactPreview.hidden = modeState.stepArtifactHidden; - els.stepArtifactPreview.replaceChildren(...modeState.stepArtifactNodes); - showRightTab(modeState.rightActiveTab); - updateLifecycleToolbar(); -} - -function stripTransientModeClasses() { - els.yamlInputViewer.querySelectorAll('.yaml-region-selected, .yaml-step-card-active') - .forEach((element) => element.classList.remove('yaml-region-selected', 'yaml-step-card-active')); - els.yamlRecordedViewer.querySelectorAll('.yaml-region-selected, .yaml-step-card-active') - .forEach((element) => element.classList.remove('yaml-region-selected', 'yaml-step-card-active')); - els.progress.querySelectorAll('.progress-item-selected, .progress-item-active') - .forEach((element) => element.classList.remove('progress-item-selected', 'progress-item-active')); -} - -function bindProgressDetailToggles() { - for (const detail of els.progress.querySelectorAll('.progress-detail[data-detail-key]')) { - detail.addEventListener('toggle', () => { - state.progressDetailOpenState.set(detail.dataset.detailKey, detail.open); - }); - } -} - -function defaultYamlViewForMode(mode) { - if (mode === 'strict-yaml') return 'input'; - return 'progress'; -} - -function switchRunMode() { - if (state.currentRequestId || state.finishingRun) return; - if (state.loadedRunId) { - saveRunModeState(state.activeRunMode); - const mode = currentRunMode(); - state.activeRunMode = mode; - restoreRunModeState(mode); - updateRunMode({ preserveView: true }); - return; - } - saveRunModeState(state.activeRunMode); - state.activeRunMode = currentRunMode(); - restoreRunModeState(state.activeRunMode); - updateRunMode({ preserveView: true }); -} - -function updateRunMode({ preserveView = false } = {}) { - const mode = currentRunMode(); - const hasInputYaml = mode === 'yaml' || mode === 'strict-yaml'; - const loadRunAvailable = mode === 'goal'; - els.goal.hidden = hasInputYaml; - els.yamlPathRow.hidden = !hasInputYaml; - els.loadRunToggle.hidden = !loadRunAvailable; - if (!loadRunAvailable && !els.loadRunForm.hidden) resetLoadRunForm({ collapse: true }); - els.caseYaml.disabled = !hasInputYaml || Boolean(state.currentRequestId); - for (const input of els.runModeInputs) input.disabled = Boolean(state.currentRequestId || state.finishingRun); - els.executionSection.hidden = false; - els.yamlTabs.hidden = false; - els.yamlProgressTab.hidden = false; - syncYamlTabOrder(mode); - const targetView = preserveView ? state.yamlActiveView : defaultYamlViewForMode(mode); - if (loadedRunIsActive()) { - els.yamlInputTab.hidden = true; - els.yamlRecordedTab.hidden = !state.loadedRunAvailability?.recordedYaml; - showYamlView(targetView); - } else if (mode === 'goal') { - els.yamlInputTab.hidden = true; - els.yamlRecordedTab.hidden = false; - showYamlView(targetView); - } else if (mode === 'strict-yaml') { - els.yamlInputTab.hidden = false; - els.yamlRecordedTab.hidden = true; - showYamlView(targetView); - } else { - els.yamlInputTab.hidden = false; - els.yamlRecordedTab.hidden = false; - showYamlView(targetView); - } - if (!state.currentRequestId && !state.finishingRun) setRunButtonIdle(); - setLoadRunControlsDisabled(); - updateLifecycleToolbar(); -} - -function syncYamlTabOrder(mode) { - const tabs = mode === 'goal' || mode === 'yaml' - ? [els.yamlProgressTab, els.yamlInputTab, els.yamlRecordedTab] - : [els.yamlInputTab, els.yamlRecordedTab, els.yamlProgressTab]; - for (const tab of tabs) els.yamlTabs.appendChild(tab); -} - -async function startExecution(payload) { - if (!(await ensureSession())) return; - if (state.loadedRunId) { - state.activeRunMode = currentRunMode(); - restoreRunModeState(state.activeRunMode); - } - state.loadedRunId = null; - state.loadedRunAvailability = null; - resetLoadRunForm({ collapse: true }); - state.activeRunMode = currentRunMode(); - state.currentExecutionMode = payload.strictCaseYamlPath ? 'strict-yaml' : (payload.caseYamlPath ? 'yaml' : 'goal'); - state.progressSequence = 0; - state.lastProgressSequence = 0; - state.progressDetailOpenState.clear(); - state.replayRequestId = null; - clearStepArtifactPreview(); - clearRecordedYaml(); - clearRunId(); - clearActiveProgressItem(); - clearSelectedProgressItem(); - els.progress.innerHTML = ''; - els.reportContent.textContent = 'No report yet.'; - clearPreview('Loading live preview...'); - highlightRunStartSummary(); - els.refresh.disabled = true; - els.deviceSelect.disabled = true; - try { - const result = await api('/execute', { method: 'POST', body: JSON.stringify(payload) }); - state.currentRequestId = result.requestId; - state.finishingRun = false; - state.replayRequestId = result.requestId; - setRunButtonCancel(); - updateRunMode(); - startProgressPolling(); - await refreshStatus(); - } catch (error) { - state.currentExecutionMode = null; - state.finishingRun = false; - els.refresh.disabled = false; - els.deviceSelect.disabled = false; - updateRunMode(); - appendProgress(`Error: ${error.message}`); - } -} - -function highlightRunStartSummary() { - if (state.currentExecutionMode !== 'strict-yaml') return; - const title = els.yamlInputViewer.querySelector('.yaml-case-title-row'); - if (title) { - selectYamlRegion(title); - return; - } - const summary = els.yamlInputViewer.querySelector('.yaml-case-summary'); - if (summary) selectYamlRegion(summary); -} - -async function cancelExecution() { - const requestId = state.currentRequestId; - if (!requestId) return; - setRunButtonCancel({ disabled: true }); - try { - const progress = await api(`/cancel/${encodeURIComponent(requestId)}`, { method: 'POST', body: JSON.stringify({}) }); - stopProgressUpdates(); - appendProgress('Cancelled by user', null, [], 'failed'); - state.currentRequestId = null; - state.currentExecutionMode = null; - state.finishingRun = false; - state.replayRequestId = progress.result?.runId || progress.runId || state.replayRequestId; - setRunButtonIdle(); - updateRunMode(); - await refreshStatus(); - } catch (error) { - appendProgress(`Cancel error: ${error.message}`, null, [], 'failed'); - setRunButtonCancel(); - } -} - -function setRunButtonCancel({ disabled = false } = {}) { - els.runSelected.textContent = 'Cancel'; - els.runSelected.classList.remove('primary'); - els.runSelected.classList.add('secondary-button'); - els.runSelected.disabled = disabled; -} - -function setRunButtonIdle({ disabled = false } = {}) { - els.runSelected.textContent = 'Run'; - els.runSelected.classList.add('primary'); - els.runSelected.classList.remove('secondary-button'); - els.runSelected.disabled = disabled; -} - -function startProgressPolling() { - stopProgressUpdates(); - if (window.EventSource) { - const requestId = state.currentRequestId; - const stream = new EventSource(`/task-stream/${encodeURIComponent(requestId)}`); - state.progressStream = stream; - stream.onmessage = (event) => { - try { - applyProgress(JSON.parse(event.data)); - } catch (error) { - appendProgress(`Progress error: ${error.message}`, null, [], 'failed'); - } - }; - stream.onerror = () => { - if (!state.currentRequestId) return; - stopProgressUpdates(); - state.progressTimer = window.setInterval(refreshProgress, PROGRESS_POLL_INTERVAL_MS); - }; - return; - } - state.progressTimer = window.setInterval(refreshProgress, PROGRESS_POLL_INTERVAL_MS); - refreshProgress(); -} - -function stopProgressUpdates() { - if (state.progressTimer) { - window.clearInterval(state.progressTimer); - state.progressTimer = null; - } - if (state.progressStream) { - state.progressStream.close(); - state.progressStream = null; - } -} - -async function refreshProgress() { - if (!state.currentRequestId) return; - try { - await applyProgress(await api(progressPath(state.currentRequestId))); - } catch (error) { - appendProgress(`Progress error: ${error.message}`, null, [], 'failed'); - } -} - -async function applyProgress(progress) { - if (!state.currentRequestId) return; - for (const event of progress.events || []) { - if (event.type === 'run_started') setRunId(event.run_id || event.runId); - appendProgress(eventLabel(event), event.sequence, eventDetails(event), eventStatus(event), event); - updateLastProgressSequence(event.sequence); - } - if (progress.preview?.token && progress.preview.token !== state.previewToken) { - await refreshPreview(progress.requestId, progress.preview.token); - } - syncYamlStepWithActiveStep(progress.activeStep || null); - if (progress.status !== 'running') { - stopProgressUpdates(); - state.currentRequestId = null; - state.finishingRun = true; - setRunButtonCancel({ disabled: true }); - updateLifecycleToolbar(); - scheduleClearActiveYamlStepCard(); - appendProgress(`Finished: ${progress.status}`, null, [], statusFromValue(progress.status)); - if (progress.error) appendProgress(`Error: ${progress.error}`, null, [], 'failed'); - if (progress.result?.runId) { - setRunId(progress.result.runId); - state.replayRequestId = progress.result.runId; - await loadReport(progress.result.runId); - await loadRecordedYaml(progress.result.runId, progress.result.recording || null); - await refreshPreviewFromReplay(progress.result.runId); - } - if (progress.result?.runId && state.replayRequestId && progress.status !== 'cancelled') { - try { - const replay = await loadReplayFrames(state.replayRequestId); - appendReplayFramesProgress(replay.frames); - appendReplayVideoGeneratingProgress(); - const replayVideo = await ensureReplayVideoGenerated(state.replayRequestId, replay.frames); - if (replayVideo?.videoUrl) { - appendProgress('Replay video saved', null, [], 'success'); - await showReplayVideoPreview(replayVideo.videoUrl); - showRightTab('preview'); - } else { - appendProgress(`Replay video was not generated: ${replayVideo?.error || 'unknown error'}`, null, [], 'failed'); - } - } catch (error) { - appendProgress(`Replay video was not generated: ${error.message}`, null, [], 'failed'); - } - } - scheduleClearActiveProgressItem(); - clearSelectedYamlRegion(); - setRunButtonIdle(); - els.refresh.disabled = false; - els.deviceSelect.disabled = false; - state.finishingRun = false; - state.currentExecutionMode = null; - updateRunMode({ preserveView: true }); - saveRunModeState(state.activeRunMode); - await refreshStatus(); - await refreshRuntime(); - } -} - -function progressPath(requestId) { - const encoded = encodeURIComponent(requestId); - if (state.lastProgressSequence <= 0) return `/task-progress/${encoded}`; - return `/task-progress/${encoded}?after_sequence=${state.lastProgressSequence}`; -} - -function updateLastProgressSequence(sequence) { - if (Number.isInteger(sequence) && sequence > state.lastProgressSequence) { - state.lastProgressSequence = sequence; - } -} - -async function refreshPreview(requestId, token) { - try { - const preview = await api(`/preview/${encodeURIComponent(requestId)}`); - const src = `data:image/png;base64,${preview.screenshot}`; - await preloadImage(src); - clearStepArtifactPreview(); - state.previewToken = token; - els.replayVideo.hidden = true; - els.replayVideo.style.display = 'none'; - els.screenshot.src = src; - els.screenshot.style.display = 'block'; - els.previewEmpty.style.display = 'none'; - } catch { - // Preview artifacts are best-effort while strict execution is still writing evidence. - } -} - -function setRunId(runId) { - if (!runId) return; - els.progressRunId.textContent = `Run ID: ${runId}`; - els.progressRunId.dataset.runId = runId; - els.progressRunId.hidden = false; -} - -function clearRunId() { - els.progressRunId.textContent = ''; - delete els.progressRunId.dataset.runId; - els.progressRunId.hidden = true; - clearSelectedProgressRunId(); -} - -async function loadReport(runId) { - try { - els.reportContent.textContent = 'Loading report...'; - const report = await api(`/reports/${encodeURIComponent(runId)}?format=markdown`); - els.reportContent.innerHTML = renderMarkdown(report.content || 'Report is empty.'); - } catch (error) { - els.reportContent.textContent = `Unable to load report: ${error.message}`; - } -} - -async function loadInputYaml() { - const path = els.caseYaml.value.trim(); - showYamlView('input'); - if (!path) { - clearYamlInput(); - setYamlInputStatus('YAML path is required.', 'error'); - return; - } - if (state.yamlLifecycleDirty) { - if (!window.confirm('Discard unsaved lifecycle changes and reload YAML?')) { - els.caseYaml.value = state.yamlInputLastPreviewPath; - return; - } - } - setYamlInputStatus('Loading YAML...', 'neutral'); - try { - const yaml = await api(`/yaml/input?path=${encodeURIComponent(path)}`); - state.yamlInputContent = yaml.content || ''; - state.yamlInputDisplay = yaml.display || null; - state.yamlInputRevision = yaml.revision || ''; - state.yamlInputEditable = yaml.editable === true; - state.yamlLifecycleSnapshot = cloneLifecycle(yaml.display?.lifecycle || emptyLifecycle()); - state.yamlLifecycleDraft = cloneLifecycle(state.yamlLifecycleSnapshot); - state.yamlLifecycleDirty = false; - state.yamlInputLastPreviewPath = path; - renderYamlDisplay(els.yamlInputViewer, state.yamlInputDisplay, 'YAML file is empty.', { lifecycleEditor: state.yamlInputEditable }); - updateLifecycleToolbar(); - setYamlInputStatus('', 'success'); - } catch (error) { - state.yamlInputContent = ''; - state.yamlInputDisplay = null; - resetLifecycleState(); - renderYamlEmpty(els.yamlInputViewer, ''); - setYamlInputStatus(error.message, 'error'); - } -} - -async function loadRecordedYaml(runId, recording, { existingRun = false } = {}) { - if (!existingRun && state.currentExecutionMode === 'strict-yaml') { - clearRecordedYaml(); - updateRunMode(); - return; - } - if (!recording) { - clearRecordedYaml(); - return; - } - setRecordedYamlNoContent('Loading generated YAML...', 'neutral'); - els.executionSection.hidden = false; - try { - const yaml = await api(`/yaml/recorded/${encodeURIComponent(runId)}`); - state.yamlRecordedContent = yaml.content || ''; - state.yamlRecordedDisplay = yaml.display || null; - if (yaml.content) { - renderYamlDisplay(els.yamlRecordedViewer, state.yamlRecordedDisplay, 'Generated YAML is empty.'); - } else { - renderYamlEmpty(els.yamlRecordedViewer, recordingStatusDetails(yaml)); - } - setYamlRecordedStatus('', 'neutral'); - els.executionSection.hidden = false; - } catch (error) { - state.yamlRecordedContent = ''; - state.yamlRecordedDisplay = null; - renderYamlEmpty(els.yamlRecordedViewer, error.message); - setYamlRecordedStatus(error.message, 'error'); - els.executionSection.hidden = false; - } -} - -function clearYamlInput(message = 'No YAML loaded.') { - state.yamlInputContent = ''; - state.yamlInputDisplay = null; - state.yamlInputLastPreviewPath = ''; - resetLifecycleState(); - setYamlInputStatus(message === 'No YAML loaded.' ? '' : message, 'neutral'); - renderYamlEmpty(els.yamlInputViewer, message); -} - -function clearRecordedYaml() { - state.yamlRecordedContent = ''; - state.yamlRecordedDisplay = null; - setYamlRecordedStatus('', 'neutral'); - renderYamlEmpty(els.yamlRecordedViewer, 'No generated YAML yet.'); -} - -function toggleLoadRunForm() { - if (currentRunMode() !== 'goal' || state.currentRequestId || state.finishingRun || state.loadRunInFlight) return; - const opening = els.loadRunForm.hidden; - els.loadRunForm.hidden = !opening; - els.loadRunToggle.setAttribute('aria-expanded', String(opening)); - if (opening) { - setLoadRunStatus('', 'neutral'); - els.loadRunPath.focus(); - } -} - -function resetLoadRunForm({ collapse = false } = {}) { - state.loadRunInFlight = false; - els.loadRunPath.value = ''; - setLoadRunStatus('', 'neutral'); - if (collapse) { - els.loadRunForm.hidden = true; - els.loadRunToggle.setAttribute('aria-expanded', 'false'); - } - setLoadRunControlsDisabled(); -} - -function setLoadRunStatus(message, status = 'neutral') { - setYamlStatus(els.loadRunStatus, message, status); -} - -function setLoadRunControlsDisabled(serverBusy = false) { - const disabled = Boolean(serverBusy || state.currentRequestId || state.finishingRun || state.loadRunInFlight); - els.loadRunToggle.disabled = disabled; - els.loadRunPath.disabled = disabled; - els.loadRunSubmit.disabled = disabled; - els.loadRunCancel.disabled = disabled; -} - -async function loadExistingRun() { - if (currentRunMode() !== 'goal' || state.currentRequestId || state.finishingRun || state.loadRunInFlight) return; - if (hasUnsavedLifecycleDraft()) { - setLoadRunStatus('Save or discard lifecycle changes before loading a run.', 'error'); - return; - } - const path = els.loadRunPath.value.trim(); - if (!path) { - setLoadRunStatus('Run directory path is required.', 'error'); - return; - } - state.loadRunInFlight = true; - setLoadRunStatus('Loading run...', 'neutral'); - setLoadRunControlsDisabled(); - try { - const result = await api('/runs/load', { method: 'POST', body: JSON.stringify({ path }) }); - await activateLoadedRun(result.runId, result.availability || {}); - resetLoadRunForm({ collapse: true }); - } catch (error) { - state.loadRunInFlight = false; - setLoadRunStatus(error.message, 'error'); - setLoadRunControlsDisabled(); - } -} - -async function activateLoadedRun(runId, availability) { - stopProgressUpdates(); - saveRunModeState(state.activeRunMode); - state.loadedRunId = runId; - state.loadedRunAvailability = availability; - state.replayRequestId = runId; - state.previewToken = null; - state.currentExecutionMode = null; - state.progressSequence = 0; - state.lastProgressSequence = 0; - state.progressDetailOpenState.clear(); - clearStepArtifactPreview(); - clearYamlInput(); - clearRecordedYaml(); - clearRunId(); - clearActiveProgressItem(); - clearSelectedProgressItem(); - clearSelectedYamlRegion(); - els.progress.innerHTML = ''; - setRunId(runId); - await loadExistingRunProgress(runId); - updateRunMode({ preserveView: true }); - showYamlView('progress'); - if (availability.report) { - await loadReport(runId); - } else { - els.reportContent.textContent = 'No report is available for this run.'; - } - if (availability.recordedYaml) { - await loadRecordedYaml(runId, availability.recordedYaml, { existingRun: true }); - } else { - setRecordedYamlNoContent('No generated YAML is available for this run.', 'neutral'); - } - if (availability.replay) { - await showCompletedRunReplayPreview(runId); - } else { - clearPreview('No replay video is available for this run.'); - } - showRightTab('preview'); - saveRunModeState('goal'); -} - -function loadedRunIsActive() { - return Boolean(state.loadedRunId && currentRunMode() === 'goal'); -} - -async function loadExistingRunProgress(runId) { - els.progress.innerHTML = ''; - state.progressSequence = 0; - state.lastProgressSequence = 0; - state.progressDetailOpenState.clear(); - try { - const progress = await api(`/runs/${encodeURIComponent(runId)}/progress`); - const events = Array.isArray(progress.events) ? progress.events : []; - for (const event of events) { - appendProgress(eventLabel(event), event.sequence, eventDetails(event), eventStatus(event), event); - updateLastProgressSequence(event.sequence); - } - if (events.length === 0) { - els.progress.textContent = 'No persisted progress events are available for this run.'; - } - } catch (error) { - els.progress.textContent = `Unable to load progress: ${error.message}`; - } -} - -function setRecordedYamlNoContent(message, status = 'neutral') { - state.yamlRecordedContent = ''; - state.yamlRecordedDisplay = null; - setYamlRecordedStatus(message, status); - renderYamlEmpty(els.yamlRecordedViewer, message); -} - -function setYamlInputStatus(message, status) { - setYamlStatus(els.yamlInputStatus, message, status); -} - -function setYamlRecordedStatus(message, status) { - setYamlStatus(els.yamlRecordedStatus, message, status); -} - -function setYamlStatus(element, message, status) { - element.hidden = !message; - element.textContent = message; - element.className = `yaml-status yaml-status-${status || 'neutral'}`; -} - -function showYamlView(viewName) { - const inputAvailable = !els.yamlInputTab.hidden; - const recordedAvailable = !els.yamlRecordedTab.hidden; - const progressAvailable = !els.yamlProgressTab.hidden; - let selectedView = inputAvailable ? 'input' : (recordedAvailable ? 'recorded' : 'progress'); - if (viewName === 'recorded' && recordedAvailable) selectedView = 'recorded'; - if (viewName === 'progress' && progressAvailable) selectedView = 'progress'; - if (viewName === 'input' && inputAvailable) selectedView = 'input'; - const showInput = selectedView === 'input'; - const showRecorded = selectedView === 'recorded'; - const showProgress = selectedView === 'progress'; - state.yamlActiveView = selectedView; - els.yamlInputPane.hidden = !showInput; - els.yamlRecordedPane.hidden = !showRecorded; - els.progressPane.hidden = !showProgress; - els.yamlInputTab.classList.toggle('active', inputAvailable && showInput); - els.yamlRecordedTab.classList.toggle('active', recordedAvailable && showRecorded); - els.yamlProgressTab.classList.toggle('active', progressAvailable && showProgress); - els.yamlInputTab.setAttribute('aria-selected', String(inputAvailable && showInput)); - els.yamlRecordedTab.setAttribute('aria-selected', String(recordedAvailable && showRecorded)); - els.yamlProgressTab.setAttribute('aria-selected', String(progressAvailable && showProgress)); -} - -function renderYamlDisplay(root, display, emptyMessage, { lifecycleEditor = false } = {}) { - clearSelectedYamlRegion(root); - root.innerHTML = ''; - if (!display) { - renderYamlEmpty(root, emptyMessage); - return; - } - const fragment = document.createDocumentFragment(); - fragment.appendChild(renderYamlCaseTitle(display.metadata || {})); - fragment.appendChild(renderYamlCaseSummary(display.metadata || {})); - if (lifecycleEditor) fragment.appendChild(renderLifecycleSection('onCaseStart', 'Before case')); - fragment.appendChild(renderYamlCaseSteps(display.steps || [])); - if (lifecycleEditor) fragment.appendChild(renderLifecycleSection('onCaseComplete', 'After case')); - root.appendChild(fragment); -} - -function renderYamlCaseSteps(steps) { - const section = document.createElement('section'); - section.className = 'yaml-case-steps-section'; - const heading = document.createElement('div'); - heading.className = 'yaml-lifecycle-heading yaml-case-steps-heading'; - const title = document.createElement('h3'); - title.textContent = 'Case steps'; - heading.appendChild(title); - section.append(heading, renderYamlSteps(steps)); - return section; -} - -function emptyLifecycle() { - return LifecycleEditorModel.empty(); -} - -function cloneLifecycle(value) { - return LifecycleEditorModel.clone(value); -} - -function resetLifecycleState() { - state.yamlInputRevision = ''; - state.yamlInputEditable = false; - state.yamlLifecycleSnapshot = null; - state.yamlLifecycleDraft = null; - state.yamlLifecycleDirty = false; - state.yamlLifecycleSaving = false; - updateLifecycleToolbar(); -} - -function lifecycleActions(field) { - return LifecycleEditorModel.actions(state.yamlLifecycleDraft, field); -} - -function renderLifecycleSection(field, label) { - const section = document.createElement('section'); - section.className = 'yaml-lifecycle-section'; - section.dataset.lifecycleField = field; - const heading = document.createElement('div'); - heading.className = 'yaml-lifecycle-heading'; - const title = document.createElement('h3'); - title.textContent = label; - const add = iconButton('+', `Add ${label} action`, () => addLifecycleAction(field)); - heading.append(title, add); - section.appendChild(heading); - const actions = lifecycleActions(field); - if (!actions.length) { - const empty = document.createElement('div'); - empty.className = 'yaml-lifecycle-empty'; - empty.textContent = 'No actions.'; - section.appendChild(empty); - return section; - } - const list = document.createElement('div'); - list.className = 'yaml-lifecycle-list'; - actions.forEach((action, actionIndex) => list.appendChild(renderLifecycleAction(field, action, actionIndex))); - section.appendChild(list); - return section; -} - -function renderLifecycleAction(field, action, actionIndex) { - const row = document.createElement('div'); - row.className = 'yaml-lifecycle-action-row'; - const number = document.createElement('span'); - number.textContent = String(actionIndex + 1).padStart(2, '0'); - number.className = 'yaml-step-index'; - const select = document.createElement('select'); - select.setAttribute('aria-label', 'Lifecycle action type'); - for (const type of ['runCase', 'runShell']) { - const option = document.createElement('option'); - option.value = type; - option.textContent = type; - option.selected = action.action === type; - select.appendChild(option); - } - select.addEventListener('change', () => updateLifecycleAction(field, actionIndex, 'action', select.value)); - const input = document.createElement('input'); - input.type = 'text'; - input.value = action.value || ''; - input.placeholder = action.action === 'runShell' ? 'Shell command...' : 'Case path...'; - input.setAttribute('aria-label', `${action.action || 'Lifecycle'} value`); - input.addEventListener('input', () => updateLifecycleAction(field, actionIndex, 'value', input.value)); - const controls = document.createElement('div'); - controls.className = 'yaml-lifecycle-action-controls'; - controls.append( - iconButton('↑', 'Move action up', () => moveLifecycleAction(field, actionIndex, -1), actionIndex === 0), - iconButton('↓', 'Move action down', () => moveLifecycleAction(field, actionIndex, 1), actionIndex === lifecycleActions(field).length - 1), - iconButton('×', 'Delete action', () => deleteLifecycleAction(field, actionIndex)), - ); - row.append(number, select, input, controls); - return row; -} - -function iconButton(symbol, label, handler, disabled = false) { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'yaml-lifecycle-icon-button'; - button.textContent = symbol; - button.title = label; - button.setAttribute('aria-label', label); - button.disabled = disabled; - button.addEventListener('click', handler); - return button; -} - -function markLifecycleDirty() { - state.yamlLifecycleDirty = JSON.stringify(state.yamlLifecycleDraft) !== JSON.stringify(state.yamlLifecycleSnapshot); - updateLifecycleToolbar(); -} - -function hasUnsavedLifecycleDraft() { - if (state.yamlLifecycleDirty) return true; - return Object.values(state.modeStates).some((modeState) => modeState.yamlLifecycleDirty); -} - -function rerenderLifecycleEditor() { - renderYamlDisplay(els.yamlInputViewer, state.yamlInputDisplay, 'YAML file is empty.', { lifecycleEditor: true }); - markLifecycleDirty(); -} - -function addLifecycleAction(field, action = 'runCase') { - if (lifecycleEditorIsDisabled()) return; - state.yamlLifecycleDraft = LifecycleEditorModel.addAction(state.yamlLifecycleDraft, field, action); - rerenderLifecycleEditor(); -} - -function updateLifecycleAction(field, actionIndex, key, value) { - if (lifecycleEditorIsDisabled()) return; - state.yamlLifecycleDraft = LifecycleEditorModel.updateAction( - state.yamlLifecycleDraft, field, actionIndex, key, value, - ); - if (!lifecycleValidationError()) setYamlInputStatus('', 'neutral'); - markLifecycleDirty(); -} - -function deleteLifecycleAction(field, actionIndex) { - if (lifecycleEditorIsDisabled()) return; - state.yamlLifecycleDraft = LifecycleEditorModel.deleteAction( - state.yamlLifecycleDraft, field, actionIndex, - ); - rerenderLifecycleEditor(); -} - -function moveLifecycleAction(field, actionIndex, delta) { - if (lifecycleEditorIsDisabled()) return; - state.yamlLifecycleDraft = LifecycleEditorModel.moveAction( - state.yamlLifecycleDraft, field, actionIndex, delta, - ); - rerenderLifecycleEditor(); -} - -function lifecycleValidationError() { - return LifecycleEditorModel.validationError(state.yamlLifecycleDraft); -} - -function updateLifecycleToolbar() { - if (!els.yamlLifecycleToolbar) return; - const available = state.yamlInputEditable && !loadedRunIsActive(); - const editorDisabled = Boolean(state.currentRequestId || state.finishingRun || state.yamlLifecycleSaving); - els.yamlLifecycleToolbar.hidden = !available; - els.yamlLifecycleDirty.hidden = !state.yamlLifecycleDirty; - els.yamlLifecycleSave.disabled = !available || !state.yamlLifecycleDirty || editorDisabled; - els.yamlLifecycleDiscard.disabled = !available || !state.yamlLifecycleDirty || editorDisabled; - setLifecycleEditorDisabled(!available || editorDisabled); -} - -function lifecycleEditorIsDisabled() { - return Boolean(state.currentRequestId || state.finishingRun || state.yamlLifecycleSaving); -} - -function setLifecycleEditorDisabled(disabled) { - for (const control of els.yamlInputViewer.querySelectorAll( - '.yaml-lifecycle-section select, .yaml-lifecycle-section input, .yaml-lifecycle-section button', - )) { - if (disabled) { - if (!Object.hasOwn(control.dataset, 'lifecycleWasDisabled')) { - control.dataset.lifecycleWasDisabled = String(control.disabled); - } - control.disabled = true; - } else { - const wasDisabled = control.dataset.lifecycleWasDisabled === 'true'; - control.disabled = wasDisabled; - delete control.dataset.lifecycleWasDisabled; - } - } -} - -async function saveLifecycleDraft() { - const validationError = lifecycleValidationError(); - if (validationError) { - setYamlInputStatus(validationError, 'error'); - return; - } - state.yamlLifecycleSaving = true; - updateLifecycleToolbar(); - setYamlInputStatus('Saving lifecycle hooks...', 'neutral'); - try { - const payload = await api('/yaml/input/lifecycle', { - method: 'PUT', - body: JSON.stringify({ - path: state.yamlInputLastPreviewPath, - revision: state.yamlInputRevision, - onCaseStart: lifecycleActions('onCaseStart').map(({ action, value }) => ({ action, value })), - onCaseComplete: lifecycleActions('onCaseComplete').map(({ action, value }) => ({ action, value })), - }), - }); - state.yamlInputContent = payload.content || ''; - state.yamlInputDisplay = payload.display || null; - state.yamlInputRevision = payload.revision || ''; - state.yamlLifecycleSnapshot = cloneLifecycle(payload.display?.lifecycle || emptyLifecycle()); - state.yamlLifecycleDraft = cloneLifecycle(state.yamlLifecycleSnapshot); - state.yamlLifecycleDirty = false; - renderYamlDisplay(els.yamlInputViewer, state.yamlInputDisplay, 'YAML file is empty.', { lifecycleEditor: true }); - setYamlInputStatus('', 'neutral'); - showToast('Lifecycle hooks saved.'); - } catch (error) { - setYamlInputStatus(error.message, 'error'); - } finally { - state.yamlLifecycleSaving = false; - updateLifecycleToolbar(); - } -} - -function showToast(message, durationMs = 2000) { - if (state.toastTimer) window.clearTimeout(state.toastTimer); - els.toast.textContent = message; - els.toast.hidden = false; - els.toast.classList.add('toast-visible'); - state.toastTimer = window.setTimeout(() => hideToast(), durationMs); -} - -function hideToast() { - if (state.toastTimer) window.clearTimeout(state.toastTimer); - state.toastTimer = null; - els.toast.classList.remove('toast-visible'); - els.toast.hidden = true; - els.toast.textContent = ''; -} - -function discardLifecycleDraft() { - state.yamlLifecycleDraft = cloneLifecycle(state.yamlLifecycleSnapshot); - state.yamlLifecycleDirty = false; - renderYamlDisplay(els.yamlInputViewer, state.yamlInputDisplay, 'YAML file is empty.', { lifecycleEditor: true }); - setYamlInputStatus('', 'neutral'); - updateLifecycleToolbar(); -} - -function renderYamlCaseTitle(metadata) { - const titleRow = document.createElement('div'); - titleRow.className = 'yaml-case-title-row'; - const title = document.createElement('div'); - title.className = 'yaml-case-title'; - title.textContent = metadata.title || 'Untitled case'; - title.title = title.textContent; - titleRow.appendChild(title); - return titleRow; -} - -function renderYamlCaseSummary(metadata) { - const summary = document.createElement('div'); - summary.className = 'yaml-case-summary'; - - if (Array.isArray(metadata.tags) && metadata.tags.length > 0) { - const tags = document.createElement('div'); - tags.className = 'yaml-tags'; - for (const tag of metadata.tags) { - const chip = document.createElement('span'); - chip.className = 'yaml-chip yaml-chip-muted'; - chip.textContent = String(tag); - tags.appendChild(chip); - } - summary.appendChild(tags); - } - - const fields = Array.isArray(metadata.fields) ? metadata.fields : []; - if (fields.length > 0) { - const grid = document.createElement('div'); - grid.className = 'yaml-metadata-grid'; - for (const field of fields) { - const item = document.createElement('div'); - item.className = 'yaml-metadata-item'; - const label = document.createElement('span'); - label.className = 'yaml-metadata-label'; - label.textContent = field.label || field.key || 'Field'; - const value = document.createElement('span'); - value.className = 'yaml-metadata-value'; - value.textContent = formatYamlValue(field.value); - item.appendChild(label); - item.appendChild(value); - grid.appendChild(item); - } - summary.appendChild(grid); - } - return summary; -} - -function renderYamlSteps(steps) { - const container = document.createElement('div'); - container.className = 'yaml-step-list'; - if (!steps.length) { - const empty = document.createElement('div'); - empty.className = 'yaml-empty'; - empty.textContent = 'No YAML steps.'; - container.appendChild(empty); - return container; - } - for (const step of steps) { - container.appendChild(renderYamlStep(step)); - } - return container; -} - -function renderYamlStep(step) { - const card = document.createElement('div'); - card.className = 'yaml-step-card'; - card.dataset.yamlStepIndex = String(step.index || ''); - if (step.artifactStepId) card.dataset.yamlStepId = String(step.artifactStepId); - card.dataset.yamlAction = step.action || 'command'; - card.dataset.yamlActionKey = normalizeYamlActionName(step.action || 'command'); - const header = document.createElement('div'); - header.className = 'yaml-step-header'; - - const index = document.createElement('span'); - index.className = 'yaml-step-index'; - index.textContent = String(step.index || '?').padStart(2, '0'); - header.appendChild(index); - - const action = document.createElement('span'); - const actionKind = step.kind || 'action'; - action.className = `yaml-action-name yaml-action-name-${actionKind}`; - action.textContent = step.action || 'command'; - action.title = actionKind; - header.appendChild(action); - - for (const badge of step.badges || []) { - const chip = document.createElement('span'); - chip.className = 'yaml-chip yaml-chip-muted'; - chip.textContent = badge.label || String(badge); - header.appendChild(chip); - } - - card.appendChild(header); - if ((step.params || []).length > 0) { - card.appendChild(renderYamlParams(step.params || [])); - } - return card; -} - -function renderYamlParams(params) { - const list = document.createElement('div'); - list.className = 'yaml-param-list'; - for (const param of params) { - const row = document.createElement('div'); - row.className = 'yaml-param-row'; - const key = document.createElement('span'); - key.className = 'yaml-param-key'; - key.textContent = param.key || 'value'; - const value = document.createElement('span'); - value.className = `yaml-param-value yaml-param-value-${param.kind || 'scalar'}`; - value.textContent = yamlParamDisplayValue(param); - row.appendChild(key); - row.appendChild(value); - list.appendChild(row); - if (Array.isArray(param.fields) && param.fields.length > 0) { - list.appendChild(renderYamlNestedParams(param.fields)); - } - } - return list; -} - -function renderYamlNestedParams(params) { - const nested = document.createElement('div'); - nested.className = 'yaml-param-nested'; - for (const param of params) { - const row = document.createElement('div'); - row.className = 'yaml-param-row yaml-param-row-nested'; - const key = document.createElement('span'); - key.className = 'yaml-param-key'; - key.textContent = param.key || 'value'; - const value = document.createElement('span'); - value.className = `yaml-param-value yaml-param-value-${param.kind || 'scalar'}`; - value.textContent = yamlParamDisplayValue(param); - row.appendChild(key); - row.appendChild(value); - nested.appendChild(row); - if (Array.isArray(param.fields) && param.fields.length > 0) { - nested.appendChild(renderYamlNestedParams(param.fields)); - } - } - return nested; -} - -function yamlParamDisplayValue(param) { - if (param.kind === 'secret') return `runtimeSecret: ${param.value}`; - if ((param.kind === 'object' || param.kind === 'list') && Array.isArray(param.fields)) return ''; - return formatYamlValue(param.value); -} - -function renderYamlEmpty(root, message) { - clearSelectedYamlRegion(root); - root.innerHTML = ''; - if (!message) return; - const empty = document.createElement('div'); - empty.className = 'yaml-empty'; - empty.textContent = message; - root.appendChild(empty); -} - -function formatYamlValue(value) { - if (value === null || value === undefined) return ''; - if (Array.isArray(value)) return value.join(', '); - if (typeof value === 'object') return JSON.stringify(value); - return String(value); -} - -function normalizeYamlActionName(value) { - return String(value || '').replace(/[^a-z0-9]/gi, '').toLowerCase(); -} - -function syncYamlStepWithActiveStep(activeStep) { - if (state.currentExecutionMode !== 'strict-yaml' || !activeStep) return; - const stepIndex = Number(activeStep.stepIndex ?? activeStep.step_index); - if (!Number.isInteger(stepIndex) || stepIndex <= 0) return; - const stepCard = Array.from(els.yamlInputViewer.querySelectorAll('.yaml-step-card')) - .find((card) => Number(card.dataset.yamlStepIndex) === stepIndex); - if (!stepCard) return; - activateYamlStepCard(stepCard); -} - -function activateYamlStepCard(stepCard) { - cancelActiveYamlStepClearTimer(); - if (state.activeYamlStepCard === stepCard) { - centerYamlStepCard(stepCard); - return; - } - clearActiveYamlStepCard(); - state.activeYamlStepCard = stepCard; - stepCard.classList.add('yaml-step-card-active'); - centerYamlStepCard(stepCard); -} - -function clearActiveYamlStepCard(root = null) { - if (!state.activeYamlStepCard) return; - if (root && !root.contains(state.activeYamlStepCard)) return; - cancelActiveYamlStepClearTimer(); - state.activeYamlStepCard.classList.remove('yaml-step-card-active'); - state.activeYamlStepCard = null; -} - -function centerYamlStepCard(stepCard) { - const viewer = stepCard.closest('.yaml-viewer'); - if (!viewer || !stepCard.getClientRects().length) return; - const viewerRect = viewer.getBoundingClientRect(); - const stepRect = stepCard.getBoundingClientRect(); - const viewerCenter = viewerRect.top + viewerRect.height / 2; - const stepCenter = stepRect.top + stepRect.height / 2; - const tolerance = Math.max(24, viewerRect.height * YAML_STEP_CENTER_TOLERANCE_RATIO); - if (Math.abs(stepCenter - viewerCenter) <= tolerance) return; - stepCard.scrollIntoView({ block: 'center', behavior: 'smooth' }); -} - -function scheduleClearActiveYamlStepCard(delayMs = 900) { - cancelActiveYamlStepClearTimer(); - state.activeYamlStepClearTimer = window.setTimeout(() => { - state.activeYamlStepClearTimer = null; - if (!state.activeYamlStepCard) return; - state.activeYamlStepCard.classList.remove('yaml-step-card-active'); - state.activeYamlStepCard = null; - }, delayMs); -} - -function cancelActiveYamlStepClearTimer() { - if (!state.activeYamlStepClearTimer) return; - clearTimeout(state.activeYamlStepClearTimer); - state.activeYamlStepClearTimer = null; -} - -function clearSelectedYamlRegion(root = null) { - if (root && state.selectedYamlRegion && !root.contains(state.selectedYamlRegion)) return; - if (state.selectedYamlRegion) { - state.selectedYamlRegion.classList.remove('yaml-region-selected'); - } - if (state.selectedYamlStepCard && state.selectedYamlStepCard !== state.selectedYamlRegion) { - state.selectedYamlStepCard.classList.remove('yaml-region-selected'); - } - if (state.selectedYamlCaseSummary && state.selectedYamlCaseSummary !== state.selectedYamlRegion) { - state.selectedYamlCaseSummary.classList.remove('yaml-region-selected'); - } - if (state.selectedYamlCaseTitle && state.selectedYamlCaseTitle !== state.selectedYamlRegion) { - state.selectedYamlCaseTitle.classList.remove('yaml-region-selected'); - } - state.selectedYamlRegion = null; - state.selectedYamlStepCard = null; - state.selectedYamlCaseSummary = null; - state.selectedYamlCaseTitle = null; -} - -function selectYamlRegion(region) { - if (state.selectedYamlRegion === region) return; - clearSelectedYamlRegion(); - const stepCard = region.closest('.yaml-step-card'); - const caseTitle = region.closest('.yaml-case-title-row'); - const caseSummary = region.closest('.yaml-case-summary') - || (caseTitle?.nextElementSibling?.classList.contains('yaml-case-summary') ? caseTitle.nextElementSibling : null); - state.selectedYamlRegion = region; - state.selectedYamlStepCard = stepCard; - state.selectedYamlCaseSummary = caseSummary; - state.selectedYamlCaseTitle = caseTitle || (caseSummary?.previousElementSibling?.classList.contains('yaml-case-title-row') ? caseSummary.previousElementSibling : null); - region.classList.add('yaml-region-selected'); - if (stepCard && stepCard !== region) stepCard.classList.add('yaml-region-selected'); - if (caseSummary && caseSummary !== region) caseSummary.classList.add('yaml-region-selected'); - if (state.selectedYamlCaseTitle && state.selectedYamlCaseTitle !== region) state.selectedYamlCaseTitle.classList.add('yaml-region-selected'); -} - -function handleYamlRegionClick(event) { - const region = event.target.closest(YAML_SELECTABLE_REGION_SELECTOR); - if (region && (els.yamlInputViewer.contains(region) || els.yamlRecordedViewer.contains(region))) { - if (state.currentRequestId || state.finishingRun) return; - selectYamlRegion(region); - if (els.yamlInputViewer.contains(region) || els.yamlRecordedViewer.contains(region)) { - if (els.yamlInputViewer.contains(region) && currentRunMode() !== 'strict-yaml') return; - if ((region.closest('.yaml-case-title-row') || region.closest('.yaml-case-summary')) && !region.closest('.yaml-step-card')) { - showCompletedRunReplayPreview(); - } else { - const stepCard = region.closest('.yaml-step-card'); - if (stepCard) loadStepArtifactsForCard(stepCard); - } - } - return; - } - if (state.currentRequestId || state.finishingRun) return; - clearSelectedYamlRegion(); -} - -function completedRunId() { - if (loadedRunIsActive()) return state.loadedRunId; - return !state.currentRequestId && state.replayRequestId ? state.replayRequestId : ''; -} - -async function showCompletedRunReplayPreview(runId = completedRunId()) { - if (!runId) return; - clearStepArtifactPreview(); - showRightTab('preview'); - try { - const replayVideo = await ensureReplayVideoGenerated(runId); - if (replayVideo?.videoUrl) { - await showReplayVideoPreview(replayVideo.videoUrl); - } else { - clearPreview('No replay video is available for this run.'); - } - } catch (error) { - clearPreview(`Replay video could not be loaded: ${error.message}`); - } -} - -function handleProgressRunIdClick() { - if (state.currentRequestId || state.finishingRun) return; - const runId = els.progressRunId.dataset.runId || ''; - if (!runId) return; - clearSelectedProgressItem(); - selectProgressRunId(); - showCompletedRunReplayPreview(runId); -} - -function selectProgressRunId() { - state.selectedProgressRunId = true; - els.progressRunId.classList.add('progress-run-id-selected'); -} - -function clearSelectedProgressRunId() { - state.selectedProgressRunId = false; - els.progressRunId.classList.remove('progress-run-id-selected'); -} - -async function loadStepArtifactsForCard(stepCard) { - const runId = completedRunId(); - if (!runId) return; - const stepIdentifier = stepCard.dataset.yamlStepId || stepCard.dataset.yamlStepIndex || ''; - if (!stepIdentifier) return; - const previewKey = `${runId}:${stepIdentifier}`; - state.stepArtifactPreviewActive = true; - state.stepArtifactStepKey = previewKey; - showRightTab('preview'); - const shouldShowLoading = els.stepArtifactPreview.hidden || !els.stepArtifactPreview.hasChildNodes(); - if (shouldShowLoading) renderStepArtifactLoading(stepCard); - try { - const payload = await api(`/step-artifacts/${encodeURIComponent(runId)}/${encodeURIComponent(stepIdentifier)}`); - if (state.stepArtifactStepKey !== previewKey) return; - await preloadStepArtifactScreenshots(payload); - if (state.stepArtifactStepKey !== previewKey) return; - renderStepArtifactPreview(payload, stepCard); - } catch (error) { - if (state.stepArtifactStepKey !== previewKey) return; - renderStepArtifactError(stepCard, error.message); - } -} - -function clearStepArtifactPreview() { - state.stepArtifactPreviewActive = false; - state.stepArtifactStepKey = null; - if (!els.stepArtifactPreview) return; - els.stepArtifactPreview.hidden = true; - els.stepArtifactPreview.innerHTML = ''; -} - -function showStepArtifactContainer() { - cancelPendingReplayVideoReadyWait(); - if (els.replayVideo.src) { - els.replayVideo.pause(); - } - els.replayVideo.hidden = true; - els.replayVideo.style.display = 'none'; - els.screenshot.removeAttribute('src'); - els.screenshot.style.display = 'none'; - els.previewEmpty.style.display = 'none'; - els.stepArtifactPreview.hidden = false; -} - -function renderStepArtifactLoading(stepCard) { - showStepArtifactContainer(); - const shell = stepArtifactShell(stepCard, 'Loading artifacts...'); - els.stepArtifactPreview.replaceChildren(shell); -} - -function renderStepArtifactError(stepCard, message) { - showStepArtifactContainer(); - const shell = stepArtifactShell(stepCard); - const error = document.createElement('div'); - error.className = 'step-artifact-error'; - error.textContent = message || 'Unable to load step artifacts.'; - shell.appendChild(error); - els.stepArtifactPreview.replaceChildren(shell); -} - -function renderStepArtifactPreview(payload, stepCard) { - showStepArtifactContainer(); - const artifacts = Array.isArray(payload?.artifacts) ? payload.artifacts : []; - const shell = stepArtifactShell(stepCard, payload?.message || 'No artifacts for this step yet.'); - if (artifacts.length > 0) { - shell.querySelector('.step-artifact-empty')?.remove(); - const screenshots = artifacts.filter((artifact) => artifact.kind === 'screenshot' && artifact.contentBase64); - const textArtifacts = artifacts.filter((artifact) => artifact.kind !== 'screenshot' && (typeof artifact.content === 'string' || typeof artifact.error === 'string')); - const body = document.createElement('div'); - body.className = 'step-artifact-body'; - let screenshotRegion = null; - if (screenshots.length > 0) { - screenshotRegion = wrapStepArtifactRegion(renderStepArtifactScreenshots(screenshots), 'screenshots'); - body.appendChild(screenshotRegion); - } - if (textArtifacts.length > 0) { - const textRegion = wrapStepArtifactRegion(renderStepArtifactTextArtifacts(textArtifacts), 'text'); - const diffBody = textRegion.querySelector('.step-artifact-diff-body'); - if (screenshotRegion) screenshotRegion.appendChild(createScreenshotHeightResizer(screenshotRegion)); - if (diffBody) textRegion.prepend(createDiffTopResizer(textRegion)); - if (diffBody) textRegion.appendChild(createDiffHeightResizer(textRegion)); - body.appendChild(textRegion); - } - if (body.childElementCount > 0) shell.appendChild(body); - } - els.stepArtifactPreview.replaceChildren(shell); - requestAnimationFrame(() => { - for (const region of els.stepArtifactPreview.querySelectorAll('.step-artifact-region-screenshots')) { - initializeScreenshotResizeLayout(region); - } - }); -} - -function wrapStepArtifactRegion(section, variant) { - const region = document.createElement('div'); - region.className = `step-artifact-region step-artifact-region-${variant}`; - if (variant === 'screenshots') { - const scroll = document.createElement('div'); - scroll.className = 'step-artifact-screenshot-scroll'; - scroll.appendChild(section); - region.appendChild(scroll); - } else { - region.appendChild(section); - } - return region; -} - -function initializeScreenshotResizeLayout(region) { - if (region.classList.contains('step-artifact-region-user-sized')) return; - const card = region.querySelector('.step-artifact-image-card'); - const cardWidth = card?.getBoundingClientRect().width || 0; - if (!cardWidth) return; - region.style.setProperty('--shot-card-width', `${Math.round(cardWidth)}px`); - region.dataset.shotBaseCardWidth = String(Math.round(cardWidth)); - region.classList.add('step-artifact-region-user-sized'); -} - -function applyScreenshotRegionHeight(region, cardWidth, regionHeight) { - region.style.setProperty('--shot-card-width', `${Math.max(140, Math.round(cardWidth))}px`); - region.style.height = `${regionHeight}px`; -} - -function createScreenshotHeightResizer(screenshotRegion) { - const resizer = document.createElement('div'); - resizer.className = 'step-artifact-screenshot-border-resizer'; - resizer.setAttribute('role', 'separator'); - resizer.setAttribute('aria-orientation', 'horizontal'); - let startY = 0; - let startRegionHeight = 0; - let startCardWidth = 0; - let baseCardWidth = 0; - let startRenderedImageWidth = 0; - let imageAspect = 1; - const currentCardWidth = () => { - const card = screenshotRegion.querySelector('.step-artifact-image-card'); - return card ? card.getBoundingClientRect().width : 280; - }; - const currentImageAspect = () => { - const image = screenshotRegion.querySelector('.step-artifact-image-card img'); - return image?.naturalWidth && image?.naturalHeight ? image.naturalWidth / image.naturalHeight : 1; - }; - const currentRenderedImageWidth = () => { - const image = screenshotRegion.querySelector('.step-artifact-image-card img'); - if (!image) return startCardWidth; - const bounds = image.getBoundingClientRect(); - return Math.min(bounds.width, bounds.height * imageAspect); - }; - const onMove = (event) => { - const delta = event.clientY - startY; - const limit = Math.max(2400, window.innerHeight * 2); - const nextHeight = Math.min(Math.max(160, startRegionHeight + delta), limit); - const appliedDelta = nextHeight - startRegionHeight; - const nextImageWidth = startRenderedImageWidth + appliedDelta * imageAspect; - const nextCardWidth = Math.max(baseCardWidth, nextImageWidth); - applyScreenshotRegionHeight(screenshotRegion, nextCardWidth, nextHeight); - }; - const onUp = (event) => { - document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); - if (resizer.hasPointerCapture?.(event.pointerId)) resizer.releasePointerCapture(event.pointerId); - document.body.classList.remove('step-artifact-resizing'); - }; - resizer.addEventListener('pointerdown', (event) => { - event.preventDefault(); - resizer.setPointerCapture?.(event.pointerId); - initializeScreenshotResizeLayout(screenshotRegion); - startY = event.clientY; - startRegionHeight = Math.round(screenshotRegion.getBoundingClientRect().height); - startCardWidth = currentCardWidth(); - baseCardWidth = Number(screenshotRegion.dataset.shotBaseCardWidth) || startCardWidth; - imageAspect = currentImageAspect(); - startRenderedImageWidth = currentRenderedImageWidth(); - document.body.classList.add('step-artifact-resizing'); - document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); - }); - return resizer; -} - -function createDiffTopResizer(textRegion) { - const resizer = document.createElement('div'); - resizer.className = 'step-artifact-ui-tree-border-resizer'; - resizer.setAttribute('role', 'separator'); - resizer.setAttribute('aria-orientation', 'horizontal'); - resizer.setAttribute('aria-label', 'Resize UI Tree from top'); - let startY = 0; - let startHeight = 0; - let startScrollTop = 0; - let preview = null; - const onMove = (event) => { - const limit = Math.max(2400, window.innerHeight * 2); - const nextHeight = Math.min(Math.max(140, startHeight - (event.clientY - startY)), limit); - const heightDelta = nextHeight - startHeight; - textRegion.style.height = `${nextHeight}px`; - if (preview) preview.scrollTop = Math.max(0, startScrollTop + heightDelta); - }; - const onUp = () => { - document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); - document.body.classList.remove('step-artifact-resizing'); - }; - resizer.addEventListener('pointerdown', (event) => { - event.preventDefault(); - startY = event.clientY; - startHeight = Math.round(textRegion.getBoundingClientRect().height); - preview = textRegion.closest('.step-artifact-preview'); - startScrollTop = preview?.scrollTop || 0; - document.body.classList.add('step-artifact-resizing'); - document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); - }); - return resizer; -} - -async function preloadStepArtifactScreenshots(payload) { - const artifacts = Array.isArray(payload?.artifacts) ? payload.artifacts : []; - const sources = artifacts - .filter((artifact) => artifact.kind === 'screenshot' && artifact.contentBase64) - .map((artifact) => stepArtifactImageSrc(artifact)); - await Promise.allSettled(sources.map((src) => loadImageElement(src))); -} - -function stepArtifactShell(stepCard, emptyMessage = '') { - const shell = document.createElement('div'); - shell.className = 'step-artifact-shell'; - const title = document.createElement('div'); - title.className = 'step-artifact-title'; - title.textContent = stepArtifactTitle(stepCard); - shell.appendChild(title); - if (emptyMessage) { - const empty = document.createElement('div'); - empty.className = 'step-artifact-empty'; - empty.textContent = emptyMessage; - shell.appendChild(empty); - } - return shell; -} - -function stepArtifactTitle(stepCard) { - const index = stepCard.dataset.progressSequenceLabel || stepCard.dataset.yamlStepIndex || '?'; - const action = stepCard.dataset.yamlAction || 'command'; - const paddedIndex = stepCard.dataset.progressSequenceLabel ? index : String(index).padStart(2, '0'); - return `${paddedIndex} ${action}`; -} - -function renderStepArtifactScreenshots(screenshots) { - const section = document.createElement('section'); - section.className = 'step-artifact-section'; - const heading = document.createElement('div'); - heading.className = 'step-artifact-section-title'; - heading.textContent = 'Screenshots'; - section.appendChild(heading); - const primary = primaryScreenshotArtifacts(screenshots); - const row = document.createElement('div'); - row.className = `step-artifact-screenshot-row${primary.length === 2 ? ' step-artifact-compare' : ''}`; - row.appendChild(renderStepArtifactImage(primary[0])); - if (primary.length === 2) { - const connector = document.createElement('div'); - connector.className = 'step-artifact-connector'; - connector.setAttribute('aria-hidden', 'true'); - row.appendChild(connector); - row.appendChild(renderStepArtifactImage(primary[1])); - } - section.appendChild(row); - return section; -} - -function primaryScreenshotArtifacts(screenshots) { - const before = screenshots.find((artifact) => stepArtifactLabel(artifact).toLowerCase().includes('before')); - const after = screenshots.find((artifact) => stepArtifactLabel(artifact).toLowerCase().includes('after')); - if (before && after && before !== after) return [before, after]; - if (screenshots.length > 1) return [screenshots[0], screenshots[screenshots.length - 1]]; - return [screenshots[0]]; -} - -function renderStepArtifactImage(artifact) { - const card = document.createElement('figure'); - card.className = 'step-artifact-image-card'; - const label = document.createElement('figcaption'); - label.className = 'step-artifact-image-label'; - label.textContent = stepArtifactLabel(artifact); - const image = document.createElement('img'); - image.alt = label.textContent; - image.src = stepArtifactImageSrc(artifact); - card.appendChild(label); - card.appendChild(image); - return card; -} - -function stepArtifactImageSrc(artifact) { - return `data:${artifact.mimeType || 'image/png'};base64,${artifact.contentBase64}`; -} - -function renderStepArtifactTextArtifacts(artifacts) { - const section = document.createElement('section'); - section.className = 'step-artifact-section'; - const heading = document.createElement('div'); - heading.className = 'step-artifact-section-title'; - heading.textContent = 'UI Tree'; - section.appendChild(heading); - const uiTreeDiff = renderUiTreeDiffArtifact(artifacts); - for (const artifact of artifacts.filter((candidate) => ( - typeof candidate.error === 'string' - && !(uiTreeDiff && OBSERVATION_ARTIFACT_KINDS.includes(candidate.kind)) - ))) { - const card = document.createElement('div'); - card.className = 'step-artifact-text-card'; - const label = document.createElement('div'); - label.className = 'step-artifact-text-label'; - label.textContent = `${artifact.kind || 'artifact'} · ${stepArtifactLabel(artifact)}`; - const error = document.createElement('div'); - error.className = 'step-artifact-error'; - error.textContent = artifact.error; - card.appendChild(label); - card.appendChild(error); - section.appendChild(card); - } - if (uiTreeDiff) section.appendChild(uiTreeDiff); - for (const artifact of artifacts) { - if ((uiTreeDiff && OBSERVATION_ARTIFACT_KINDS.includes(artifact.kind)) || typeof artifact.error === 'string') continue; - const card = document.createElement('div'); - card.className = 'step-artifact-text-card'; - const label = document.createElement('div'); - label.className = 'step-artifact-text-label'; - label.textContent = `${artifact.kind || 'artifact'} · ${stepArtifactLabel(artifact)}`; - const pre = document.createElement('pre'); - pre.className = isXmlStepArtifact(artifact) ? 'step-artifact-xml' : ''; - pre.textContent = artifact.content || ''; - card.appendChild(label); - card.appendChild(pre); - section.appendChild(card); - } - return section; -} - -const OBSERVATION_ARTIFACT_KINDS = ['ui_tree', 'ui_snapshot']; - -function renderUiTreeDiffArtifact(artifacts) { - const uiTrees = artifacts.filter((artifact) => ( - OBSERVATION_ARTIFACT_KINDS.includes(artifact.kind) - && (typeof artifact.content === 'string' || typeof artifact.error === 'string') - )); - const beforeArtifact = uiTrees.find((artifact) => stepArtifactLabel(artifact) === 'Before'); - const afterArtifact = uiTrees.find((artifact) => stepArtifactLabel(artifact) === 'After'); - if (!beforeArtifact && !afterArtifact) return null; - const card = document.createElement('div'); - card.className = 'step-artifact-diff-card'; - const body = document.createElement('div'); - body.className = 'step-artifact-diff-body'; - if (!beforeArtifact || !afterArtifact) { - const artifact = beforeArtifact || afterArtifact; - const side = beforeArtifact ? 'before' : 'after'; - const title = beforeArtifact ? 'Before' : 'After'; - const pane = typeof artifact.error === 'string' - ? renderArtifactErrorPane(side, title, artifact) - : renderDiffPane(side, title, contextDiffLines(artifact.content || '')); - const wrap = document.createElement('div'); - wrap.className = 'step-artifact-diff-scroll'; - const split = document.createElement('div'); - split.className = 'step-artifact-diff-split'; - split.appendChild(pane); - wrap.appendChild(split); - body.appendChild(wrap); - card.appendChild(body); - return card; - } - const before = beforeArtifact; - const after = afterArtifact; - const hasError = typeof before.error === 'string' || typeof after.error === 'string'; - let diff = diffTextLines(before.content || '', after.content || ''); - const unchanged = diff.length === 0; - if (unchanged) diff = contextDiffLines(after.content || before.content || ''); - else annotateInlineDiff(diff); - if (hasError) { - const wrap = document.createElement('div'); - wrap.className = 'step-artifact-diff-scroll'; - const split = document.createElement('div'); - split.className = 'step-artifact-diff-split'; - split.appendChild(renderArtifactErrorPane('before', 'Before', before)); - split.appendChild(renderArtifactErrorPane('after', 'After', after)); - wrap.appendChild(split); - body.appendChild(wrap); - } else if (diff.length === 0) { - const empty = document.createElement('div'); - empty.className = 'step-artifact-diff-empty'; - empty.textContent = 'No UI tree captured.'; - body.appendChild(empty); - } else { - const wrap = document.createElement('div'); - wrap.className = 'step-artifact-diff-scroll'; - const split = document.createElement('div'); - split.className = 'step-artifact-diff-split'; - const beforePane = renderDiffPane('before', 'Before', diff); - const afterPane = renderDiffPane('after', 'After', diff); - split.appendChild(beforePane); - split.appendChild(afterPane); - const panes = [beforePane, afterPane]; - syncDiffPaneScroll(beforePane, afterPane); - wrap.appendChild(split); - wrap.appendChild(renderDiffOverview(diff, panes)); - body.appendChild(wrap); - } - card.appendChild(body); - return card; -} - -function renderArtifactErrorPane(side, title, artifact) { - const pane = document.createElement('div'); - pane.className = `step-artifact-diff-pane diff-pane-${side}`; - const content = document.createElement('div'); - content.className = 'step-artifact-error'; - content.textContent = artifact.error || 'UI tree is unavailable.'; - pane.appendChild(renderDiffPaneHeader(title)); - pane.appendChild(content); - return pane; -} - -function createDiffHeightResizer(target) { - const resizer = document.createElement('div'); - resizer.className = 'step-artifact-ui-tree-bottom-border-resizer'; - resizer.setAttribute('role', 'separator'); - resizer.setAttribute('aria-orientation', 'horizontal'); - let startY = 0; - let startHeight = 0; - const onMove = (event) => { - const max = Math.max(2400, window.innerHeight * 2); - const next = Math.min(Math.max(140, startHeight + (event.clientY - startY)), max); - target.style.height = `${next}px`; - resizer.scrollIntoView({ block: 'nearest' }); - }; - const onUp = () => { - document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); - document.body.classList.remove('step-artifact-resizing'); - }; - resizer.addEventListener('pointerdown', (event) => { - event.preventDefault(); - startY = event.clientY; - startHeight = target.getBoundingClientRect().height; - document.body.classList.add('step-artifact-resizing'); - document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); - }); - return resizer; -} - -function renderDiffOverview(diff, panes) { - const overview = document.createElement('div'); - overview.className = 'step-artifact-diff-overview'; - overview.title = 'Diff overview — click to jump'; - const rowCount = diff.length + 1; - diff.forEach((line, index) => { - if (line.kind !== 'added' && line.kind !== 'removed') return; - const marker = document.createElement('button'); - marker.type = 'button'; - marker.className = `step-artifact-diff-overview-marker step-artifact-diff-overview-${line.kind}`; - marker.style.top = `${((index + 1) / rowCount) * 100}%`; - marker.title = line.kind === 'added' ? 'Added line' : 'Removed line'; - marker.addEventListener('click', (event) => { - event.stopPropagation(); - scrollDiffToRow(panes, index + 1, rowCount); - }); - overview.appendChild(marker); - }); - overview.addEventListener('click', (event) => { - const rect = overview.getBoundingClientRect(); - const ratio = rect.height ? (event.clientY - rect.top) / rect.height : 0; - const pane = panes[0]; - applyDiffScrollTop(panes, ratio * (pane.scrollHeight - pane.clientHeight)); - }); - return overview; -} - -function scrollDiffToRow(panes, row, rowCount) { - const pane = panes[0]; - const target = rowCount ? (row / rowCount) * pane.scrollHeight - pane.clientHeight / 2 : 0; - applyDiffScrollTop(panes, target); -} - -function applyDiffScrollTop(panes, value) { - const pane = panes[0]; - const max = Math.max(0, pane.scrollHeight - pane.clientHeight); - const clamped = Math.max(0, Math.min(max, value)); - for (const target of panes) target.scrollTop = clamped; -} - -function renderDiffPane(side, title, diff) { - const pane = document.createElement('div'); - pane.className = `step-artifact-diff-pane diff-pane-${side}`; - const column = document.createElement('div'); - column.className = 'step-artifact-diff-column'; - - column.appendChild(renderDiffPaneHeader(title)); - - for (const line of diff) { - const row = document.createElement('div'); - row.className = `step-artifact-diff-row step-artifact-diff-line-${line.kind}`; - const number = document.createElement('span'); - number.className = 'step-artifact-diff-number'; - const text = document.createElement('span'); - text.className = 'step-artifact-diff-text'; - if (side === 'before') { - number.textContent = line.beforeNumber ? String(line.beforeNumber) : ''; - appendDiffText(text, line.beforeText || '', line.beforeInline); - } else { - number.textContent = line.afterNumber ? String(line.afterNumber) : ''; - appendDiffText(text, line.afterText || '', line.afterInline); - } - row.appendChild(number); - row.appendChild(text); - column.appendChild(row); - } - pane.appendChild(column); - return pane; -} - -function renderDiffPaneHeader(title) { - const header = document.createElement('div'); - header.className = 'step-artifact-image-label step-artifact-diff-headrow'; - header.textContent = title; - return header; -} - -function appendDiffText(target, value, inlineChange) { - if (!inlineChange) { - target.textContent = value; - return; - } - if (inlineChange.prefix) target.appendChild(document.createTextNode(inlineChange.prefix)); - const changed = document.createElement('span'); - changed.className = 'step-artifact-diff-inline-change'; - changed.textContent = inlineChange.changed; - target.appendChild(changed); - if (inlineChange.suffix) target.appendChild(document.createTextNode(inlineChange.suffix)); -} - -function annotateInlineDiff(diff) { - let index = 0; - while (index < diff.length) { - if (diff[index].kind === 'context') { - index += 1; - continue; - } - const start = index; - while (index < diff.length && diff[index].kind !== 'context') index += 1; - annotateInlineChangeBlock(diff.slice(start, index)); - } -} - -function annotateInlineChangeBlock(block) { - const removed = block.filter((line) => line.kind === 'removed'); - const added = block.filter((line) => line.kind === 'added'); - const pairCount = Math.min(removed.length, added.length); - for (let index = 0; index < pairCount; index += 1) { - const beforeText = removed[index].beforeText || ''; - const afterText = added[index].afterText || ''; - const changes = inlineTextChanges(beforeText, afterText); - removed[index].beforeInline = changes.before; - added[index].afterInline = changes.after; - } -} - -function inlineTextChanges(beforeText, afterText) { - let prefixLength = 0; - const maxPrefix = Math.min(beforeText.length, afterText.length); - while (prefixLength < maxPrefix && beforeText[prefixLength] === afterText[prefixLength]) prefixLength += 1; - - let suffixLength = 0; - const maxSuffix = Math.min(beforeText.length - prefixLength, afterText.length - prefixLength); - while ( - suffixLength < maxSuffix - && beforeText[beforeText.length - suffixLength - 1] === afterText[afterText.length - suffixLength - 1] - ) { - suffixLength += 1; - } - return { - before: inlineTextChange(beforeText, prefixLength, suffixLength), - after: inlineTextChange(afterText, prefixLength, suffixLength), - }; -} - -function inlineTextChange(text, prefixLength, suffixLength) { - const changedEnd = suffixLength ? text.length - suffixLength : text.length; - return { - prefix: text.slice(0, prefixLength), - changed: text.slice(prefixLength, changedEnd), - suffix: text.slice(changedEnd), - }; -} - -function syncDiffPaneScroll(first, second) { - let syncing = false; - const link = (source, target) => { - source.addEventListener('scroll', () => { - if (syncing) return; - syncing = true; - target.scrollLeft = source.scrollLeft; - target.scrollTop = source.scrollTop; - syncing = false; - }); - }; - link(first, second); - link(second, first); -} - -function contextDiffLines(text) { - return normalizeDiffLines(text).map((value, index) => ({ - kind: 'context', - beforeNumber: index + 1, - beforeText: value, - afterNumber: index + 1, - afterText: value, - })); -} - -function diffTextLines(beforeText, afterText) { - const beforeLines = normalizeDiffLines(beforeText); - const afterLines = normalizeDiffLines(afterText); - if (beforeLines.join('\n') === afterLines.join('\n')) return []; - if (beforeLines.length * afterLines.length > 160000) return myersDiffLines(beforeLines, afterLines); - const table = Array.from({ length: beforeLines.length + 1 }, () => Array(afterLines.length + 1).fill(0)); - for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex -= 1) { - for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex -= 1) { - if (beforeLines[beforeIndex] === afterLines[afterIndex]) { - table[beforeIndex][afterIndex] = table[beforeIndex + 1][afterIndex + 1] + 1; - } else { - table[beforeIndex][afterIndex] = Math.max(table[beforeIndex + 1][afterIndex], table[beforeIndex][afterIndex + 1]); - } - } - } - const diff = []; - let beforeIndex = 0; - let afterIndex = 0; - let beforeNumber = 1; - let afterNumber = 1; - while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) { - if (beforeLines[beforeIndex] === afterLines[afterIndex]) { - diff.push({ kind: 'context', beforeNumber, beforeText: beforeLines[beforeIndex], afterNumber, afterText: afterLines[afterIndex] }); - beforeIndex += 1; - afterIndex += 1; - beforeNumber += 1; - afterNumber += 1; - } else if (table[beforeIndex + 1][afterIndex] >= table[beforeIndex][afterIndex + 1]) { - diff.push({ kind: 'removed', beforeNumber, beforeText: beforeLines[beforeIndex] }); - beforeIndex += 1; - beforeNumber += 1; - } else { - diff.push({ kind: 'added', afterNumber, afterText: afterLines[afterIndex] }); - afterIndex += 1; - afterNumber += 1; - } - } - while (beforeIndex < beforeLines.length) { - diff.push({ kind: 'removed', beforeNumber, beforeText: beforeLines[beforeIndex] }); - beforeIndex += 1; - beforeNumber += 1; - } - while (afterIndex < afterLines.length) { - diff.push({ kind: 'added', afterNumber, afterText: afterLines[afterIndex] }); - afterIndex += 1; - afterNumber += 1; - } - return diff; -} - -function normalizeDiffLines(text) { - return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); -} - -function myersDiffLines(beforeLines, afterLines) { - const beforeLength = beforeLines.length; - const afterLength = afterLines.length; - const maxDistance = Math.min(beforeLength + afterLength, 1200); - const furthest = new Map([[1, 0]]); - const trace = []; - - for (let distance = 0; distance <= maxDistance; distance += 1) { - trace.push(new Map(furthest)); - for (let diagonal = -distance; diagonal <= distance; diagonal += 2) { - const down = furthest.get(diagonal + 1) ?? -1; - const right = furthest.get(diagonal - 1) ?? -1; - let beforeIndex; - if (diagonal === -distance || (diagonal !== distance && right < down)) { - beforeIndex = Math.max(0, down); - } else { - beforeIndex = Math.max(0, right + 1); - } - let afterIndex = beforeIndex - diagonal; - while ( - beforeIndex < beforeLength - && afterIndex < afterLength - && beforeLines[beforeIndex] === afterLines[afterIndex] - ) { - beforeIndex += 1; - afterIndex += 1; - } - furthest.set(diagonal, beforeIndex); - if (beforeIndex >= beforeLength && afterIndex >= afterLength) { - return backtrackMyersDiff(trace, beforeLines, afterLines); - } - } - } - return patienceDiffLines(beforeLines, afterLines); -} - -function backtrackMyersDiff(trace, beforeLines, afterLines) { - let beforeIndex = beforeLines.length; - let afterIndex = afterLines.length; - const reversed = []; - - for (let distance = trace.length - 1; distance >= 0; distance -= 1) { - const furthest = trace[distance]; - const diagonal = beforeIndex - afterIndex; - const down = furthest.get(diagonal + 1) ?? -1; - const right = furthest.get(diagonal - 1) ?? -1; - const previousDiagonal = diagonal === -distance || (diagonal !== distance && right < down) - ? diagonal + 1 - : diagonal - 1; - const previousBefore = Math.max(0, furthest.get(previousDiagonal) ?? 0); - const previousAfter = previousBefore - previousDiagonal; - - while (beforeIndex > previousBefore && afterIndex > previousAfter) { - reversed.push({ - kind: 'context', - beforeNumber: beforeIndex, - beforeText: beforeLines[beforeIndex - 1], - afterNumber: afterIndex, - afterText: afterLines[afterIndex - 1], - }); - beforeIndex -= 1; - afterIndex -= 1; - } - if (distance === 0) break; - if (beforeIndex === previousBefore) { - reversed.push({ kind: 'added', afterNumber: afterIndex, afterText: afterLines[afterIndex - 1] }); - afterIndex -= 1; - } else { - reversed.push({ kind: 'removed', beforeNumber: beforeIndex, beforeText: beforeLines[beforeIndex - 1] }); - beforeIndex -= 1; - } - } - return reversed.reverse(); -} - -function patienceDiffLines(beforeLines, afterLines) { - const diff = []; - appendPatienceRange(diff, beforeLines, afterLines, 0, beforeLines.length, 0, afterLines.length); - return diff; -} - -function appendPatienceRange(diff, beforeLines, afterLines, beforeStart, beforeEnd, afterStart, afterEnd) { - while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeStart] === afterLines[afterStart]) { - diff.push({ - kind: 'context', - beforeNumber: beforeStart + 1, - beforeText: beforeLines[beforeStart], - afterNumber: afterStart + 1, - afterText: afterLines[afterStart], - }); - beforeStart += 1; - afterStart += 1; - } - - let commonSuffix = 0; - while ( - beforeStart < beforeEnd - commonSuffix - && afterStart < afterEnd - commonSuffix - && beforeLines[beforeEnd - commonSuffix - 1] === afterLines[afterEnd - commonSuffix - 1] - ) { - commonSuffix += 1; - } - const beforeMiddleEnd = beforeEnd - commonSuffix; - const afterMiddleEnd = afterEnd - commonSuffix; - const anchors = patienceAnchors(beforeLines, afterLines, beforeStart, beforeMiddleEnd, afterStart, afterMiddleEnd); - - if (anchors.length === 0) { - for (let index = beforeStart; index < beforeMiddleEnd; index += 1) { - diff.push({ kind: 'removed', beforeNumber: index + 1, beforeText: beforeLines[index] }); - } - for (let index = afterStart; index < afterMiddleEnd; index += 1) { - diff.push({ kind: 'added', afterNumber: index + 1, afterText: afterLines[index] }); - } - } else { - let previousBefore = beforeStart; - let previousAfter = afterStart; - for (const anchor of anchors) { - appendPatienceRange(diff, beforeLines, afterLines, previousBefore, anchor.before, previousAfter, anchor.after); - diff.push({ - kind: 'context', - beforeNumber: anchor.before + 1, - beforeText: beforeLines[anchor.before], - afterNumber: anchor.after + 1, - afterText: afterLines[anchor.after], - }); - previousBefore = anchor.before + 1; - previousAfter = anchor.after + 1; - } - appendPatienceRange(diff, beforeLines, afterLines, previousBefore, beforeMiddleEnd, previousAfter, afterMiddleEnd); - } - - for (let offset = commonSuffix - 1; offset >= 0; offset -= 1) { - const beforeIndex = beforeEnd - offset - 1; - const afterIndex = afterEnd - offset - 1; - diff.push({ - kind: 'context', - beforeNumber: beforeIndex + 1, - beforeText: beforeLines[beforeIndex], - afterNumber: afterIndex + 1, - afterText: afterLines[afterIndex], - }); - } -} - -function patienceAnchors(beforeLines, afterLines, beforeStart, beforeEnd, afterStart, afterEnd) { - const beforeOccurrences = uniqueLineOccurrences(beforeLines, beforeStart, beforeEnd); - const afterOccurrences = uniqueLineOccurrences(afterLines, afterStart, afterEnd); - const candidates = []; - for (const [line, beforeOccurrence] of beforeOccurrences) { - const afterOccurrence = afterOccurrences.get(line); - if (beforeOccurrence.count === 1 && afterOccurrence?.count === 1) { - candidates.push({ before: beforeOccurrence.index, after: afterOccurrence.index }); - } - } - candidates.sort((left, right) => left.before - right.before); - return longestIncreasingAnchorSequence(candidates); -} - -function uniqueLineOccurrences(lines, start, end) { - const occurrences = new Map(); - for (let index = start; index < end; index += 1) { - const line = lines[index]; - const occurrence = occurrences.get(line); - if (occurrence) occurrence.count += 1; - else occurrences.set(line, { count: 1, index }); - } - return occurrences; -} - -function longestIncreasingAnchorSequence(candidates) { - if (candidates.length === 0) return []; - const tails = []; - const previous = new Array(candidates.length).fill(-1); - for (let index = 0; index < candidates.length; index += 1) { - let low = 0; - let high = tails.length; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (candidates[tails[middle]].after < candidates[index].after) low = middle + 1; - else high = middle; - } - if (low > 0) previous[index] = tails[low - 1]; - tails[low] = index; - } - const sequence = []; - let cursor = tails[tails.length - 1]; - while (cursor >= 0) { - sequence.push(candidates[cursor]); - cursor = previous[cursor]; - } - return sequence.reverse(); -} - -function isXmlStepArtifact(artifact) { - const content = String(artifact.content || '').trimStart(); - const mimeType = String(artifact.mimeType || '').toLowerCase(); - const path = String(artifact.path || '').toLowerCase(); - return mimeType.includes('xml') || path.endsWith('.xml') || content.startsWith('<'); -} - -function stepArtifactLabel(artifact) { - const combined = `${artifact.reason || ''} ${artifact.phase || ''}`.toLowerCase(); - if (combined.includes('before') || combined.includes('prepare')) return 'Before'; - if (combined.includes('after') || combined.includes('finalize')) return 'After'; - if (combined.includes('failure')) return 'Failure'; - return artifact.phase || artifact.reason || artifact.kind || 'Artifact'; -} - -function recordingStatusDetails(recording) { - const messages = []; - for (const warning of recording.warnings || []) messages.push(`Warning: ${formatProgressValue(warning)}`); - for (const error of recording.errors || []) messages.push(`Error: ${formatProgressValue(error)}`); - if ((recording.skippedToolCalls || []).length > 0) messages.push(`Skipped tool calls: ${recording.skippedToolCalls.length}`); - return messages.join('\n') || 'No generated YAML content.'; -} - -function renderMarkdown(markdown) { - const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n'); - const html = []; - let inCode = false; - let codeLines = []; - let inList = false; - - const closeList = () => { - if (inList) { - html.push(''); - inList = false; - } - }; - const flushCode = () => { - html.push(`
${escapeHtml(codeLines.join('\n'))}
`); - codeLines = []; - }; - - for (const line of lines) { - if (line.trim().startsWith('```')) { - if (inCode) { - inCode = false; - flushCode(); - } else { - closeList(); - inCode = true; - codeLines = []; - } - continue; - } - if (inCode) { - codeLines.push(line); - continue; - } - - const heading = line.match(/^(#{1,4})\s+(.*)$/); - if (heading) { - closeList(); - const level = heading[1].length; - html.push(`${formatInlineMarkdown(heading[2])}`); - continue; - } - - const bullet = line.match(/^\s*[-*]\s+(.*)$/); - if (bullet) { - if (!inList) { - html.push('