Studio: rename GitHub recipe to 'GitHub Scraper' and add Easy mode

Changes the recipe framing from a single-purpose 'Support Bot' pipeline
to a general-purpose scraper that produces {user_request,
grounded_response} training pairs. Aligns with the canonical
github_data_gatherer dataset (11 enrichment tasks mirrored in pr_requests_20
/ issue_requests_20 on the input side and explain_pr / issue_fix_plan /
issue_solution on the output side).

Recipe JSON changes:
- columns[0] renamed normalized_question -> user_request, prompt now
  inverts a GitHub thread into a realistic user ask instead of
  normalising it.
- columns[1] renamed support_answer -> coauthor_response, emits
  {response, followups, cites, task, confidence} and branches on
  issue vs PR thread type.
- Notes rewritten to document the 11-task catalog and the canonical
  production prompt to paste in for a full dataset backfill.

Frontend: Easy mode for github_repo recipes. The drag-and-drop canvas is
hidden behind an 'Advanced' tab; Easy mode is the default for any recipe
whose seed_source_type is github_repo. The Easy form reuses the existing
GithubRepoSeedForm (promoted to exported), adds a rows input bound to
previewRows, a model field bound to the model_config, and a single Run
button that calls runPreview() directly (no modal). Non-github recipes
see the same Editor / Runs tabs as before.

View mode persists per-recipe-id in localStorage under
recipe-studio:view-mode:<recipeId>.
This commit is contained in:
Daniel Han 2026-04-24 15:54:00 +00:00
commit dba29845de
8 changed files with 261 additions and 40 deletions

View file

@ -41,29 +41,30 @@
"columns": [
{
"column_type": "llm-text",
"name": "normalized_question",
"name": "user_request",
"drop": false,
"model_alias": "model_1",
"prompt": "Rewrite the following GitHub {{ item_type }} into a single, standalone Unsloth support question a user would ask on Discord. Keep the key technical details (code snippets, tracebacks, versions) verbatim. Keep it 1-5 sentences. Output ONLY the rewritten question.\n\n--- INPUT ---\nRepo: {{ repo }}\nTitle: {{ title }}\nBody:\n{{ body }}\n\nFirst comments:\n{{ comments }}",
"system_prompt": "You rewrite real GitHub issue / PR threads into concise support questions. Preserve technical fidelity: do not invent facts.",
"prompt": "Read the GitHub {{ item_type }} below and write ONE realistic user request that could have produced it. Imagine a developer asking a GitHub co-author model to either file this {{ item_type }} or draft a PR that resolves it. Use first-person imperative phrasing (\"Open an issue...\", \"Draft a PR that...\", \"Investigate why...\"). Preserve concrete technical details (model names, flags, file paths, tracebacks) that appear in the thread. Keep it 1-3 sentences. Output ONLY the user request, no preamble.\n\n--- INPUT ---\nRepo: {{ repo }}\nType: {{ item_type }}\nTitle: {{ title }}\nBody:\n{{ body }}\n\nFirst comments:\n{{ comments }}",
"system_prompt": "You invert real GitHub threads into the user request that would have produced them. Faithful to the thread, no invented facts, no em-dashes, no emojis.",
"with_trace": "none"
},
{
"column_type": "llm-structured",
"name": "support_answer",
"name": "coauthor_response",
"drop": false,
"model_alias": "model_1",
"prompt": "You are writing one training row for an Unsloth support bot. Produce structured JSON grounded in the GitHub thread.\n\nSource issue / PR:\n- Repo: {{ repo }}\n- Title: {{ title }}\n- URL: {{ url }}\n- State: {{ state }}\n- Labels: {{ labels }}\n- Body: {{ body }}\n- First comments: {{ comments }}\n\nNormalized user question:\n{{ normalized_question }}\n\nRules:\n- `answer`: 80-200 words of Markdown grounded in the thread. Cite the source URL at least once inline as `[source: {{ url }}]`.\n- When the thread is procedural (install, upgrade, fix), include a short ```bash or ```python code block if one appears in the thread.\n- Name at least one concrete symbol (function, class, flag, env var, or file path) from the thread when available.\n- Never recommend `rm -rf`, force push, or other destructive commands without a warning.\n- No em-dashes, no emojis, no AI-disclaimer phrases. Only cite URLs / paths that appear in the thread.\n- `diagnosis_questions`: 1-4 follow-ups when the thread is missing info (versions, GPU, traceback). Empty list if the answer is complete.\n- `cites`: URLs / file paths actually used. Always include `{{ url }}`.\n- `confidence`: `high` / `medium` / `low`. Use `low` when ambiguous or out of scope.",
"system_prompt": "You write grounded Unsloth support answers. Faithful to the thread, no invented facts, no em-dashes, no emojis, no AI-disclaimer phrases.",
"prompt": "You are generating one training row for an Unsloth GitHub co-author model. Given the real GitHub thread and a synthesized user request, produce a grounded structured response.\n\nSource thread:\n- Repo: {{ repo }}\n- Type: {{ item_type }}\n- Title: {{ title }}\n- URL: {{ url }}\n- State: {{ state }}\n- Labels: {{ labels }}\n- Body: {{ body }}\n- First comments: {{ comments }}\n\nUser request:\n{{ user_request }}\n\nRules:\n- `response`: 100-250 words of Markdown grounded in the thread. If the thread is a closed / resolved issue, follow the `issue_fix_plan` shape: brief diagnosis, numbered fix steps, and a short repro. If the thread is a PR, follow the `explain_pr` shape: what changed, why, and which files or symbols were touched. If the thread is open / unresolved, answer honestly and ask for the missing info.\n- Cite the source URL at least once inline as `[source: {{ url }}]`.\n- Name at least one concrete symbol (function, class, flag, env var, or file path) from the thread when available.\n- Include a short ```bash or ```python code block ONLY if the thread itself contains that code or command.\n- Never recommend `rm -rf`, force push, or other destructive commands without an explicit warning.\n- No em-dashes, no emojis, no AI-disclaimer phrases. Only cite URLs / paths that appear in the thread.\n- `followups`: 0-4 follow-up questions when the thread is missing info (versions, GPU, traceback). Empty list if the response is complete.\n- `cites`: URLs / file paths actually used. Always include `{{ url }}`.\n- `task`: one of `explain_pr`, `issue_fix_plan`, `issue_solution`, `discussion_qa`. Pick the closest match.\n- `confidence`: `high` / `medium` / `low`. Use `low` when ambiguous or out of scope.",
"system_prompt": "You write grounded GitHub co-author responses for Unsloth. Faithful to the thread, no invented facts, no em-dashes, no emojis, no AI-disclaimer phrases.",
"output_format": {
"type": "object",
"properties": {
"answer": {"type": "string", "minLength": 1},
"diagnosis_questions": {"type": "array", "items": {"type": "string"}, "maxItems": 4},
"response": {"type": "string", "minLength": 1},
"followups": {"type": "array", "items": {"type": "string"}, "maxItems": 4},
"cites": {"type": "array", "items": {"type": "string"}, "maxItems": 6},
"task": {"type": "string", "enum": ["explain_pr", "issue_fix_plan", "issue_solution", "discussion_qa"]},
"confidence": {"type": "string", "enum": ["high", "medium", "low"]}
},
"required": ["answer", "diagnosis_questions", "cites", "confidence"],
"required": ["response", "followups", "cites", "task", "confidence"],
"additionalProperties": false
}
}
@ -80,8 +81,8 @@
{"id": "Local Model", "x": -1056, "y": 520, "width": 400},
{"id": "model_1", "x": -544, "y": 488, "width": 400},
{"id": "seed", "x": 0, "y": 140, "width": 400},
{"id": "normalized_question", "x": 0, "y": 440, "width": 400},
{"id": "support_answer", "x": 0, "y": 740, "width": 400},
{"id": "user_request", "x": 0, "y": 440, "width": 400},
{"id": "coauthor_response", "x": 0, "y": 740, "width": 400},
{
"id": "note_1",
"x": 480,
@ -89,7 +90,7 @@
"width": 400,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "### GitHub Support Bot\nReal GitHub data to synthetic Q&A pairs for support-bot fine-tuning.\n\n**Click `Run` below to generate 10 sample rows.** Defaults already point at `unslothai/unsloth` + `unslothai/unsloth-zoo`, use the server's `GH_TOKEN` env var, and run the bundled local model.\n\n**Upgrade to production**\n- Swap `unsloth/gemma-4-E2B-it-GGUF` for a larger model in the model_config node.\n- Replace the demo prompts on `normalized_question` / `support_answer` with the canonical codex pattern below (see Note 3).\n- Raise the seed `limit` from 100 to `0` (All) for a full backfill.\n- Raise `max_parallel_requests` back to 4 once your inference server can handle it.",
"markdown": "### GitHub Scraper\nReal GitHub issues and PRs turned into `{user_request, grounded_response}` training pairs. Mirrors two of the eleven canonical enrichment tasks in the `github_data_gatherer` dataset: `pr_requests_20` / `issue_requests_20` for the input side, and `explain_pr` / `issue_fix_plan` / `issue_solution` for the output side.\n\n**Click `Run` below for 10 sample rows.** Defaults point at `unslothai/unsloth` + `unslothai/unsloth-zoo`, use the server's `GH_TOKEN` env var, and run the bundled local model.\n\n**Upgrade to production**\n- Swap `unsloth/gemma-4-E2B-it-GGUF` for a stronger model (`gpt-5.4-mini` with `reasoning_effort=medium` is what the reference dataset uses).\n- Replace the demo prompts with the task-specific prompts from the reference dataset (see Note 3).\n- Raise the seed `limit` from 100 to `0` (All) for a full backfill.\n- Raise `max_parallel_requests` to 4 once the inference server can handle it.",
"note_color": "#E0F2FE",
"note_opacity": "35"
},
@ -100,7 +101,7 @@
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "The **normalize** step turns a raw GitHub thread (which often has title/body/comments scattered) into a single clean support question.\n\nTweak the prompt to:\n- always include the traceback\n- drop off-topic chitchat\n- target a specific user persona",
"markdown": "The **user_request** column inverts each GitHub thread into a realistic request a developer would give a co-author model (`\"Draft a PR that...\"`, `\"Investigate why...\"`). Same shape as the `pr_requests_20` and `issue_requests_20` enrichments.\n\nTweak the prompt to:\n- always keep the traceback verbatim\n- vary persona (newcomer, maintainer, ops)\n- split one thread into multiple alternative phrasings for data augmentation.",
"note_color": "#E0F2FE",
"note_opacity": "35"
},
@ -111,17 +112,17 @@
"width": 400,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "The **answer** block produces `{answer, diagnosis_questions, cites, confidence}`, ready to feed into the grounded RAG answerer.\n\n**Demo default**: 80-200 word answer, one inline `[source: <url>]` cite, `max_parallel_requests=1` so a small local model stays stable.\n\n**Production prompt (paste in):**\n- Require 150-300 word answers with 2+ citations.\n- Enforce named symbols (function / flag / env var / file path).\n- Code fences for every procedural step.\n- Use the resolved-issue / merged-PR template: `What changed / Fix location / Minimum repro`.\n- Reject rows containing em-dashes, emojis, or AI-disclaimer phrases.\n\nSee the Unsloth SupportBot dataset card for the full codex prompt we used to train `Gemma-4-Unsloth-Bot`.",
"markdown": "The **coauthor_response** block emits `{response, followups, cites, task, confidence}` and branches on thread type: closed issues become `issue_fix_plan` rows, PRs become `explain_pr` rows, everything else becomes `issue_solution` or `discussion_qa`.\n\n**Demo default**: 100-250 word response, one inline `[source: <url>]` cite, `max_parallel_requests=1` so a small local model stays stable.\n\n**Production prompt (paste in):**\n- Match the reference dataset's per-task prompts (`explain_pr`, `issue_fix_plan`, `pr_review_critique`, `pr_test_plan`, etc.).\n- Require 300 words for explanations, 6-12 bullets for test plans.\n- Enforce named symbols (function / flag / env var / file path).\n- Code fences only for content already in the thread.\n- Reject rows with em-dashes, emojis, or AI-disclaimer phrases.\n\nSee the `github_data_gatherer` dataset card for the full task catalog and the codex prompts used to train the reference GitHub model.",
"note_color": "#E0F2FE",
"note_opacity": "35"
}
],
"edges": [
{"from": "seed", "to": "normalized_question", "type": "canvas", "source_handle": "data-out-bottom", "target_handle": "data-in-top"},
{"from": "normalized_question", "to": "support_answer", "type": "canvas", "source_handle": "data-out-bottom", "target_handle": "data-in-top"},
{"from": "seed", "to": "user_request", "type": "canvas", "source_handle": "data-out-bottom", "target_handle": "data-in-top"},
{"from": "user_request", "to": "coauthor_response", "type": "canvas", "source_handle": "data-out-bottom", "target_handle": "data-in-top"},
{"from": "Local Model", "to": "model_1", "type": "semantic", "source_handle": "semantic-out", "target_handle": "semantic-in"},
{"from": "model_1", "to": "normalized_question", "type": "semantic", "source_handle": "semantic-out", "target_handle": "data-in"},
{"from": "model_1", "to": "support_answer", "type": "semantic", "source_handle": "semantic-out-bottom", "target_handle": "data-in"}
{"from": "model_1", "to": "user_request", "type": "semantic", "source_handle": "semantic-out", "target_handle": "data-in"},
{"from": "model_1", "to": "coauthor_response", "type": "semantic", "source_handle": "semantic-out-bottom", "target_handle": "data-in"}
],
"layout_direction": "LR"
}

View file

@ -143,9 +143,9 @@ export const LEARNING_RECIPES: LearningRecipeDef[] = [
},
{
id: "github-support-bot",
title: "GitHub Support Bot",
title: "GitHub Scraper",
description:
"Generate realistic Unsloth support Q&A (question + structured answer with citations) for fine-tuning a support assistant.",
"Scrape real GitHub issues and PRs and turn each thread into a {user_request, grounded_response} training pair.",
loadPayload: () => loadPayloadFromUrl(githubSupportBotUrl),
},
];

View file

@ -164,9 +164,9 @@ const TEMPLATE_CARDS: TemplateCard[] = [
learningRecipeId: "structured-outputs-jinja",
},
{
title: "GitHub Support Bot",
title: "GitHub Scraper",
description:
"Scrape real GitHub issues / PRs / commits (multi-repo) and turn each into a normalized question + structured answer for fine-tuning a support assistant.",
"Scrape real GitHub issues and PRs and invert each thread into a {user_request, grounded_response} training pair.",
icon: GithubIcon,
difficulty: "Intermediate",
learningBadges: ["GitHub", "LLM Text", "Structured LLM"],

View file

@ -34,6 +34,7 @@ type RecipeStudioHeaderProps = {
savedAtLabel: string;
workflowName: string;
warnings?: GraphWarning[];
supportsEasyMode?: boolean;
onWorkflowNameChange: (value: string) => void;
onViewChange: (view: RecipeStudioView) => void;
onSaveRecipe: () => void;
@ -51,6 +52,7 @@ export function RecipeStudioHeader({
savedAtLabel,
workflowName,
warnings = [],
supportsEasyMode = false,
onWorkflowNameChange,
onViewChange,
onSaveRecipe,
@ -58,7 +60,7 @@ export function RecipeStudioHeader({
const [editingWorkflowName, setEditingWorkflowName] = useState(false);
function handleViewValueChange(value: string): void {
if (value === "editor" || value === "executions") {
if (value === "easy" || value === "editor" || value === "executions") {
onViewChange(value);
}
}
@ -130,7 +132,12 @@ export function RecipeStudioHeader({
<div className="justify-self-center">
<Tabs value={activeView} onValueChange={handleViewValueChange}>
<TabsList>
<TabsTrigger value="editor">Editor</TabsTrigger>
{supportsEasyMode && (
<TabsTrigger value="easy">Easy</TabsTrigger>
)}
<TabsTrigger value="editor">
{supportsEasyMode ? "Advanced" : "Editor"}
</TabsTrigger>
<TabsTrigger value="executions">Runs</TabsTrigger>
</TabsList>
</Tabs>

View file

@ -75,7 +75,7 @@ type SeedDialogProps = {
open: boolean;
};
function GithubRepoSeedForm({
export function GithubRepoSeedForm({
config,
onUpdate,
}: {
@ -84,12 +84,13 @@ function GithubRepoSeedForm({
}): ReactElement {
const [advancedOpen, setAdvancedOpen] = useState(false);
const limitStr = (config.github_limit ?? "100").trim();
const allMode = limitStr === "" || limitStr === "0";
return (
<div className="space-y-3 rounded-xl corner-squircle border border-border/60 p-3">
<div className="grid gap-1.5">
<FieldLabel
label="GitHub repositories"
hint="One owner/name per line."
hint="One owner/name per line. Defaults to the two Unsloth repos."
/>
<textarea
className="nodrag min-h-20 w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 text-xs font-mono"
@ -101,7 +102,7 @@ function GithubRepoSeedForm({
<div className="grid gap-1.5">
<FieldLabel
label="GitHub token"
hint="Use public_repo for public repos or repo for private repos. Leave blank to use the server's GH_TOKEN / GITHUB_TOKEN env var."
hint="Personal access token with repo scope. Leave blank to use the server's GH_TOKEN / GITHUB_TOKEN env var."
/>
<Input
type="password"
@ -114,17 +115,29 @@ function GithubRepoSeedForm({
<div className="grid gap-1.5">
<FieldLabel
label="Items per repo"
hint="How many issues/PRs/commits to fetch from each repo (1-5000)."
/>
<Input
type="number"
className="nodrag"
min={1}
max={5000}
value={limitStr}
onChange={(e) => onUpdate({ github_limit: e.target.value })}
placeholder="100"
hint="How many issues/PRs/commits to fetch from each repo. Toggle All to scrape everything."
/>
<div className="flex items-center gap-2">
<Input
type="number"
className="nodrag flex-1"
min={1}
max={100000}
disabled={allMode}
value={allMode ? "" : limitStr}
onChange={(e) => onUpdate({ github_limit: e.target.value })}
placeholder={allMode ? "All" : "100"}
/>
<label className="flex cursor-pointer items-center gap-1.5 text-xs">
<Checkbox
checked={allMode}
onCheckedChange={(v) =>
onUpdate({ github_limit: v === true ? "0" : "100" })
}
/>
<span>All</span>
</label>
</div>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>

View file

@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { GithubIcon, PlayCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FieldLabel } from "../dialogs/shared/field-label";
import { GithubRepoSeedForm } from "../dialogs/seed/seed-dialog";
import type { ModelConfig, NodeConfig, SeedConfig } from "../types";
type GithubScraperEasyViewProps = {
configs: Record<string, NodeConfig>;
previewRows: number;
setPreviewRows: (rows: number) => void;
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
onRun: () => void;
runLoading: boolean;
runErrors: string[];
onSwitchToAdvanced: () => void;
};
export function GithubScraperEasyView({
configs,
previewRows,
setPreviewRows,
updateConfig,
onRun,
runLoading,
runErrors,
onSwitchToAdvanced,
}: GithubScraperEasyViewProps): ReactElement {
const seedConfig = useMemo(
() =>
Object.values(configs).find(
(c): c is SeedConfig => c.kind === "seed",
) ?? null,
[configs],
);
const modelConfig = useMemo(
() =>
Object.values(configs).find(
(c): c is ModelConfig => c.kind === "model_config",
) ?? null,
[configs],
);
const handleSeedUpdate = (patch: Partial<SeedConfig>): void => {
if (!seedConfig) return;
updateConfig(seedConfig.id, patch);
};
const handleModelChange = (value: string): void => {
if (!modelConfig) return;
updateConfig(modelConfig.id, { model: value });
};
if (!seedConfig) {
return (
<div className="flex h-full items-center justify-center px-6">
<p className="text-sm text-muted-foreground">
This recipe has no seed node. Switch to{" "}
<button
type="button"
className="underline hover:text-foreground"
onClick={onSwitchToAdvanced}
>
Advanced
</button>{" "}
to configure it.
</p>
</div>
);
}
return (
<div className="mx-auto flex h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto px-6 py-6">
<div className="flex items-start gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg corner-squircle border border-border/70 bg-muted/20"
aria-hidden={true}
>
<HugeiconsIcon icon={GithubIcon} className="size-5" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold">GitHub Scraper</h2>
<p className="text-xs text-muted-foreground">
Scrape real GitHub issues and PRs and turn each thread into a{" "}
<code>{"{user_request, grounded_response}"}</code> training pair.
Defaults use the server's <code>GH_TOKEN</code> env var and the
bundled local model.
</p>
</div>
</div>
<GithubRepoSeedForm config={seedConfig} onUpdate={handleSeedUpdate} />
<div className="space-y-3 rounded-xl corner-squircle border border-border/60 p-3">
<div className="grid gap-1.5">
<FieldLabel
label="Rows to generate"
hint="How many training pairs the LLM should produce from the scraped threads."
/>
<Input
type="number"
className="nodrag"
min={1}
max={10000}
value={previewRows}
onChange={(event) => {
const next = Number.parseInt(event.target.value, 10);
setPreviewRows(Number.isFinite(next) && next > 0 ? next : 1);
}}
/>
</div>
<div className="grid gap-1.5">
<FieldLabel
label="Model"
hint="Any OpenAI-compatible model id. Local GGUFs (e.g. unsloth/...-GGUF) run on the bundled llama-server."
/>
<Input
className="nodrag font-mono text-xs"
value={modelConfig?.model ?? ""}
onChange={(event) => handleModelChange(event.target.value)}
placeholder="unsloth/gemma-4-E2B-it-GGUF"
disabled={!modelConfig}
/>
</div>
</div>
{runErrors.length > 0 && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<p className="font-semibold">Cannot run:</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{runErrors.slice(0, 4).map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
className="text-xs text-muted-foreground underline hover:text-foreground"
onClick={onSwitchToAdvanced}
>
Advanced (drag and drop canvas)
</button>
<Button
type="button"
size="lg"
onClick={onRun}
disabled={runLoading}
>
<HugeiconsIcon icon={PlayCircleIcon} className="size-4" />
{runLoading ? "Running..." : "Run"}
</Button>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export type RecipeStudioView = "editor" | "executions";
export type RecipeStudioView = "easy" | "editor" | "executions";
export type RecipeExecutionKind = "preview" | "full";

View file

@ -47,6 +47,7 @@ import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { RunDialog } from "./dialogs/preview-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
import { GithubScraperEasyView } from "./easy/github-scraper-easy-view";
import type {
RecipeExecutionRecord,
RecipeStudioView,
@ -201,7 +202,28 @@ export function RecipeStudioPage({
null,
);
const flowContainerRef = useRef<HTMLDivElement | null>(null);
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const supportsEasyMode = initialPayload?.ui?.seed_source_type === "github_repo";
const viewModeStorageKey = `recipe-studio:view-mode:${recipeId}`;
const [activeView, setActiveViewState] = useState<RecipeStudioView>(() => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem(viewModeStorageKey);
if (stored === "easy" && supportsEasyMode) return "easy";
if (stored === "editor" || stored === "executions") return stored;
}
return supportsEasyMode ? "easy" : "editor";
});
const setActiveView = useCallback(
(next: RecipeStudioView | ((prev: RecipeStudioView) => RecipeStudioView)) => {
setActiveViewState((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
if (typeof window !== "undefined") {
window.localStorage.setItem(viewModeStorageKey, resolved);
}
return resolved;
});
},
[viewModeStorageKey],
);
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false);
@ -326,6 +348,7 @@ export function RecipeStudioPage({
validateResult,
cancelExecution,
loadExecutionDatasetPage,
runPreview,
copyRecipe,
importRecipe,
} = useRecipeStudioActions({
@ -739,6 +762,7 @@ export function RecipeStudioPage({
savedAtLabel={savedAtLabel}
workflowName={workflowName}
warnings={getGraphWarnings(configs, edges)}
supportsEasyMode={supportsEasyMode}
onWorkflowNameChange={setWorkflowName}
onViewChange={setActiveView}
onSaveRecipe={() => {
@ -749,7 +773,20 @@ export function RecipeStudioPage({
className="h-[75vh] w-full rounded-t-none"
ref={flowContainerRef}
>
{activeView === "editor" ? (
{activeView === "easy" ? (
<GithubScraperEasyView
configs={configs}
previewRows={previewRows}
setPreviewRows={setPreviewRows}
updateConfig={updateConfig}
onRun={() => {
void runPreview();
}}
runLoading={previewLoading || executionLocked}
runErrors={runErrors}
onSwitchToAdvanced={() => setActiveView("editor")}
/>
) : activeView === "editor" ? (
editorContent
) : (
<ExecutionsView