feat(desktop): add experiments and sweep workspace v1#207
Conversation
Reviewer's GuideAdds 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 renderingsequenceDiagram
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
Sequence diagram for launching a sweep from the Experiments workspacesequenceDiagram
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
Class diagram for main Experiments-related modules and helpersclassDiagram
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
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
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,outerHTMLordocument.writeis an anti-pattern that can lead to XSS vulnerabilities (link) - User controlled data in a
elements.tabContent.innerHTMLis an anti-pattern that can lead to XSS vulnerabilities (link)
General comments:
- In
buildSweepSummaryyou construct file paths with hardcoded backslashes (e.g.${rootPath}\meta.json), which will break on POSIX platforms since the main-processassertPathInsideProjectis usingpath.resolve—switch these to a platform-agnostic form (e.g. always/from the renderer, or pass relative names and join in the main process). refreshExperimentsWorkspaceis invoked both from the periodicrefreshSnapshotand user actions; consider guarding against concurrent calls (e.g. by reusing the last in-flight promise or tracking aisLoadingflag) 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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`; |
There was a problem hiding this comment.
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.
| <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> |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
Summary
This PR:
Experimentsworkspace for local sweep configs and recent sweep outputsconfigs/experimentswithout leaving QuantLab Desktopoutputs/sweepsartifacts locally, including leaderboard and walkforward summary filesWhy
This slice matters because:
hypothesis -> sweep launch -> inspect outputsinstead of forcing that work back into scattered browser pagesScope
This PR does not:
Validation
Validated with:
node --check desktop/main.jsnode --check desktop/preload.jsnode --check desktop/renderer/app.jsnode --check desktop/renderer/modules/utils.jsnode --check desktop/renderer/modules/tab-renderers.jspython -m pytest test/test_research_ui_server.pyNotes
Infinityvalues in local sweepmeta.jsonfiles so walkforward artifacts can still render.configs/experimentsandoutputs/sweepsdirectly 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:
Enhancements:
Documentation: