diff --git a/client/src/components/pipeline/stages/TextStagePanel.jsx b/client/src/components/pipeline/stages/TextStagePanel.jsx
index d213c4c65e..906a54ade2 100644
--- a/client/src/components/pipeline/stages/TextStagePanel.jsx
+++ b/client/src/components/pipeline/stages/TextStagePanel.jsx
@@ -5,18 +5,22 @@
* generate button that calls the server's text-stage runner.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Loader2, Sparkles, Save, History } from 'lucide-react';
import toast from '../../ui/Toast';
import {
generatePipelineStage, updatePipelineIssue,
PIPELINE_STAGE_LABELS,
+ PIPELINE_TEXT_STAGES,
+ PIPELINE_DEFAULT_FORWARD_SOURCE as DEFAULT_FORWARD_SOURCE,
PIPELINE_STAGE_STATUS_LABEL as STATUS_LABEL,
PIPELINE_STAGE_STATUS_COLOR as STATUS_COLOR,
} from '../../../services/api';
import { useAsyncAction } from '../../../hooks/useAsyncAction';
import StageHistoryModal from './StageHistoryModal';
+const stageHasContent = (stage) => Boolean(stage?.input?.trim() || stage?.output?.trim());
+
export default function TextStagePanel({
issue,
series,
@@ -38,6 +42,29 @@ export default function TextStagePanel({
const [historyOpen, setHistoryOpen] = useState(false);
const runHistory = stage.runHistory || [];
+ // Other text stages that currently have content — the candidate source
+ // material for this generation. Excludes the target stage itself. Lets you
+ // generate any stage FROM any other populated stage (backport), e.g. prose
+ // from a comic script. Ordered by the canonical stage order.
+ const availableSources = useMemo(
+ () => PIPELINE_TEXT_STAGES.filter(
+ (id) => id !== stageId && stageHasContent(issue.stages?.[id]),
+ ),
+ [issue.stages, stageId],
+ );
+
+ // Selected source stage ids. Defaults to the conventional forward source(s)
+ // that exist; recomputed whenever the candidate set changes (issue/stage swap).
+ const [selectedSources, setSelectedSources] = useState([]);
+ useEffect(() => {
+ const preferred = (DEFAULT_FORWARD_SOURCE[stageId] || []).filter((id) => availableSources.includes(id));
+ setSelectedSources(preferred);
+ }, [issue.id, stageId, availableSources]);
+
+ const toggleSource = (id) => setSelectedSources(
+ (prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]),
+ );
+
// Reset local edits when the stage record changes from the parent (e.g.
// auto-run pushed a new output).
useEffect(() => {
@@ -51,6 +78,9 @@ export default function TextStagePanel({
seedInput: draftInput,
providerId: series?.llm?.provider || undefined,
model: series?.llm?.model || undefined,
+ // Only send when there's a real choice to make — omitting it lets the
+ // server fall back to the conventional forward source (unchanged behavior).
+ ...(availableSources.length ? { sourceStageIds: selectedSources } : {}),
}),
{ errorMessage: `Failed to generate ${stageId}` },
);
@@ -131,6 +161,30 @@ export default function TextStagePanel({
+ {availableSources.length > 0 ? (
+