Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions dashboard/src/v2/components/sprints/SprintActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Pause,
Pencil,
Play,
RotateCcw,
Sparkles,
Square,
XCircle,
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface SprintActionMenuProps {
onToggleShowcase?: () => void;
onOverrides?: () => void;
onMarkCompleted?: () => void;
onRollback?: () => void;
onDelete?: () => void;
onClose?: () => void;
markCompletedIcon?: "square" | "circle";
Expand Down Expand Up @@ -68,6 +70,7 @@ export const SprintActionMenu: FunctionComponent<SprintActionMenuProps> = ({
onToggleShowcase,
onOverrides,
onMarkCompleted,
onRollback,
onDelete,
onClose,
markCompletedIcon = "circle",
Expand Down Expand Up @@ -301,6 +304,20 @@ export const SprintActionMenu: FunctionComponent<SprintActionMenuProps> = ({
Mark Completed
</button>
)}
{isCompleted && sprint.kind !== "rollback" && onRollback && (
<button
type="button"
role={role}
onClick={() => {
onClose?.();
onRollback();
}}
className={`${buttonClassName} text-orange-600 hover:bg-orange-500/10 dark:text-orange-300`}
>
<RotateCcw className="h-3.5 w-3.5" strokeWidth={2.1} />
Rollback Sprint
</button>
)}
<button
type="button"
role={role}
Expand Down
20 changes: 16 additions & 4 deletions dashboard/src/v2/components/sprints/SprintCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Maximize2,
MoreVertical,
Play,
RotateCcw,
Sparkles,
Square,
XCircle,
Expand Down Expand Up @@ -81,6 +82,7 @@ interface SprintCellProps {
onOverrides?: () => void;
onToggleShowcase?: () => void;
onMarkCompleted?: () => void;
onRollback?: () => void;
}

const formatSprintKey = (sprint: Sprint, prefix: string = "SPR"): string => (
Expand Down Expand Up @@ -114,6 +116,7 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({
onOverrides,
onToggleShowcase,
onMarkCompleted,
onRollback,
}) => {
const reducedMotion = useReducedMotion();
const interactionTokens = useInteractionTokens();
Expand All @@ -122,6 +125,7 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({
const bubbleRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const state = statusMap[sprint.status];
const isRollback = sprint.kind === "rollback";
const statusPresentation = getSprintStatusPresentation({
state: sprint.status,
humanInterventionTitle: humanIntervention?.title ?? null,
Expand All @@ -139,7 +143,7 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({

let effectiveLabel = statusPresentation.statusLabel;
let effectiveTextTone = state.text;
let effectiveAccentHex = state.accentHex;
let effectiveAccentHex = isRollback ? "#F97316" : state.accentHex;
let StatusIcon = state.icon;

if (effectiveLabel === "QA") {
Expand Down Expand Up @@ -226,6 +230,7 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({
<div
ref={bubbleRef}
data-sprint-attention={attentionIndicatorState?.kind}
data-sprint-kind={sprint.kind}
onMouseEnter={handleHoverEnter}
onMouseLeave={handleHoverLeave}
className="group relative flex h-72 w-72 shrink-0 cursor-pointer items-center justify-center perspective-1000 transition-transform duration-150 [@media(hover:hover)]:hover:-translate-y-px motion-reduce:transition-none motion-reduce:hover:transform-none lg:h-80 lg:w-80"
Expand All @@ -237,16 +242,16 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({
style={listReorderStyle}
>
<div
className={`absolute inset-0 overflow-hidden rounded-[inherit] border border-white/70 backdrop-blur-md transition-colors dark:border-white/[0.06] ${isRunning ? "bg-white/72 dark:bg-void-800/82" : "bg-white/55 dark:bg-void-800/65"}`}
className={`absolute inset-0 overflow-hidden rounded-[inherit] border backdrop-blur-md transition-colors ${isRollback ? "border-orange-400/35 bg-orange-50/78 dark:border-orange-400/25 dark:bg-orange-950/24" : `border-white/70 dark:border-white/[0.06] ${isRunning ? "bg-white/72 dark:bg-void-800/82" : "bg-white/55 dark:bg-void-800/65"}`}`}
style={{
...listReorderStyle,
WebkitMaskImage: "-webkit-radial-gradient(white, black)",
backfaceVisibility: "hidden",
}}
>
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.5)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" />
<WaveFluid accentHex={state.accentHex} />
<BorderTrace accentHex={state.accentHex} />
<WaveFluid accentHex={effectiveAccentHex} />
<BorderTrace accentHex={effectiveAccentHex} />
</div>
</div>

Expand Down Expand Up @@ -302,6 +307,12 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({
)}

<div className="relative z-20 flex h-full w-full flex-col items-center justify-center p-8 text-center">
{isRollback && (
<div className="absolute left-1/2 top-5 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-orange-500/25 bg-orange-500/10 px-3 py-1 text-[9px] font-bold uppercase tracking-[0.16em] text-orange-700 dark:text-orange-300">
<RotateCcw className="h-3 w-3" strokeWidth={2.2} />
Rollback
</div>
)}
{attentionIndicatorState && (
<SprintAttentionIndicator
state={attentionIndicatorState}
Expand Down Expand Up @@ -441,6 +452,7 @@ export const SprintCell: FunctionComponent<SprintCellProps> = ({
onToggleShowcase={onToggleShowcase}
onOverrides={onOverrides}
onMarkCompleted={onMarkCompleted}
onRollback={onRollback}
onDelete={onDelete}
onClose={() => setMenuOpen(false)}
markCompletedIcon="circle"
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/v2/components/sprints/SprintLedger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface SprintLedgerProps {
onExportSprint: (sprint: Sprint) => void;
onOverridesSprint: (sprint: Sprint) => void;
onMarkCompletedSprint: (sprintId: string) => void;
onRollbackSprint?: (sprint: Sprint) => void;
onDeleteSprint: (sprintId: string) => void;
onBulkShowcaseEnable: (sprintIds: string[]) => void;
onBulkShowcaseDisable: (sprintIds: string[]) => void;
Expand All @@ -90,6 +91,7 @@ const SprintLedgerComponent: FunctionComponent<SprintLedgerProps> = ({
onExportSprint,
onOverridesSprint,
onMarkCompletedSprint,
onRollbackSprint,
onDeleteSprint,
onBulkStart,
onBulkDelete,
Expand Down Expand Up @@ -692,6 +694,7 @@ const SprintLedgerComponent: FunctionComponent<SprintLedgerProps> = ({
onExport={() => onExportSprint(sprint)}
onOverrides={() => onOverridesSprint(sprint)}
onMarkCompleted={() => onMarkCompletedSprint(sprint.id)}
onRollback={onRollbackSprint ? () => onRollbackSprint(sprint) : undefined}
onDelete={() => { void handleDeleteSprint(sprint); }}
/>
))
Expand Down
17 changes: 16 additions & 1 deletion dashboard/src/v2/components/sprints/SprintLedgerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MoreVertical,
Pause,
Play,
RotateCcw,
Square,
} from "lucide-preact";
import { useState, useRef, useEffect } from "preact/hooks";
Expand Down Expand Up @@ -116,6 +117,7 @@ export interface SprintLedgerRowProps {
onExport: () => void;
onOverrides: () => void;
onMarkCompleted: () => void;
onRollback?: () => void;
onDelete: () => void;
}

Expand All @@ -141,6 +143,7 @@ const SprintLedgerRowComponent: FunctionComponent<SprintLedgerRowProps> = ({
onExport,
onOverrides,
onMarkCompleted,
onRollback,
onDelete,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
Expand Down Expand Up @@ -201,13 +204,17 @@ const SprintLedgerRowComponent: FunctionComponent<SprintLedgerRowProps> = ({

const rowTone = attentionIndicatorState
? "border-status-red/55 bg-status-red/[0.055] shadow-[0_14px_36px_rgba(227,0,15,0.14)]"
: sprint.kind === "rollback"
? "border-orange-500/30 bg-orange-500/[0.07] shadow-[0_14px_36px_rgba(249,115,22,0.1)]"
: isSelected
? "border-signal-500/35 bg-signal-500/[0.08] shadow-[0_18px_44px_rgba(0,224,160,0.12)]"
: isEven
? "border-black/[0.06] bg-white/80 dark:border-white/[0.07] dark:bg-white/[0.045]"
: "border-black/[0.06] bg-slate-50/80 dark:border-white/[0.07] dark:bg-white/[0.03]";
const desktopCellTone = attentionIndicatorState
? "lg:border-status-red/45 lg:bg-status-red/[0.045] dark:lg:border-status-red/45 dark:lg:bg-status-red/[0.06]"
: sprint.kind === "rollback"
? "lg:border-orange-500/25 lg:bg-orange-500/[0.065] dark:lg:border-orange-400/20 dark:lg:bg-orange-500/[0.08]"
: isSelected
? "lg:border-signal-500/25 lg:bg-signal-500/[0.08]"
: isEven
Expand Down Expand Up @@ -352,7 +359,14 @@ const SprintLedgerRowComponent: FunctionComponent<SprintLedgerRowProps> = ({
</TableCell>
<TableCell className={`min-w-0 max-w-full lg:w-[220px] lg:min-w-[220px] ${desktopCellTone}`} mobileLabel="Sprint">
<div className="flex flex-wrap items-center gap-2">
<div className={`font-display text-base font-semibold leading-tight break-words ${isCompleted ? "text-slate-700 dark:text-slate-300" : "text-[var(--text-primary)]"}`}>{sprint.name}</div>
<div className="flex flex-wrap items-center gap-2">
<div className={`font-display text-base font-semibold leading-tight break-words ${isCompleted ? "text-slate-700 dark:text-slate-300" : "text-[var(--text-primary)]"}`}>{sprint.name}</div>
{sprint.kind === "rollback" && (
<span className="inline-flex items-center gap-1 rounded-full border border-orange-500/25 bg-orange-500/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.13em] text-orange-700 dark:text-orange-300">
<RotateCcw className="h-2.5 w-2.5" /> Rollback
</span>
)}
</div>
{attentionIndicatorState && (
<SprintAttentionIndicator state={attentionIndicatorState} compact />
)}
Expand Down Expand Up @@ -583,6 +597,7 @@ const SprintLedgerRowComponent: FunctionComponent<SprintLedgerRowProps> = ({
onToggleShowcase={() => onToggleShowcase(sprint)}
onOverrides={onOverrides}
onMarkCompleted={onMarkCompleted}
onRollback={onRollback}
onDelete={onDelete}
onClose={() => setMenuOpen(false)}
markCompletedIcon="square"
Expand Down
162 changes: 162 additions & 0 deletions dashboard/src/v2/components/sprints/SprintRollbackModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { FunctionComponent } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { AlertTriangle, Bot, Loader2, RotateCcw, ShieldCheck, X } from "lucide-preact";
import type { Sprint, SprintRollbackAssessment } from "../../types.js";
import { assessSprintRollback, createSprintRollback } from "../../lib/project-api.js";
import { Modal } from "../ui/Modal.js";

interface SprintRollbackModalProps {
sprint: Sprint | null;
onClose: () => void;
onCreated: () => Promise<void> | void;
}

export const SprintRollbackModal: FunctionComponent<SprintRollbackModalProps> = ({
sprint,
onClose,
onCreated,
}) => {
const [assessment, setAssessment] = useState<SprintRollbackAssessment | null>(null);
const [instructions, setInstructions] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
if (!sprint) {
setAssessment(null);
setInstructions("");
setError(null);
setSubmitting(false);
return;
}
const controller = new AbortController();
setAssessment(null);
setError(null);
void assessSprintRollback(sprint.projectId, sprint.id, controller.signal)
.then(setAssessment)
.catch((reason) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : String(reason));
}
});
return () => controller.abort();
}, [sprint?.id, sprint?.projectId]);

const customScope = instructions.trim().length > 0;
const effectiveMode = customScope ? "agent_assisted" : assessment?.recommendedMode;

const submit = async (): Promise<void> => {
if (!sprint || !assessment?.eligible || submitting) return;
setSubmitting(true);
setError(null);
try {
await createSprintRollback(sprint.projectId, sprint.id, instructions);
await onCreated();
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setSubmitting(false);
}
};

return (
<Modal
isOpen={Boolean(sprint)}
onClose={submitting ? () => undefined : onClose}
disableBackdropClick={submitting}
ariaLabelledBy="sprint-rollback-title"
ariaDescribedBy="sprint-rollback-description"
initialFocusRef={textareaRef}
className="w-[min(44rem,calc(100vw-2rem))] overflow-hidden rounded-[1.7rem]"
>
<div className="relative overflow-hidden border-b border-black/[0.06] bg-[radial-gradient(circle_at_10%_0%,rgba(249,115,22,0.16),transparent_48%),linear-gradient(135deg,rgba(15,23,42,0.04),transparent)] px-6 py-6 dark:border-white/[0.07] dark:bg-[radial-gradient(circle_at_10%_0%,rgba(249,115,22,0.2),transparent_48%)] sm:px-8">
<div className="flex items-start justify-between gap-5">
<div className="flex min-w-0 items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-orange-500/25 bg-orange-500/12 text-orange-600 shadow-[0_0_24px_rgba(249,115,22,0.14)] dark:text-orange-300">
<RotateCcw className="h-5 w-5" strokeWidth={2} />
</div>
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-300">Dedicated rollback sprint</div>
<h2 id="sprint-rollback-title" className="mt-2 font-display text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
Revert {sprint?.name || "sprint"}
</h2>
<p id="sprint-rollback-description" className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Code UX will preserve the original sprint and create a separately tracked rollback branch, sprint, and remote pull request.
</p>
</div>
</div>
<button type="button" onClick={onClose} disabled={submitting} aria-label="Close rollback dialog" className="touch-target flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-black/[0.05] hover:text-slate-900 disabled:opacity-40 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X className="h-4 w-4" />
</button>
</div>
</div>

<div className="space-y-5 px-6 py-6 sm:px-8">
{!assessment && !error && (
<div role="status" className="flex items-center gap-3 rounded-2xl border border-black/[0.06] bg-slate-50 px-4 py-4 text-sm text-slate-600 dark:border-white/[0.07] dark:bg-white/[0.035] dark:text-slate-300">
<Loader2 className="h-4 w-4 animate-spin text-orange-500 motion-reduce:animate-none" />
Inspecting merge history and later sprint work…
</div>
)}

{assessment && (
<div className={`rounded-2xl border px-4 py-4 ${assessment.eligible && assessment.recommendedMode === "automatic" ? "border-emerald-500/20 bg-emerald-500/[0.07]" : "border-orange-500/20 bg-orange-500/[0.07]"}`}>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
{assessment.eligible && assessment.recommendedMode === "automatic" ? <ShieldCheck className="h-4 w-4 text-emerald-500" /> : <Bot className="h-4 w-4 text-orange-500" />}
{assessment.eligible
? assessment.recommendedMode === "automatic" ? "Safe automatic rollback available" : "Agent-assisted rollback required"
: "Rollback is not available"}
</div>
<ul className="mt-3 space-y-1.5 text-xs leading-relaxed text-slate-600 dark:text-slate-300">
{assessment.reasons.map((reason) => <li key={reason}>• {reason}</li>)}
</ul>
</div>
)}

<label className="block">
<span className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">Rollback instructions <span className="font-medium normal-case tracking-normal">(optional)</span></span>
<textarea
ref={textareaRef}
value={instructions}
onInput={(event) => setInstructions(event.currentTarget.value)}
disabled={!assessment?.eligible || submitting}
rows={5}
maxLength={6000}
placeholder="Example: Remove only feature XY from this sprint and keep the database migration."
className="mt-2 w-full resize-y rounded-2xl border border-black/[0.08] bg-white px-4 py-3 text-sm leading-relaxed text-slate-900 outline-none transition focus:border-orange-500/40 focus:ring-4 focus:ring-orange-500/10 disabled:cursor-not-allowed disabled:opacity-55 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white"
/>
<span className="mt-2 block text-xs text-slate-500 dark:text-slate-400">
Adding instructions always invokes an agent so the requested subset can be removed safely.
</span>
</label>

{error && (
<div role="alert" className="flex items-start gap-2 rounded-2xl border border-status-red/20 bg-status-red/[0.07] px-4 py-3 text-sm text-status-red">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{error}
</div>
)}

<div className="flex flex-col-reverse gap-2 border-t border-black/[0.06] pt-5 dark:border-white/[0.07] sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{effectiveMode === "automatic" ? "No coding invocation will be started." : effectiveMode === "agent_assisted" ? "A rollback coding invocation will be started." : ""}
</div>
<div className="flex gap-2">
<button type="button" onClick={onClose} disabled={submitting} className="rounded-full px-4 py-2 text-xs font-semibold text-slate-600 transition hover:bg-black/[0.04] disabled:opacity-40 dark:text-slate-300 dark:hover:bg-white/[0.05]">Cancel</button>
<button
type="button"
onClick={() => void submit()}
disabled={!assessment?.eligible || submitting}
className="inline-flex min-w-36 items-center justify-center gap-2 rounded-full bg-orange-500 px-5 py-2.5 text-xs font-bold uppercase tracking-[0.12em] text-white shadow-[0_10px_24px_rgba(249,115,22,0.22)] transition hover:-translate-y-px hover:bg-orange-400 focus-visible:ring-2 focus-visible:ring-orange-500/40 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:translate-y-0"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" /> : <RotateCcw className="h-4 w-4" />}
{submitting ? "Starting" : customScope ? "Start agent rollback" : "Create rollback"}
</button>
</div>
</div>
</div>
</Modal>
);
};
Loading
Loading