feat(FN-1103): improve configured workflow steps experience

- Render a dedicated configured-steps state in WorkflowResultsTab with step count, descriptions, and phase badges when results are not yet available
- Keep inline workflow step editing available from the configured state and results state, including add/remove and execution-order controls
- Refine workflow step loading/edit behavior by aligning callback names and fetching step definitions by project context
- Add dashboard styles for the configured workflow step cards and improved edit toggle button treatment
- Expand WorkflowResultsTab and TaskDetailModal tests and add electron-updater type declarations to restore workspace build
This commit is contained in:
gsxdsm
2026-04-08 00:02:02 -07:00
parent 4f7ba06e18
commit 8c5b5e128c
5 changed files with 343 additions and 118 deletions

View File

@@ -169,17 +169,46 @@ describe("WorkflowResultsTab", () => {
expect(screen.getByText("No workflow steps configured for this task.")).toBeInTheDocument();
});
it("shows 'configured but not run' empty state when enabledWorkflowSteps is non-empty", () => {
it("shows configured step details when enabledWorkflowSteps is non-empty and results are empty", async () => {
render(
<WorkflowResultsTab
taskId="FN-001"
results={[]}
enabledWorkflowSteps={["WS-001", "WS-002"]}
enabledWorkflowSteps={["WS-101", "WS-102"]}
/>,
);
expect(screen.getByTestId("workflow-results-empty")).toBeInTheDocument();
expect(screen.getByText("Workflow steps configured but haven't run yet.")).toBeInTheDocument();
expect(screen.getByTestId("workflow-configured-steps")).toBeInTheDocument();
expect(screen.getByTestId("workflow-configured-header")).toBeInTheDocument();
expect(screen.getByTestId("workflow-configured-count")).toHaveTextContent("2 steps");
const qaStep = await screen.findByTestId("workflow-configured-step-WS-101");
const docsStep = await screen.findByTestId("workflow-configured-step-WS-102");
expect(qaStep).toHaveTextContent("QA Check");
expect(qaStep).toHaveTextContent("Run test suite");
expect(screen.getByTestId("workflow-configured-phase-WS-101")).toHaveTextContent("Pre-merge");
expect(docsStep).toHaveTextContent("Docs Review");
expect(docsStep).toHaveTextContent("Review docs");
expect(screen.getByTestId("workflow-configured-phase-WS-102")).toHaveTextContent("Post-merge");
expect(screen.getByText("Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.")).toBeInTheDocument();
});
it("falls back to step ID and default description when definition is missing", () => {
render(
<WorkflowResultsTab
taskId="FN-001"
results={[]}
enabledWorkflowSteps={["WS-unknown"]}
/>,
);
const fallbackStep = screen.getByTestId("workflow-configured-step-WS-unknown");
expect(fallbackStep).toHaveTextContent("WS-unknown");
expect(fallbackStep).toHaveTextContent("Step definition not found.");
expect(screen.getByTestId("workflow-configured-phase-WS-unknown")).toHaveTextContent("Pre-merge");
});
it("shows loading state when loading prop is true", () => {
@@ -363,22 +392,43 @@ describe("WorkflowResultsTab", () => {
});
describe("workflow step editing", () => {
it("shows edit button when canEdit is true", () => {
render(<WorkflowResultsTab taskId="FN-001" results={[]} canEdit />);
it("shows edit button when canEdit is true and configured steps are present", () => {
render(
<WorkflowResultsTab
taskId="FN-001"
results={[]}
canEdit
enabledWorkflowSteps={["WS-101"]}
/>,
);
expect(screen.getByTestId("workflow-steps-edit-toggle")).toBeInTheDocument();
});
it("does not show edit button when canEdit is false or undefined", () => {
const { rerender } = render(<WorkflowResultsTab taskId="FN-001" results={[]} canEdit={false} />);
const { rerender } = render(
<WorkflowResultsTab
taskId="FN-001"
results={[]}
canEdit={false}
enabledWorkflowSteps={["WS-101"]}
/>,
);
expect(screen.queryByTestId("workflow-steps-edit-toggle")).not.toBeInTheDocument();
rerender(<WorkflowResultsTab taskId="FN-001" results={[]} />);
rerender(<WorkflowResultsTab taskId="FN-001" results={[]} enabledWorkflowSteps={["WS-101"]} />);
expect(screen.queryByTestId("workflow-steps-edit-toggle")).not.toBeInTheDocument();
});
it("shows and hides workflow step checkboxes when edit is toggled", async () => {
render(<WorkflowResultsTab taskId="FN-001" results={[]} canEdit />);
render(
<WorkflowResultsTab
taskId="FN-001"
results={[]}
canEdit
enabledWorkflowSteps={["WS-101"]}
/>,
);
expect(screen.queryByTestId("workflow-steps-editor")).not.toBeInTheDocument();
@@ -399,7 +449,7 @@ describe("WorkflowResultsTab", () => {
taskId="FN-001"
results={[]}
canEdit
enabledWorkflowSteps={[]}
enabledWorkflowSteps={["WS-102"]}
onWorkflowStepsChange={onWorkflowStepsChange}
/>,
);
@@ -408,7 +458,7 @@ describe("WorkflowResultsTab", () => {
const stepCheckbox = (await screen.findByTestId("workflow-step-checkbox-WS-101")).querySelector("input") as HTMLInputElement;
fireEvent.click(stepCheckbox);
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-101"]);
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-102", "WS-101"]);
onWorkflowStepsChange.mockClear();
rerender(
@@ -421,6 +471,10 @@ describe("WorkflowResultsTab", () => {
/>,
);
if (!screen.queryByTestId("workflow-steps-editor")) {
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
}
const selectedCheckbox = (await screen.findByTestId("workflow-step-checkbox-WS-101")).querySelector("input") as HTMLInputElement;
expect(selectedCheckbox.checked).toBe(true);
fireEvent.click(selectedCheckbox);

View File

@@ -108,11 +108,6 @@ export function WorkflowResultsTab({
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (!canEdit) {
setAllWorkflowSteps([]);
return;
}
let cancelled = false;
fetchWorkflowSteps(projectId)
.then((steps) => {
@@ -129,7 +124,7 @@ export function WorkflowResultsTab({
return () => {
cancelled = true;
};
}, [canEdit, projectId]);
}, [projectId]);
const selectedWorkflowSteps = enabledWorkflowSteps ?? [];
@@ -160,7 +155,7 @@ export function WorkflowResultsTab({
setExpandedOutputs((prev) => ({ ...prev, [stepId]: !prev[stepId] }));
};
const toggleWorkflowStep = useCallback((stepId: string, checked: boolean) => {
const toggleStep = useCallback((stepId: string, checked: boolean) => {
if (!onWorkflowStepsChange) return;
if (checked) {
@@ -195,6 +190,114 @@ export function WorkflowResultsTab({
}, [onWorkflowStepsChange, selectedWorkflowSteps]);
const hasResults = results.length > 0;
const hasConfiguredSteps = selectedWorkflowSteps.length > 0;
useEffect(() => {
if (!canEdit) {
setIsEditing(false);
}
}, [canEdit]);
const configuredSteps = useMemo(() => {
return selectedWorkflowSteps.map((stepId) => {
const stepInfo = workflowStepLookup.get(stepId);
return {
id: stepId,
name: stepInfo?.name || stepId,
description: stepInfo?.description || "Step definition not found.",
phase: stepInfo?.phase || "pre-merge",
} as WorkflowStepOption;
});
}, [selectedWorkflowSteps, workflowStepLookup]);
const renderEditor = () => {
if (!canEdit || !isEditing || loading) {
return null;
}
return (
<div className="workflow-results-editor" data-testid="workflow-steps-editor">
<small style={{ marginBottom: "8px", display: "block" }}>
Select steps to run after task implementation completes
</small>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{workflowStepOptions.map((step) => (
<label
key={step.id}
className="checkbox-label"
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
data-testid={step.id === "browser-verification"
? "browser-verification-checkbox"
: `workflow-step-checkbox-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(event) => toggleStep(step.id, event.target.checked)}
style={{ marginTop: "2px" }}
/>
<div>
<span style={{ fontWeight: 500, fontSize: "13px" }}>
{step.name}
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
</span>
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
{step.description}
</div>
</div>
</label>
))}
</div>
{selectedWorkflowSteps.length > 1 && (
<div className="workflow-step-order" data-testid="workflow-step-order">
<small className="workflow-step-order-label">Execution order:</small>
{selectedWorkflowSteps.map((stepId, index) => {
const stepInfo = workflowStepLookup.get(stepId);
return (
<div key={stepId} className="workflow-step-order-item" data-testid={`workflow-step-order-item-${stepId}`}>
<span className="workflow-step-order-number">{index + 1}</span>
<span className="workflow-step-order-name">{stepInfo?.name || stepId}</span>
<div className="workflow-step-order-actions">
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveWorkflowStepUp(index)}
disabled={index === 0}
data-testid={`workflow-step-move-up-${stepId}`}
title="Move up"
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveWorkflowStepDown(index)}
disabled={index === selectedWorkflowSteps.length - 1}
data-testid={`workflow-step-move-down-${stepId}`}
title="Move down"
>
<ChevronDown size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => removeWorkflowStep(stepId)}
data-testid={`workflow-step-remove-${stepId}`}
title="Remove"
>
<X size={14} />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
const renderResults = () => {
if (loading) {
@@ -207,14 +310,9 @@ export function WorkflowResultsTab({
}
if (!hasResults) {
const hasConfiguredSteps = selectedWorkflowSteps.length > 0;
return (
<div className="workflow-results-empty" data-testid="workflow-results-empty">
<p>
{hasConfiguredSteps
? "Workflow steps configured but haven't run yet."
: "No workflow steps configured for this task."}
</p>
<p>No workflow steps configured for this task.</p>
<p className="workflow-results-empty-hint">
Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.
</p>
@@ -310,109 +408,79 @@ export function WorkflowResultsTab({
);
};
const showEditUI = !!canEdit && isEditing;
const editButton = canEdit ? (
<button
type="button"
className="modal-edit-btn workflow-results-edit-toggle"
onClick={() => setIsEditing((prev) => !prev)}
data-testid="workflow-steps-edit-toggle"
aria-label={isEditing ? "Done editing workflow steps" : "Edit workflow steps"}
title={isEditing ? "Done" : "Edit"}
>
{isEditing ? (
<>
<Check size={14} />
Done
</>
) : (
<>
<Pencil size={14} />
Edit
</>
)}
</button>
) : null;
const showConfiguredStepsState = !loading && !hasResults && hasConfiguredSteps;
const showEditHeaderForResults = canEdit && hasResults;
return (
<div className="workflow-results-tab" data-task-id={taskId}>
{canEdit && (
<div className="workflow-results-edit-header" data-testid="workflow-results-edit-header">
<h4>Workflow Steps</h4>
<button
type="button"
className="modal-edit-btn"
onClick={() => setIsEditing((prev) => !prev)}
data-testid="workflow-steps-edit-toggle"
aria-label={isEditing ? "Done editing workflow steps" : "Edit workflow steps"}
title={isEditing ? "Done" : "Edit"}
>
{isEditing ? <Check size={14} /> : <Pencil size={14} />}
</button>
</div>
)}
{showConfiguredStepsState ? (
<div className="workflow-configured-steps" data-testid="workflow-configured-steps">
<div className="workflow-configured-header" data-testid="workflow-configured-header">
<div className="workflow-configured-title-row">
<h4>Configured Workflow Steps</h4>
<span className="workflow-configured-count" data-testid="workflow-configured-count">
{configuredSteps.length} step{configuredSteps.length === 1 ? "" : "s"}
</span>
</div>
{editButton}
</div>
{(!showEditUI || hasResults || loading) && renderResults()}
{showEditUI && !loading && (
<div className="workflow-results-editor" data-testid="workflow-steps-editor">
<small style={{ marginBottom: "8px", display: "block" }}>
Select steps to run after task implementation completes
</small>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{workflowStepOptions.map((step) => (
<label
<div className="workflow-configured-list" data-testid="workflow-configured-list">
{configuredSteps.map((step) => (
<div
key={step.id}
className="checkbox-label"
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
data-testid={step.id === "browser-verification"
? "browser-verification-checkbox"
: `workflow-step-checkbox-${step.id}`}
className="workflow-configured-item"
data-testid={`workflow-configured-step-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(event) => toggleWorkflowStep(step.id, event.target.checked)}
style={{ marginTop: "2px" }}
/>
<div>
<span style={{ fontWeight: 500, fontSize: "13px" }}>
{step.name}
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
</span>
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
{step.description}
</div>
<div className="workflow-configured-name">
{step.name}
{phaseBadge(step.phase, step.id, "workflow-configured-phase")}
</div>
</label>
<p className="workflow-configured-description">{step.description}</p>
</div>
))}
</div>
{selectedWorkflowSteps.length > 1 && (
<div className="workflow-step-order" data-testid="workflow-step-order">
<small className="workflow-step-order-label">Execution order:</small>
{selectedWorkflowSteps.map((stepId, index) => {
const stepInfo = workflowStepLookup.get(stepId);
return (
<div key={stepId} className="workflow-step-order-item" data-testid={`workflow-step-order-item-${stepId}`}>
<span className="workflow-step-order-number">{index + 1}</span>
<span className="workflow-step-order-name">{stepInfo?.name || stepId}</span>
<div className="workflow-step-order-actions">
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveWorkflowStepUp(index)}
disabled={index === 0}
data-testid={`workflow-step-move-up-${stepId}`}
title="Move up"
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveWorkflowStepDown(index)}
disabled={index === selectedWorkflowSteps.length - 1}
data-testid={`workflow-step-move-down-${stepId}`}
title="Move down"
>
<ChevronDown size={14} />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => removeWorkflowStep(stepId)}
data-testid={`workflow-step-remove-${stepId}`}
title="Remove"
>
<X size={14} />
</button>
</div>
</div>
);
})}
<p className="workflow-results-empty-hint">
Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.
</p>
{renderEditor()}
</div>
) : (
<>
{showEditHeaderForResults && (
<div className="workflow-results-edit-header" data-testid="workflow-results-edit-header">
<h4>Workflow Steps</h4>
{editButton}
</div>
)}
</div>
{renderResults()}
{renderEditor()}
</>
)}
</div>
);

View File

@@ -4241,7 +4241,7 @@ describe("TaskDetailModal", () => {
});
});
it("renders empty state when workflow results are empty", async () => {
it("renders configured workflow steps state when results are empty", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
mockFetch.mockResolvedValueOnce([]);
@@ -4261,8 +4261,8 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByText("Workflow"));
await waitFor(() => {
expect(screen.getByTestId("workflow-results-empty")).toBeTruthy();
expect(screen.getByText("Workflow steps configured but haven't run yet.")).toBeTruthy();
expect(screen.getByTestId("workflow-configured-steps")).toBeTruthy();
expect(screen.getByTestId("workflow-configured-step-WS-001")).toHaveTextContent("WS-001");
});
});

View File

@@ -19236,6 +19236,92 @@ html .column.drag-over * {
color: var(--text-primary, #e6edf3);
}
.workflow-results-edit-toggle {
width: auto;
height: 30px;
padding: 0 10px;
border: 1px solid var(--border, #30363d);
border-radius: 6px;
background: var(--surface-elevated, #161b22);
color: var(--text-muted, #8b949e);
font-size: 12px;
font-weight: 500;
gap: 6px;
}
.workflow-results-edit-toggle:hover {
background: var(--surface, #21262d);
color: var(--text-primary, #e6edf3);
}
.workflow-results-edit-toggle svg {
flex-shrink: 0;
}
.workflow-configured-steps {
display: flex;
flex-direction: column;
gap: var(--space-sm, 8px);
}
.workflow-configured-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
}
.workflow-configured-title-row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
.workflow-configured-title-row h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary, #e6edf3);
}
.workflow-configured-count {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border, #30363d);
color: var(--text-muted, #8b949e);
font-size: 11px;
font-weight: 600;
}
.workflow-configured-list {
display: flex;
flex-direction: column;
gap: var(--space-sm, 8px);
}
.workflow-configured-item {
border: 1px solid var(--border, #30363d);
border-radius: 8px;
padding: var(--space-md, 16px);
background: var(--surface-elevated, #161b22);
}
.workflow-configured-name {
display: flex;
align-items: center;
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #e6edf3);
}
.workflow-configured-description {
margin: var(--space-xs, 4px) 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary, #c9d1d9);
}
.workflow-results-editor {
border: 1px solid var(--border, #30363d);
border-radius: 8px;

View File

@@ -0,0 +1,17 @@
declare module "electron-updater" {
export interface UpdateInfo {
version?: string;
[key: string]: unknown;
}
export interface AutoUpdater {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
on(event: "update-available", listener: (info: UpdateInfo) => void): this;
on(event: "update-downloaded", listener: (info: UpdateInfo) => void): this;
on(event: "error", listener: (error: Error) => void): this;
checkForUpdates(): Promise<unknown>;
}
export const autoUpdater: AutoUpdater;
}