Skip to content

feat(desktop): add experiments and sweep workspace v1#207

Merged
Whiteks1 merged 1 commit intomainfrom
codex/desktop-experiments-workspace
Mar 28, 2026
Merged

feat(desktop): add experiments and sweep workspace v1#207
Whiteks1 merged 1 commit intomainfrom
codex/desktop-experiments-workspace

Conversation

@Whiteks1
Copy link
Copy Markdown
Owner

@Whiteks1 Whiteks1 commented Mar 28, 2026

Summary

This PR:

  • adds a shell-native Experiments workspace for local sweep configs and recent sweep outputs
  • lets operators launch sweeps directly from configs/experiments without leaving QuantLab Desktop
  • reads and summarizes outputs/sweeps artifacts locally, including leaderboard and walkforward summary files

Why

This slice matters because:

  • the shell needed a native surface for hypothesis -> sweep launch -> inspect outputs instead of forcing that work back into scattered browser pages
  • recent roadmap work already made runs, compare, artifacts, candidates, and paper more native; experiments was the missing research-side entry point

Scope

This PR does not:

  • add Stepbit orchestration or AI reasoning to the experiment flow
  • turn experiments into a full experiment manager, scoring engine, or workflow system beyond local configs and recent sweeps

Validation

Validated with:

  • node --check desktop/main.js
  • node --check desktop/preload.js
  • node --check desktop/renderer/app.js
  • node --check desktop/renderer/modules/utils.js
  • node --check desktop/renderer/modules/tab-renderers.js
  • python -m pytest test/test_research_ui_server.py

Notes

  • The shell now tolerates Python-style Infinity values in local sweep meta.json files so walkforward artifacts can still render.
  • The native experiments surface stays local-first by reading configs/experiments and outputs/sweeps directly from the workspace instead of requiring new backend endpoints.

Closes #206

Summary by Sourcery

Introduce a native Experiments workspace in QuantLab Desktop for managing local sweep configs and recent sweep outputs, wired into the existing shell navigation, chat, and launch workflow.

New Features:

  • Add an Experiments tab that surfaces experiment configs from configs/experiments and recent sweeps from outputs/sweeps as a shell-native workspace.
  • Allow launching and relaunching sweeps directly from experiment configs and past sweep metadata within the desktop UI.
  • Expose quick actions from chat, palette, and sidebar chips to open and refresh the Experiments workspace.

Enhancements:

  • Read and summarize local sweep artifacts, including meta.json, leaderboard.csv, experiments.csv, and walkforward_summary.csv, with headline metrics and mini-table previews.
  • Extend the Electron main process and preload bridge with safe text/JSON file readers that tolerate NaN and Infinity in sweep metadata.
  • Add lightweight CSV parsing, path utilities, and styling tweaks to support experiment previews and selected-state cards in the renderer UI.

Documentation:

  • Document the Experiments tab as a new shell-native workspace in the desktop README.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Mar 28, 2026

Reviewer's Guide

Adds a native Experiments workspace tab to QuantLab Desktop that reads local experiment configs and sweep outputs from the filesystem, exposes them through new IPC-safe file access helpers, parses CSV/JSON artifacts for lightweight summaries, and wires the workspace into chat, navigation, keyboard palette, and styling.

Sequence diagram for Experiments workspace refresh and rendering

sequenceDiagram
  actor User
  participant RendererApp as RendererApp_app_js
  participant TabRenderers as TabRenderers_tab_renderers_js
  participant Preload as Preload_quantlabDesktop
  participant Main as MainProcess_main_js
  participant FS as FileSystem

  User->>RendererApp: click Experiments nav button
  RendererApp->>RendererApp: openExperimentsTab()
  RendererApp->>RendererApp: upsertTab(kind=experiments)
  RendererApp->>RendererApp: refreshExperimentsWorkspace(focusTab=true, silent=true)
  RendererApp->>RendererApp: state.experimentsWorkspace.status = loading
  RendererApp->>RendererApp: renderTabs()
  RendererApp->>TabRenderers: renderExperimentsTab(tab, getRendererContext())
  TabRenderers-->>RendererApp: HTML placeholder (loading state)
  RendererApp-->>User: Experiments tab shows loading message

  RendererApp->>RendererApp: buildExperimentsWorkspace()
  RendererApp->>Preload: quantlabDesktop.listDirectory(CONFIG.experimentsConfigDir, 0)
  Preload->>Main: ipc quantlab:list-directory(experiments, 0)
  Main->>Main: listDirectoryEntries()
  Main->>FS: read configs/experiments directory
  FS-->>Main: directory entries
  Main-->>Preload: entries for configs/experiments
  Preload-->>RendererApp: configsListing

  RendererApp->>Preload: quantlabDesktop.listDirectory(CONFIG.sweepsOutputDir, 0)
  Preload->>Main: ipc quantlab:list-directory(outputs/sweeps, 0)
  Main->>Main: listDirectoryEntries()
  Main->>FS: read outputs/sweeps directory
  FS-->>Main: directory entries
  Main-->>Preload: entries for outputs/sweeps
  Preload-->>RendererApp: sweepsListing

  RendererApp->>RendererApp: buildExperimentsWorkspace filter/sort configEntries
  loop for each config
    RendererApp->>RendererApp: loadExperimentConfigPreview(config.path)
    alt preview cached
      RendererApp-->>RendererApp: read from experimentConfigPreviewCache
    else preview not cached
      RendererApp->>Preload: quantlabDesktop.readProjectText(config.path)
      Preload->>Main: ipc quantlab:read-project-text(configPath)
      Main->>Main: assertPathInsideProject(configPath)
      Main->>FS: readFile(configPath)
      FS-->>Main: YAML contents
      Main-->>Preload: text contents
      Preload-->>RendererApp: text contents
      RendererApp->>RendererApp: cache first 48 lines in experimentConfigPreviewCache
    end
  end

  loop for each sweep directory
    RendererApp->>RendererApp: buildSweepSummary(entry)
    RendererApp->>Preload: quantlabDesktop.listDirectory(entry.path, 0)
    Preload->>Main: ipc quantlab:list-directory(sweepRoot, 0)
    Main->>FS: read sweep directory
    FS-->>Main: entries
    Main-->>Preload: entries
    Preload-->>RendererApp: fileListing

    RendererApp->>Preload: quantlabDesktop.readProjectJson(metaPath)
    Preload->>Main: ipc quantlab:read-project-json(metaPath)
    Main->>Main: readProjectJson(metaPath)
    Main->>Main: readProjectText(metaPath)
    Main->>FS: readFile(metaPath)
    FS-->>Main: raw JSON text (may contain Infinity)
    Main->>Main: JSON.parse with Infinity sanitization
    Main-->>Preload: meta object
    Preload-->>RendererApp: meta object

    par read csv artifacts
      RendererApp->>Preload: quantlabDesktop.readProjectText(leaderboardPath)
      Preload->>Main: ipc quantlab:read-project-text(leaderboardPath)
      Main->>FS: readFile(leaderboardPath)
      FS-->>Main: csv text
      Main-->>Preload: csv text
      Preload-->>RendererApp: leaderboardText

      RendererApp->>Preload: quantlabDesktop.readProjectText(experimentsPath)
      Preload->>Main: ipc quantlab:read-project-text(experimentsPath)
      Main->>FS: readFile(experimentsPath)
      FS-->>Main: csv text
      Main-->>Preload: csv text
      Preload-->>RendererApp: experimentsText

      RendererApp->>Preload: quantlabDesktop.readProjectText(walkforwardSummaryPath)
      Preload->>Main: ipc quantlab:read-project-text(walkforwardSummaryPath)
      Main->>FS: readFile(walkforwardSummaryPath)
      FS-->>Main: csv text
      Main-->>Preload: csv text
      Preload-->>RendererApp: walkforwardText
    end

    RendererApp->>RendererApp: parseCsvPreviewRows(leaderboardText, maxSweepRows)
    RendererApp->>RendererApp: parseCsvPreviewRows(experimentsText, 1)
    RendererApp->>RendererApp: parseCsvPreviewRows(walkforwardText, maxSweepRows)
    RendererApp->>RendererApp: coerceNumber and inferSweepModeFromName
  end

  RendererApp->>RendererApp: state.experimentsWorkspace = {status=ready, configs, sweeps}
  RendererApp->>RendererApp: update experiments tab selection defaults
  RendererApp->>RendererApp: renderTabs()
  RendererApp->>TabRenderers: renderExperimentsTab(tab, getRendererContext())
  TabRenderers-->>RendererApp: HTML for configs and sweeps
  RendererApp-->>User: Experiments workspace with previews and summaries
Loading

Sequence diagram for launching a sweep from the Experiments workspace

sequenceDiagram
  actor User
  participant RendererApp as RendererApp_app_js
  participant TabRenderers as TabRenderers_tab_renderers_js
  participant Preload as Preload_quantlabDesktop
  participant Backend as ResearchUIServer

  User->>RendererApp: click Launch sweep on config card
  RendererApp->>RendererApp: bindTabContentEvents(tab) handles data-experiment-launch-config
  RendererApp->>RendererApp: submitLaunchRequest({command: sweep, params: {config_path}}, source=experiments)

  RendererApp->>Preload: quantlabDesktop.postJson(/api/launch, payload)
  Preload->>Backend: HTTP POST /api/launch with payload
  Backend-->>Preload: launch response (request_id, run_id, status)
  Preload-->>RendererApp: response
  RendererApp->>RendererApp: update state.launchFeedback and logs

  User->>RendererApp: observe no-context chat or continue in Experiments tab

  RendererApp->>RendererApp: refreshExperimentsWorkspace(focusTab=true, silent=true)
  RendererApp->>RendererApp: buildExperimentsWorkspace() (filesystem scan)
  RendererApp->>RendererApp: state.experimentsWorkspace updated with new sweeps
  RendererApp->>RendererApp: upsertTab({selectedConfigPath=configPath})
  RendererApp->>RendererApp: renderTabs()
  RendererApp-->>User: Experiments tab shows updated sweeps including new launch
Loading

Class diagram for main Experiments-related modules and helpers

classDiagram
  class RendererApp {
    +CONFIG_experimentsConfigDir
    +CONFIG_sweepsOutputDir
    +CONFIG_maxExperimentsConfigs
    +CONFIG_maxRecentSweeps
    +CONFIG_maxSweepRows
    +state_experimentsWorkspace
    +state_experimentConfigPreviewCache
    +openExperimentsTab()
    +refreshExperimentsWorkspace(focusTab, silent)
    +buildExperimentsWorkspace()
    +buildSweepSummary(entry)
    +readOptionalProjectText(targetPath)
    +readOptionalProjectJson(targetPath)
    +coerceNumber(value)
    +inferSweepModeFromName(value)
    +loadExperimentConfigPreview(configPath)
    +renderExperimentsTab(tab)
    +bindTabContentEvents(tab)
  }

  class TabRenderers {
    +renderExperimentsTab(tab, ctx)
  }

  class UtilsModule {
    +basenamePath(value)
    +parseCsvRows(text, limit)
    -splitCsvLine(line)
  }

  class PreloadBridge {
    +quantlabDesktop.listDirectory(targetPath, maxDepth)
    +quantlabDesktop.readProjectText(targetPath)
    +quantlabDesktop.readProjectJson(targetPath)
    +quantlabDesktop.postJson(relativePath, payload)
    +quantlabDesktop.openPath(targetPath)
  }

  class MainProcess {
    +assertPathInsideProject(targetPath)
    +listDirectoryEntries(targetPath, maxDepth)
    +readProjectText(targetPath)
    +readProjectJson(targetPath)
  }

  RendererApp --> TabRenderers : passes ctx.experimentsWorkspace
  RendererApp --> UtilsModule : uses parseCsvRows
  RendererApp --> PreloadBridge : calls quantlabDesktop APIs
  PreloadBridge --> MainProcess : forwards IPC handlers
  MainProcess --> UtilsModule : path helpers via assertPathInsideProject

  class ExperimentsWorkspaceState {
    +status
    +configs
    +sweeps
    +error
    +updatedAt
  }

  class ExperimentConfigEntry {
    +name
    +path
    +relativePath
    +modifiedAt
    +sizeBytes
    +previewText
  }

  class SweepSummaryEntry {
    +run_id
    +path
    +modifiedAt
    +createdAt
    +mode
    +configPath
    +configName
    +nRuns
    +nSelected
    +nTrainRuns
    +nTestRuns
    +topResults
    +leaderboardRows
    +walkforwardRows
    +files
    +filesTruncated
    +metaPath
    +leaderboardPath
    +experimentsPath
    +walkforwardSummaryPath
    +configResolvedPath
    +headlineReturn
    +headlineSharpe
    +headlineDrawdown
  }

  RendererApp --> ExperimentsWorkspaceState : maintains
  ExperimentsWorkspaceState "1" o-- "*" ExperimentConfigEntry : configs
  ExperimentsWorkspaceState "1" o-- "*" SweepSummaryEntry : sweeps
Loading

File-Level Changes

Change Details Files
Introduce an Experiments workspace tab that lists local experiment configs and recent sweeps, with launch and inspection actions wired into the existing tab system and chat workflow.
  • Extend CONFIG and global state with experiments-related paths, limits, workspace state, and a preview cache for config files.
  • Add an experiments palette action, sidebar chip, nav wiring, and chat intents ("open/refresh experiments") that open or refresh the Experiments tab.
  • Implement openExperimentsTab, refreshExperimentsWorkspace, and buildExperimentsWorkspace to scan configs/experiments and outputs/sweeps via quantlabDesktop.listDirectory, populate configs/sweeps, and keep the selected config/sweep stable.
  • Implement buildSweepSummary, readOptionalProjectText/Json, coerceNumber, inferSweepModeFromName, and loadExperimentConfigPreview to derive sweep metadata, CSV row previews, and YAML previews.
  • Wire tab rendering and events for the experiments kind, including syncNav mapping, rerenderContextualTabs, getBrowserUrlForActiveContext, and renderExperimentsTab delegation via getRendererContext().
desktop/renderer/app.js
desktop/renderer/index.html
desktop/renderer/modules/tab-renderers.js
desktop/renderer/styles.css
desktop/README.md
Add generic filesystem helpers and IPC endpoints so the renderer can safely read project-local text and JSON, including slightly malformed JSON from Python outputs.
  • Broaden assertPathInsideProject to treat relative paths as project-root-relative while still rejecting paths outside the workspace.
  • Add readProjectText and readProjectJson helpers that enforce project confinement, only allow files, and tolerate NaN/Infinity/-Infinity by sanitizing the JSON before parsing.
  • Expose new IPC handlers quantlab:read-project-text and quantlab:read-project-json in main.js and bridge them into the renderer via preload.js as window.quantlabDesktop.readProjectText/readProjectJson.
desktop/main.js
desktop/preload.js
Add shared CSV parsing and path utilities plus UI affordances to support Experiments previews and selection styling.
  • Add basenamePath (not yet used in this diff) and parseCsvRows utilities, including a CSV line splitter that handles quoted values and an optional row-limit, to renderer/modules/utils.js; export parseCsvRows to be used by buildSweepSummary.
  • Style selected experiment/sweep cards and experiments-specific layouts via new CSS rules (is-selected border highlight, experiments-grid columns, and config-preview height plus responsive behavior).
  • Update placeholder text in the Launch workflow sweep config input to reference configs/experiments instead of configs/sweeps for better alignment with the new workspace.
desktop/renderer/modules/utils.js
desktop/renderer/styles.css
desktop/renderer/index.html

Assessment against linked issues

Issue Objective Addressed Explanation
#206 Expose a native Experiments workspace in QuantLab Desktop oriented around sweep configs and repeatable experiments.
#206 Allow operators to launch sweeps from the desktop shell using experiment configs, staying within existing backend launch capabilities.
#206 Enable reviewing recent sweep-oriented outputs (e.g., leaderboard, walkforward summaries, sweep files) from the same Experiments surface using local workspace data.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 security issues, 3 other issues, and left some high level feedback:

Security issues:

  • User controlled data in methods like innerHTML, outerHTML or document.write is an anti-pattern that can lead to XSS vulnerabilities (link)
  • User controlled data in a elements.tabContent.innerHTML is an anti-pattern that can lead to XSS vulnerabilities (link)

General comments:

  • In buildSweepSummary you construct file paths with hardcoded backslashes (e.g. ${rootPath}\meta.json), which will break on POSIX platforms since the main-process assertPathInsideProject is using path.resolve—switch these to a platform-agnostic form (e.g. always / from the renderer, or pass relative names and join in the main process).
  • refreshExperimentsWorkspace is invoked both from the periodic refreshSnapshot and user actions; consider guarding against concurrent calls (e.g. by reusing the last in-flight promise or tracking a isLoading flag) to avoid overlapping filesystem walks and tab re-renders when the workspace is slow.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `buildSweepSummary` you construct file paths with hardcoded backslashes (e.g. `${rootPath}\meta.json`), which will break on POSIX platforms since the main-process `assertPathInsideProject` is using `path.resolve`—switch these to a platform-agnostic form (e.g. always `/` from the renderer, or pass relative names and join in the main process).
- `refreshExperimentsWorkspace` is invoked both from the periodic `refreshSnapshot` and user actions; consider guarding against concurrent calls (e.g. by reusing the last in-flight promise or tracking a `isLoading` flag) to avoid overlapping filesystem walks and tab re-renders when the workspace is slow.

## Individual Comments

### Comment 1
<location path="desktop/renderer/app.js" line_range="1186-1190" />
<code_context>
+async function buildSweepSummary(entry) {
+  const rootPath = entry.path;
+  const fileListing = await window.quantlabDesktop.listDirectory(rootPath, 0).catch(() => ({ entries: [], truncated: false }));
+  const metaPath = `${rootPath}\\meta.json`;
+  const leaderboardPath = `${rootPath}\\leaderboard.csv`;
+  const experimentsPath = `${rootPath}\\experiments.csv`;
+  const walkforwardSummaryPath = `${rootPath}\\walkforward_summary.csv`;
+  const configResolvedPath = `${rootPath}\\config_resolved.yaml`;
+
+  const [meta, leaderboardText, experimentsText, walkforwardText] = await Promise.all([
</code_context>
<issue_to_address>
**issue (bug_risk):** Building sweep file paths with hardcoded backslashes will break on non‑Windows platforms.

Using `\` here produces literal backslashes in the filenames on POSIX, so `readProjectText/readProjectJson` won’t find `meta.json`, `leaderboard.csv`, etc. Either use forward slashes in the renderer (e.g. ```${rootPath}/meta.json``` which also works on Windows), or have the main process return the full child paths and use those directly instead of recomputing them here.
</issue_to_address>

### Comment 2
<location path="desktop/renderer/modules/tab-renderers.js" line_range="586-589" />
<code_context>
+                    </div>
+                    ${selectedSweepResultRows.map((row) => `
+                      <div class="mini-table-row">
+                        <span class="${escapeHtml(toneClass(Number(row.total_return), true))}">${escapeHtml(formatPercent(Number(row.total_return)))}</span>
+                        <span>${escapeHtml(formatNumber(Number(row.sharpe_simple ?? row.best_test_sharpe)))}</span>
+                        <span class="${escapeHtml(toneClass(Number(row.max_drawdown), false))}">${escapeHtml(formatPercent(Number(row.max_drawdown)))}</span>
+                        <span>${escapeHtml(formatCount(Number(row.trades ?? row.n_test_runs)))}</span>
+                      </div>
+                    `).join("")}
</code_context>
<issue_to_address>
**issue:** Direct `Number(...)` casts on CSV row fields can surface `NaN` in the UI and bypass your `coerceNumber` handling.

These fields are rendered with `Number(row.total_return)`, `Number(row.sharpe_simple ?? row.best_test_sharpe)`, etc. If the CSV has missing or invalid values, this produces `NaN`, which then flows into `formatPercent/formatNumber/toneClass` and can surface as `NaN` in the UI or odd styling. To match `headlineReturn/headlineSharpe/headlineDrawdown`, consider running these through `coerceNumber` (or an equivalent helper) first and falling back to a neutral display when parsing fails.
</issue_to_address>

### Comment 3
<location path="desktop/renderer/modules/tab-renderers.js" line_range="440" />
<code_context>
   `;
 }

+export function renderExperimentsTab(tab, ctx) {
+  const workspace = ctx.experimentsWorkspace || { status: "idle", configs: [], sweeps: [], error: null };
+  const configs = Array.isArray(workspace.configs) ? workspace.configs : [];
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting helper functions and sub-renderers from `renderExperimentsTab` to break up the large inline template and repeated logic into smaller, reusable pieces.

You can keep the new functionality intact but reduce complexity by extracting a few small helpers and sub-renderers. That will shorten `renderExperimentsTab` and localize the inline logic.

### 1) Extract sweep metric chips (used multiple times)

Right now the sweep metrics row is hand-written inline:

```js
<div class="run-row-metrics">
  <span class="metric-chip ${toneClass(sweep.headlineReturn, true)}">Return ${formatPercent(sweep.headlineReturn)}</span>
  <span class="metric-chip">Sharpe ${formatNumber(sweep.headlineSharpe)}</span>
  <span class="metric-chip ${toneClass(sweep.headlineDrawdown, false)}">Drawdown ${formatPercent(sweep.headlineDrawdown)}</span>
  <span class="metric-chip">Runs ${formatCount(sweep.nRuns)}</span>
</div>
```

You can extract this into a helper to avoid duplication and make the sweep card template smaller:

```js
function renderSweepMetricChips(sweep) {
  return `
    <div class="run-row-metrics">
      <span class="metric-chip ${toneClass(sweep.headlineReturn, true)}">
        Return ${formatPercent(sweep.headlineReturn)}
      </span>
      <span class="metric-chip">
        Sharpe ${formatNumber(sweep.headlineSharpe)}
      </span>
      <span class="metric-chip ${toneClass(sweep.headlineDrawdown, false)}">
        Drawdown ${formatPercent(sweep.headlineDrawdown)}
      </span>
      <span class="metric-chip">
        Runs ${formatCount(sweep.nRuns)}
      </span>
    </div>
  `;
}
```

Usage in the sweeps list:

```js
${sweeps.map((sweep) => `
  <article class="candidate-card ${selectedSweep?.run_id === sweep.run_id ? "is-selected" : ""}">
    <div class="run-row-top">
      ...
    </div>
    ${renderSweepMetricChips(sweep)}
    <div class="workflow-actions">
      ...
    </div>
  </article>
`).join("")}
```

### 2) Extract leaderboard and walkforward table row rendering

Both sections repeat a similar `map(...).join("")` pattern with inline formatting. You can move the row logic out into helpers so the main tab renderer reads more like a layout.

Leaderboard rows:

```js
function renderLeaderboardRows(rows) {
  return rows.map((row) => `
    <div class="mini-table-row">
      <span class="${escapeHtml(toneClass(Number(row.total_return), true))}">
        ${escapeHtml(formatPercent(Number(row.total_return)))}
      </span>
      <span>
        ${escapeHtml(formatNumber(Number(row.sharpe_simple ?? row.best_test_sharpe)))}
      </span>
      <span class="${escapeHtml(toneClass(Number(row.max_drawdown), false))}">
        ${escapeHtml(formatPercent(Number(row.max_drawdown)))}
      </span>
      <span>
        ${escapeHtml(formatCount(Number(row.trades ?? row.n_test_runs)))}
      </span>
    </div>
  `).join("");
}
```

Usage:

```js
${selectedSweepResultRows.length ? `
  <div class="mini-table">
    <div class="mini-table-row head">
      <span>Return</span>
      <span>Sharpe</span>
      <span>Drawdown</span>
      <span>Trades</span>
    </div>
    ${renderLeaderboardRows(selectedSweepResultRows)}
  </div>
` : `<div class="empty-state">No leaderboard rows were readable for this sweep.</div>`}
```

Walkforward rows:

```js
function renderWalkforwardRows(rows) {
  return rows.map((row) => `
    <div class="mini-table-row">
      <span>${escapeHtml(row.split_name || "-")}</span>
      <span>${escapeHtml(formatNumber(Number(row.best_test_sharpe)))}</span>
      <span>${escapeHtml(formatPercent(Number(row.best_test_return)))}</span>
      <span>${escapeHtml(formatCount(Number(row.n_selected)))}</span>
    </div>
  `).join("");
}
```

Usage:

```js
${selectedSweep.walkforwardRows?.length ? `
  <div class="mini-table">
    <div class="mini-table-row head">
      <span>Split</span>
      <span>Best test sharpe</span>
      <span>Best test return</span>
      <span>Selected</span>
    </div>
    ${renderWalkforwardRows(selectedSweep.walkforwardRows)}
  </div>
` : `<div class="empty-state">This sweep did not expose walkforward summary rows.</div>`}
```

### 3) Split the main renderer into small, focused sections

You don’t need to fully decompose everything, but pulling out the largest blocks will make `renderExperimentsTab` substantially easier to scan.

For example:

```js
function renderExperimentsHeader(workspace, selectedConfig, selectedSweep, latestSweep) {
  return `
    <div class="artifact-top">
      <div>
        <div class="section-label">Experiment workspace</div>
        <h3>Configs and recent sweeps</h3>
        <div class="artifact-meta">
          Local-first shell surface for launching sweeps, inspecting their outputs,
          and resuming quantitative iteration without leaving QuantLab Desktop.
        </div>
      </div>
      <div class="workflow-actions">
        <button class="ghost-btn" type="button" data-experiments-refresh="true">Refresh</button>
        ${selectedConfig ? `
          <button class="ghost-btn" type="button"
            data-experiment-launch-config="${escapeHtml(selectedConfig.path)}">
            Launch selected config
          </button>
        ` : ""}
        ${selectedSweep ? `
          <button class="ghost-btn" type="button"
            data-experiment-open-path="${escapeHtml(selectedSweep.path)}">
            Open sweep folder
          </button>
        ` : ""}
      </div>
    </div>
  `;
}
```

Then `renderExperimentsTab` becomes more structural:

```js
export function renderExperimentsTab(tab, ctx) {
  const workspace = ctx.experimentsWorkspace || { status: "idle", configs: [], sweeps: [], error: null };
  const configs = Array.isArray(workspace.configs) ? workspace.configs : [];
  const sweeps = Array.isArray(workspace.sweeps) ? workspace.sweeps : [];
  const selectedConfig = configs.find((entry) => entry.path === tab.selectedConfigPath) || configs[0] || null;
  const selectedSweep = sweeps.find((entry) => entry.run_id === tab.selectedSweepId) || sweeps[0] || null;
  const latestSweep = sweeps[0] || null;

  if (workspace.status === "loading" && !configs.length && !sweeps.length) {
    return `<div class="tab-placeholder">Reading experiment configs and recent sweep artifacts from the local workspace...</div>`;
  }

  if (workspace.status === "error" && !configs.length && !sweeps.length) {
    return `<div class="tab-placeholder">${escapeHtml(workspace.error || "Could not read local experiment workspace.")}</div>`;
  }

  const selectedSweepFiles = selectedSweep?.files || [];
  const fileByName = (fileName) => selectedSweepFiles.find((entry) => entry.name === fileName) || null;
  const selectedSweepResultRows = selectedSweep?.topResults?.length
    ? selectedSweep.topResults
    : selectedSweep?.leaderboardRows || [];

  return `
    <div class="tab-shell">
      ${renderExperimentsHeader(workspace, selectedConfig, selectedSweep, latestSweep)}
      ${/* existing summary grid + calls to small helpers for configs/sweeps/sweep details */""}
    </div>
  `;
}
```

From here you can similarly extract `renderExperimentsConfigsPane`, `renderExperimentsSweepsPane`, and `renderSelectedSweepDetails` by moving the corresponding `<section class="artifact-panel">...</section>` blocks into dedicated functions. This keeps behavior identical but flattens the giant template into a few well-named composable pieces.
</issue_to_address>

### Comment 4
<location path="desktop/renderer/app.js" line_range="350" />
<code_context>
    elements.tabContent.innerHTML = renderExperimentsTab(activeTab);
</code_context>
<issue_to_address>
**security (javascript.browser.security.insecure-document-method):** User controlled data in methods like `innerHTML`, `outerHTML` or `document.write` is an anti-pattern that can lead to XSS vulnerabilities

*Source: opengrep*
</issue_to_address>

### Comment 5
<location path="desktop/renderer/app.js" line_range="350" />
<code_context>
    elements.tabContent.innerHTML = renderExperimentsTab(activeTab);
</code_context>
<issue_to_address>
**security (javascript.browser.security.insecure-innerhtml):** User controlled data in a `elements.tabContent.innerHTML` is an anti-pattern that can lead to XSS vulnerabilities

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +1186 to +1190
const metaPath = `${rootPath}\\meta.json`;
const leaderboardPath = `${rootPath}\\leaderboard.csv`;
const experimentsPath = `${rootPath}\\experiments.csv`;
const walkforwardSummaryPath = `${rootPath}\\walkforward_summary.csv`;
const configResolvedPath = `${rootPath}\\config_resolved.yaml`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Building sweep file paths with hardcoded backslashes will break on non‑Windows platforms.

Using \ here produces literal backslashes in the filenames on POSIX, so readProjectText/readProjectJson won’t find meta.json, leaderboard.csv, etc. Either use forward slashes in the renderer (e.g. ${rootPath}/meta.json which also works on Windows), or have the main process return the full child paths and use those directly instead of recomputing them here.

Comment on lines +586 to +589
<span class="${escapeHtml(toneClass(Number(row.total_return), true))}">${escapeHtml(formatPercent(Number(row.total_return)))}</span>
<span>${escapeHtml(formatNumber(Number(row.sharpe_simple ?? row.best_test_sharpe)))}</span>
<span class="${escapeHtml(toneClass(Number(row.max_drawdown), false))}">${escapeHtml(formatPercent(Number(row.max_drawdown)))}</span>
<span>${escapeHtml(formatCount(Number(row.trades ?? row.n_test_runs)))}</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Direct Number(...) casts on CSV row fields can surface NaN in the UI and bypass your coerceNumber handling.

These fields are rendered with Number(row.total_return), Number(row.sharpe_simple ?? row.best_test_sharpe), etc. If the CSV has missing or invalid values, this produces NaN, which then flows into formatPercent/formatNumber/toneClass and can surface as NaN in the UI or odd styling. To match headlineReturn/headlineSharpe/headlineDrawdown, consider running these through coerceNumber (or an equivalent helper) first and falling back to a neutral display when parsing fails.

`;
}

export function renderExperimentsTab(tab, ctx) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting helper functions and sub-renderers from renderExperimentsTab to break up the large inline template and repeated logic into smaller, reusable pieces.

You can keep the new functionality intact but reduce complexity by extracting a few small helpers and sub-renderers. That will shorten renderExperimentsTab and localize the inline logic.

1) Extract sweep metric chips (used multiple times)

Right now the sweep metrics row is hand-written inline:

<div class="run-row-metrics">
  <span class="metric-chip ${toneClass(sweep.headlineReturn, true)}">Return ${formatPercent(sweep.headlineReturn)}</span>
  <span class="metric-chip">Sharpe ${formatNumber(sweep.headlineSharpe)}</span>
  <span class="metric-chip ${toneClass(sweep.headlineDrawdown, false)}">Drawdown ${formatPercent(sweep.headlineDrawdown)}</span>
  <span class="metric-chip">Runs ${formatCount(sweep.nRuns)}</span>
</div>

You can extract this into a helper to avoid duplication and make the sweep card template smaller:

function renderSweepMetricChips(sweep) {
  return `
    <div class="run-row-metrics">
      <span class="metric-chip ${toneClass(sweep.headlineReturn, true)}">
        Return ${formatPercent(sweep.headlineReturn)}
      </span>
      <span class="metric-chip">
        Sharpe ${formatNumber(sweep.headlineSharpe)}
      </span>
      <span class="metric-chip ${toneClass(sweep.headlineDrawdown, false)}">
        Drawdown ${formatPercent(sweep.headlineDrawdown)}
      </span>
      <span class="metric-chip">
        Runs ${formatCount(sweep.nRuns)}
      </span>
    </div>
  `;
}

Usage in the sweeps list:

${sweeps.map((sweep) => `
  <article class="candidate-card ${selectedSweep?.run_id === sweep.run_id ? "is-selected" : ""}">
    <div class="run-row-top">
      ...
    </div>
    ${renderSweepMetricChips(sweep)}
    <div class="workflow-actions">
      ...
    </div>
  </article>
`).join("")}

2) Extract leaderboard and walkforward table row rendering

Both sections repeat a similar map(...).join("") pattern with inline formatting. You can move the row logic out into helpers so the main tab renderer reads more like a layout.

Leaderboard rows:

function renderLeaderboardRows(rows) {
  return rows.map((row) => `
    <div class="mini-table-row">
      <span class="${escapeHtml(toneClass(Number(row.total_return), true))}">
        ${escapeHtml(formatPercent(Number(row.total_return)))}
      </span>
      <span>
        ${escapeHtml(formatNumber(Number(row.sharpe_simple ?? row.best_test_sharpe)))}
      </span>
      <span class="${escapeHtml(toneClass(Number(row.max_drawdown), false))}">
        ${escapeHtml(formatPercent(Number(row.max_drawdown)))}
      </span>
      <span>
        ${escapeHtml(formatCount(Number(row.trades ?? row.n_test_runs)))}
      </span>
    </div>
  `).join("");
}

Usage:

${selectedSweepResultRows.length ? `
  <div class="mini-table">
    <div class="mini-table-row head">
      <span>Return</span>
      <span>Sharpe</span>
      <span>Drawdown</span>
      <span>Trades</span>
    </div>
    ${renderLeaderboardRows(selectedSweepResultRows)}
  </div>
` : `<div class="empty-state">No leaderboard rows were readable for this sweep.</div>`}

Walkforward rows:

function renderWalkforwardRows(rows) {
  return rows.map((row) => `
    <div class="mini-table-row">
      <span>${escapeHtml(row.split_name || "-")}</span>
      <span>${escapeHtml(formatNumber(Number(row.best_test_sharpe)))}</span>
      <span>${escapeHtml(formatPercent(Number(row.best_test_return)))}</span>
      <span>${escapeHtml(formatCount(Number(row.n_selected)))}</span>
    </div>
  `).join("");
}

Usage:

${selectedSweep.walkforwardRows?.length ? `
  <div class="mini-table">
    <div class="mini-table-row head">
      <span>Split</span>
      <span>Best test sharpe</span>
      <span>Best test return</span>
      <span>Selected</span>
    </div>
    ${renderWalkforwardRows(selectedSweep.walkforwardRows)}
  </div>
` : `<div class="empty-state">This sweep did not expose walkforward summary rows.</div>`}

3) Split the main renderer into small, focused sections

You don’t need to fully decompose everything, but pulling out the largest blocks will make renderExperimentsTab substantially easier to scan.

For example:

function renderExperimentsHeader(workspace, selectedConfig, selectedSweep, latestSweep) {
  return `
    <div class="artifact-top">
      <div>
        <div class="section-label">Experiment workspace</div>
        <h3>Configs and recent sweeps</h3>
        <div class="artifact-meta">
          Local-first shell surface for launching sweeps, inspecting their outputs,
          and resuming quantitative iteration without leaving QuantLab Desktop.
        </div>
      </div>
      <div class="workflow-actions">
        <button class="ghost-btn" type="button" data-experiments-refresh="true">Refresh</button>
        ${selectedConfig ? `
          <button class="ghost-btn" type="button"
            data-experiment-launch-config="${escapeHtml(selectedConfig.path)}">
            Launch selected config
          </button>
        ` : ""}
        ${selectedSweep ? `
          <button class="ghost-btn" type="button"
            data-experiment-open-path="${escapeHtml(selectedSweep.path)}">
            Open sweep folder
          </button>
        ` : ""}
      </div>
    </div>
  `;
}

Then renderExperimentsTab becomes more structural:

export function renderExperimentsTab(tab, ctx) {
  const workspace = ctx.experimentsWorkspace || { status: "idle", configs: [], sweeps: [], error: null };
  const configs = Array.isArray(workspace.configs) ? workspace.configs : [];
  const sweeps = Array.isArray(workspace.sweeps) ? workspace.sweeps : [];
  const selectedConfig = configs.find((entry) => entry.path === tab.selectedConfigPath) || configs[0] || null;
  const selectedSweep = sweeps.find((entry) => entry.run_id === tab.selectedSweepId) || sweeps[0] || null;
  const latestSweep = sweeps[0] || null;

  if (workspace.status === "loading" && !configs.length && !sweeps.length) {
    return `<div class="tab-placeholder">Reading experiment configs and recent sweep artifacts from the local workspace...</div>`;
  }

  if (workspace.status === "error" && !configs.length && !sweeps.length) {
    return `<div class="tab-placeholder">${escapeHtml(workspace.error || "Could not read local experiment workspace.")}</div>`;
  }

  const selectedSweepFiles = selectedSweep?.files || [];
  const fileByName = (fileName) => selectedSweepFiles.find((entry) => entry.name === fileName) || null;
  const selectedSweepResultRows = selectedSweep?.topResults?.length
    ? selectedSweep.topResults
    : selectedSweep?.leaderboardRows || [];

  return `
    <div class="tab-shell">
      ${renderExperimentsHeader(workspace, selectedConfig, selectedSweep, latestSweep)}
      ${/* existing summary grid + calls to small helpers for configs/sweeps/sweep details */""}
    </div>
  `;
}

From here you can similarly extract renderExperimentsConfigsPane, renderExperimentsSweepsPane, and renderSelectedSweepDetails by moving the corresponding <section class="artifact-panel">...</section> blocks into dedicated functions. This keeps behavior identical but flattens the giant template into a few well-named composable pieces.

if (activeTab.kind === "iframe") {
elements.tabContent.innerHTML = `<iframe class="tab-frame" src="${escapeHtml(activeTab.url)}" title="${escapeHtml(activeTab.title)}"></iframe>`;
} else if (activeTab.kind === "experiments") {
elements.tabContent.innerHTML = renderExperimentsTab(activeTab);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (javascript.browser.security.insecure-document-method): User controlled data in methods like innerHTML, outerHTML or document.write is an anti-pattern that can lead to XSS vulnerabilities

Source: opengrep

if (activeTab.kind === "iframe") {
elements.tabContent.innerHTML = `<iframe class="tab-frame" src="${escapeHtml(activeTab.url)}" title="${escapeHtml(activeTab.title)}"></iframe>`;
} else if (activeTab.kind === "experiments") {
elements.tabContent.innerHTML = renderExperimentsTab(activeTab);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (javascript.browser.security.insecure-innerhtml): User controlled data in a elements.tabContent.innerHTML is an anti-pattern that can lead to XSS vulnerabilities

Source: opengrep

@Whiteks1 Whiteks1 merged commit 2657771 into main Mar 28, 2026
1 of 2 checks passed
@Whiteks1 Whiteks1 deleted the codex/desktop-experiments-workspace branch March 28, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(desktop): add experiments and sweep workspace v1

1 participant