| 1 | import type React from 'react' |
| 2 | import { CheckCircle2, CircleAlert, Loader2 } from 'lucide-react' |
| 3 | import { Button } from '@renderer/components/ui/Button' |
| 4 | import { ModelSplitButton } from '@renderer/components/model/ModelActionButton' |
| 5 | import { cn } from '@renderer/lib/utils' |
| 6 | import type { ModelActionState } from '@renderer/hooks/useModelAction' |
| 7 | import type { GenerationRunStatus, GenerationStageKey } from './types' |
| 8 | |
| 9 | export function GenerationStatusPanel({ |
| 10 | status, |
| 11 | progress, |
| 12 | stages, |
| 13 | stageLabels, |
| 14 | currentStage, |
| 15 | completedPageCount, |
| 16 | totalPages, |
| 17 | error, |
| 18 | interruptedLabel, |
| 19 | enterEditorLabel, |
| 20 | continueRemainingLabel, |
| 21 | regenerateLabel, |
| 22 | cancelLabel, |
| 23 | isCancelling, |
| 24 | hasGeneratedPages, |
| 25 | canEnterEditor, |
| 26 | showEditorShortcut, |
| 27 | modelAction, |
| 28 | onEnterEditor, |
| 29 | onContinueRemaining, |
| 30 | onRegenerate, |
| 31 | onCancel |
| 32 | }: { |
| 33 | status: GenerationRunStatus |
| 34 | progress: number |
| 35 | stages: readonly GenerationStageKey[] |
| 36 | stageLabels: Record<GenerationStageKey, string> |
| 37 | currentStage: string |
| 38 | completedPageCount: number |
| 39 | totalPages: number |
| 40 | error: string | null |
| 41 | interruptedLabel: string |
| 42 | enterEditorLabel: string |
| 43 | continueRemainingLabel: string |
| 44 | regenerateLabel: string |
| 45 | cancelLabel: string |
| 46 | isCancelling?: boolean |
| 47 | hasGeneratedPages: boolean |
| 48 | canEnterEditor: boolean |
| 49 | showEditorShortcut: boolean |
| 50 | modelAction: ModelActionState |
| 51 | onEnterEditor: () => void |
| 52 | onContinueRemaining: (modelConfigId: string) => void |
| 53 | onRegenerate: (modelConfigId: string) => void |
| 54 | onCancel: () => void |
| 55 | }): React.JSX.Element { |
| 56 | if (status === 'failed' || status === 'cancelled') { |
| 57 | return ( |
| 58 | <div className="mb-4 shrink-0 rounded-lg border border-[#d7b5ae]/80 bg-[#fbf1ee]/86 px-4 py-2.5 text-[#93564f] shadow-[0_8px_20px_rgba(120,73,65,0.08)]"> |
| 59 | <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"> |
| 60 | <div className="flex min-w-0 items-center gap-2.5"> |
| 61 | <CircleAlert className="h-4 w-4 shrink-0" /> |
| 62 | <span className="shrink-0 rounded-md border border-[#d7b5ae]/70 bg-[#fff8f4]/75 px-2 py-1 text-xs font-semibold text-[#8e5a53]"> |
| 63 | {interruptedLabel} |
| 64 | </span> |
| 65 | <span className="min-w-0 truncate text-xs text-[#9b6b63]">{error}</span> |
| 66 | {canEnterEditor && ( |
| 67 | <button |
| 68 | type="button" |
| 69 | onClick={onEnterEditor} |
| 70 | className="shrink-0 text-xs font-medium text-[#6f8159] underline-offset-2 hover:underline" |
| 71 | > |
| 72 | {enterEditorLabel} |
| 73 | </button> |
| 74 | )} |
| 75 | </div> |
| 76 | |
| 77 | <div className="flex shrink-0 flex-wrap gap-1.5"> |
| 78 | <ModelSplitButton |
| 79 | modelAction={modelAction} |
| 80 | label={hasGeneratedPages ? continueRemainingLabel : regenerateLabel} |
| 81 | tone="subtle" |
| 82 | size="sm" |
| 83 | onRun={(modelConfigId) => { |
| 84 | if (hasGeneratedPages) { |
| 85 | onContinueRemaining(modelConfigId) |
| 86 | } else { |
| 87 | onRegenerate(modelConfigId) |
| 88 | } |
| 89 | }} |
| 90 | /> |
| 91 | </div> |
| 92 | </div> |
| 93 | </div> |
| 94 | ) |
| 95 | } |
| 96 | |
| 97 | const activeStageIndex = stages.indexOf(currentStage as GenerationStageKey) |
| 98 | |
| 99 | return ( |
| 100 | <div className="mb-4 shrink-0 rounded-lg border border-[#d8ccb5] bg-[#fff9ef] px-4 py-2 text-[#435138] shadow-[0_12px_28px_rgba(78,91,63,0.13)]"> |
| 101 | <div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:gap-4"> |
| 102 | <div className="min-w-0 flex-1"> |
| 103 | <div className="mb-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-[#617350]"> |
| 104 | <span className="flex min-w-0 flex-wrap items-center gap-2 text-[10px] text-[#7d8b63]"> |
| 105 | {stages.map((stage, index) => { |
| 106 | const isActive = index === activeStageIndex |
| 107 | const isDone = index < activeStageIndex || status === 'completed' |
| 108 | return ( |
| 109 | <span |
| 110 | key={stage} |
| 111 | className={cn( |
| 112 | 'inline-flex items-center gap-1 leading-4', |
| 113 | isDone && 'text-[#5f8a43]', |
| 114 | isActive && 'font-semibold text-[#365528]', |
| 115 | !isDone && !isActive && 'text-[#a09882]' |
| 116 | )} |
| 117 | > |
| 118 | {isDone && <CheckCircle2 className="h-3 w-3" />} |
| 119 | {isActive && (status === 'queued' || status === 'running') && ( |
| 120 | <span className="h-1.5 w-1.5 rounded-full bg-[#4f7b3f]" /> |
| 121 | )} |
| 122 | {stage === 'rendering' && completedPageCount > 0 |
| 123 | ? `${stageLabels[stage]} ${completedPageCount}/${totalPages}` |
| 124 | : stageLabels[stage]} |
| 125 | </span> |
| 126 | ) |
| 127 | })} |
| 128 | </span> |
| 129 | <span className="ml-auto inline-flex shrink-0 items-center gap-2 font-medium"> |
| 130 | <span className="font-semibold">{progress}%</span> |
| 131 | {showEditorShortcut && ( |
| 132 | <Button |
| 133 | size="sm" |
| 134 | className="h-6 rounded-md px-2 text-[10px] shadow-none" |
| 135 | onClick={onEnterEditor} |
| 136 | > |
| 137 | {enterEditorLabel} |
| 138 | </Button> |
| 139 | )} |
| 140 | {(status === 'queued' || status === 'running') && ( |
| 141 | <Button |
| 142 | size="sm" |
| 143 | variant="outline" |
| 144 | className="h-6 rounded-md px-2 text-[10px] shadow-none" |
| 145 | disabled={isCancelling} |
| 146 | aria-busy={isCancelling || undefined} |
| 147 | onClick={onCancel} |
| 148 | > |
| 149 | {isCancelling && <Loader2 className="mr-1 h-3 w-3 animate-spin" />} |
| 150 | {cancelLabel} |
| 151 | </Button> |
| 152 | )} |
| 153 | </span> |
| 154 | </div> |
| 155 | <div className="h-1.5 overflow-hidden rounded-full border border-[#d8ccb5]/80 bg-[#fffaf1] shadow-[inset_0_1px_2px_rgba(74,58,40,0.12)]"> |
| 156 | <div |
| 157 | className="h-full rounded-full bg-[linear-gradient(90deg,#9ecf8a_0%,#6f9f59_52%,#4f7b3f_100%)] bg-[length:200%_100%] transition-[width] duration-500" |
| 158 | style={{ |
| 159 | width: `${Math.max(2, progress)}%`, |
| 160 | animation: 'gen-shimmer-move 2.8s linear infinite' |
| 161 | }} |
| 162 | /> |
| 163 | </div> |
| 164 | </div> |
| 165 | </div> |
| 166 | </div> |
| 167 | ) |
| 168 | } |
| 169 |