feat(dashboard): node editor becomes the primary workflow surface; retire WorkflowStepManager
This commit is contained in:
@@ -1227,9 +1227,9 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closeScripts });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openWorkflowStepsWithNav = useCallback(() => {
|
||||
modalManager.openWorkflowSteps();
|
||||
pushNav({ type: "modal", close: modalManager.closeWorkflowSteps });
|
||||
const openWorkflowEditorWithNav = useCallback(() => {
|
||||
modalManager.openWorkflowEditor();
|
||||
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => {
|
||||
@@ -1803,7 +1803,7 @@ function AppInner() {
|
||||
onOpenGitManager={openGitManagerWithNav}
|
||||
onOpenNodes={handleOpenNodesWithNav}
|
||||
showNodesButton={nodesEnabled}
|
||||
onOpenWorkflowSteps={openWorkflowStepsWithNav}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onOpenScripts={openScriptsWithNav}
|
||||
onRunScript={runScriptWithNav}
|
||||
onToggleTerminal={toggleTerminalWithNav}
|
||||
@@ -2007,7 +2007,7 @@ function AppInner() {
|
||||
chatHasUnreadResponse={chatHasUnreadResponse}
|
||||
stashOrphanCount={stashOrphanCount}
|
||||
onOpenGitManager={openGitManagerWithNav}
|
||||
onOpenWorkflowSteps={openWorkflowStepsWithNav}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onOpenSchedules={openSchedulesWithNav}
|
||||
onOpenScripts={openScriptsWithNav}
|
||||
onToggleTerminal={toggleTerminalWithNav}
|
||||
|
||||
@@ -49,7 +49,7 @@ const createDefaultMobileNavProps = () => ({
|
||||
onOpenNodes: vi.fn(),
|
||||
mailboxUnreadCount: 0,
|
||||
onOpenGitManager: vi.fn(),
|
||||
onOpenWorkflowSteps: vi.fn(),
|
||||
onOpenWorkflowEditor: vi.fn(),
|
||||
onOpenSchedules: vi.fn(),
|
||||
onOpenScripts: vi.fn(),
|
||||
onToggleTerminal: vi.fn(),
|
||||
|
||||
@@ -178,9 +178,9 @@ describe("tablet header controls", () => {
|
||||
expect(screen.queryByTitle("Git Manager")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render workflow steps button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenWorkflowSteps: noop });
|
||||
expect(screen.queryByTitle("Workflow Steps")).toBeNull();
|
||||
it("does not render workflows button inline on tablet", () => {
|
||||
renderTabletHeader({ onOpenWorkflowEditor: noop });
|
||||
expect(screen.queryByTitle("Workflows")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Overflow menu on tablet ────────────────────────────────────
|
||||
@@ -254,8 +254,8 @@ describe("tablet header controls", () => {
|
||||
expect(screen.getByTestId("overflow-git-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("overflow menu contains workflow steps on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenWorkflowSteps: noop });
|
||||
it("overflow menu contains workflows on tablet when provided", () => {
|
||||
renderTabletHeader({ onOpenWorkflowEditor: noop });
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined();
|
||||
});
|
||||
@@ -538,7 +538,7 @@ describe("tablet header controls", () => {
|
||||
const { container } = renderTabletHeader({
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
onOpenWorkflowSteps: noop,
|
||||
onOpenWorkflowEditor: noop,
|
||||
onOpenFiles: noop,
|
||||
onOpenGitManager: noop,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import { NewTaskModal } from "./NewTaskModal";
|
||||
import { SystemStatsModal } from "./SystemStatsModal";
|
||||
import { ActivityLogModal } from "./ActivityLogModal";
|
||||
import { GitManagerModal } from "./GitManagerModal";
|
||||
import { WorkflowStepManager } from "./WorkflowStepManager";
|
||||
import { AgentListModal } from "./AgentListModal";
|
||||
import { ModelOnboardingModal } from "./ModelOnboardingModal";
|
||||
import { ToastContainer } from "./ToastContainer";
|
||||
@@ -373,19 +372,6 @@ export function AppModals({
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<ModalErrorBoundary>
|
||||
<WorkflowStepManager
|
||||
isOpen={modalManager.workflowStepsOpen}
|
||||
onClose={modalManager.closeWorkflowSteps}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
onOpenGraphEditor={() => {
|
||||
modalManager.closeWorkflowSteps();
|
||||
modalManager.openWorkflowEditor();
|
||||
}}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
{modalManager.workflowEditorOpen && (
|
||||
<ModalErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -197,7 +197,7 @@ export interface HeaderProps {
|
||||
onOpenNodes?: () => void;
|
||||
/** When false, hides the Nodes management button. Defaults to true for backward compat. */
|
||||
showNodesButton?: boolean;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onOpenWorkflowEditor?: () => void;
|
||||
onOpenScripts?: () => void;
|
||||
onRunScript?: (name: string, command: string) => void;
|
||||
onToggleTerminal?: () => void;
|
||||
@@ -266,7 +266,7 @@ export function Header({
|
||||
onOpenGitManager,
|
||||
onOpenNodes,
|
||||
showNodesButton,
|
||||
onOpenWorkflowSteps,
|
||||
onOpenWorkflowEditor,
|
||||
onOpenScripts,
|
||||
onRunScript,
|
||||
onToggleTerminal,
|
||||
@@ -1593,12 +1593,12 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenWorkflowSteps && (
|
||||
{/* Workflows - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenWorkflowEditor && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenWorkflowSteps}
|
||||
title={t("header.workflowSteps", "Workflow Steps")}
|
||||
onClick={onOpenWorkflowEditor}
|
||||
title={t("header.workflows", "Workflows")}
|
||||
data-testid="workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
@@ -1938,16 +1938,16 @@ export function Header({
|
||||
<span>{t("header.viewUsage", "View Usage")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Workflow Steps - in overflow on mobile */}
|
||||
{onOpenWorkflowSteps && (
|
||||
{/* Workflows - in overflow on mobile */}
|
||||
{onOpenWorkflowEditor && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenWorkflowSteps)}
|
||||
onClick={() => handleOverflowAction(onOpenWorkflowEditor)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
<span>{t("header.workflowSteps", "Workflow Steps")}</span>
|
||||
<span>{t("header.workflows", "Workflows")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Settings - always last in overflow menu */}
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface MobileNavBarProps {
|
||||
chatHasUnreadResponse?: boolean;
|
||||
stashOrphanCount?: number;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onOpenWorkflowEditor?: () => void;
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenScripts?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
@@ -127,7 +127,7 @@ export function MobileNavBar({
|
||||
chatHasUnreadResponse = false,
|
||||
stashOrphanCount = 0,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
onOpenWorkflowEditor,
|
||||
onOpenSchedules,
|
||||
onOpenScripts,
|
||||
onToggleTerminal,
|
||||
@@ -590,10 +590,10 @@ export function MobileNavBar({
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-workflow"
|
||||
onClick={() => handleMoreAction(onOpenWorkflowSteps)}
|
||||
onClick={() => handleMoreAction(onOpenWorkflowEditor)}
|
||||
>
|
||||
<Workflow />
|
||||
<span>{t("nav.workflowSteps", "Workflow Steps")}</span>
|
||||
<span>{t("nav.workflows", "Workflows")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,566 +0,0 @@
|
||||
/* === Workflow Step Manager Modal === */
|
||||
.modal.workflow-step-manager-modal {
|
||||
width: min(900px, 95vw);
|
||||
max-width: 95vw;
|
||||
min-width: 480px;
|
||||
height: 80vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.wfm-body {
|
||||
padding: var(--space-md);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Loading / empty states */
|
||||
.wfm-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wfm-loading {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tab navigation row */
|
||||
.wfm-tab-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.wfm-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Step list */
|
||||
.wfm-step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Step card */
|
||||
.wfm-step-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wfm-step-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wfm-step-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-step-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-step-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wfm-step-card-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-step-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-delete-confirm {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.wfm-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wfm-badge-enabled {
|
||||
background: color-mix(in srgb, var(--ws-success) 15%, transparent);
|
||||
color: var(--ws-success);
|
||||
}
|
||||
|
||||
.wfm-badge-disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wfm-badge-prompt {
|
||||
background: color-mix(in srgb, var(--ws-pre-merge) 15%, transparent);
|
||||
color: var(--ws-pre-merge);
|
||||
}
|
||||
|
||||
.wfm-badge-script {
|
||||
background: color-mix(in srgb, var(--ws-post-merge) 15%, transparent);
|
||||
color: var(--ws-post-merge);
|
||||
}
|
||||
|
||||
.wfm-badge-pre-merge {
|
||||
background: color-mix(in srgb, var(--ws-pre-merge) 15%, transparent);
|
||||
color: var(--ws-pre-merge);
|
||||
}
|
||||
|
||||
.wfm-badge-post-merge {
|
||||
background: color-mix(in srgb, var(--ws-info) 15%, transparent);
|
||||
color: var(--ws-info);
|
||||
}
|
||||
|
||||
.wfm-badge-default-on {
|
||||
background: color-mix(in srgb, var(--ws-warning) 15%, transparent);
|
||||
color: var(--ws-warning);
|
||||
}
|
||||
|
||||
.wfm-badge-gate {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.wfm-badge-advisory {
|
||||
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.wfm-badge-category {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.wfm-badge-category-quality {
|
||||
background: color-mix(in srgb, var(--ws-quality) 15%, transparent);
|
||||
color: var(--ws-quality);
|
||||
}
|
||||
|
||||
.wfm-badge-category-security {
|
||||
background: color-mix(in srgb, var(--ws-security) 15%, transparent);
|
||||
color: var(--ws-security);
|
||||
}
|
||||
|
||||
.wfm-badge-category-plugin {
|
||||
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.wfm-delete-confirm-btn {
|
||||
color: var(--ws-error);
|
||||
}
|
||||
|
||||
/* Template cards */
|
||||
.wfm-template-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wfm-template-card {
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wfm-template-inner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wfm-template-icon {
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-template-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-template-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-template-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wfm-template-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.wfm-template-add-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Create chooser */
|
||||
.wfm-create-chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wfm-create-chooser-hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wfm-create-custom-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Edit/create form */
|
||||
.wfm-form {
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wfm-form-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wfm-form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.wfm-field label,
|
||||
.wfm-field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-field input[type="text"],
|
||||
.wfm-field textarea,
|
||||
.wfm-field select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.wfm-field input[type="text"]:focus,
|
||||
.wfm-field textarea:focus,
|
||||
.wfm-field select:focus {
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.wfm-field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.wfm-field textarea.wfm-prompt-textarea {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* Mode selector */
|
||||
.wfm-mode-selector {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wfm-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Prompt header row (label + refine button) */
|
||||
.wfm-prompt-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfm-refine-btn {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-refine-btn span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* No-scripts placeholder */
|
||||
.wfm-no-scripts {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Checkbox toggle */
|
||||
.wfm-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-checkbox-label input[type="checkbox"] {
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
/* Form actions */
|
||||
.wfm-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.wfm-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wfm-footer-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.wfm-spinner {
|
||||
margin: 0 auto var(--space-sm);
|
||||
}
|
||||
|
||||
.wfm-model-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-model-header label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wfm-model-clear-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-model-clear-btn:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wfm-model-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Full-screen sheet on mobile — drop the overlay's top padding so the
|
||||
modal fills the viewport instead of being pushed below it. */
|
||||
.modal-overlay:has(.workflow-step-manager-modal) {
|
||||
padding-top: 0;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.modal.workflow-step-manager-modal {
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
max-height: 100dvh;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.wfm-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-tab-row {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.wfm-tab-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wfm-tab-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-step-card-top {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wfm-step-card-title-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-step-card-actions {
|
||||
flex-wrap: wrap;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wfm-step-card-desc {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wfm-template-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-mode-selector {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-form-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wfm-create-custom-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wfm-footer {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.wfm-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wfm-graph-editor-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-graph-editor-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
@@ -1,984 +0,0 @@
|
||||
import "./WorkflowStepManager.css";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode, WorkflowStepPhase, WorkflowStepGateMode } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflowSteps,
|
||||
createWorkflowStep,
|
||||
updateWorkflowStep,
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
fetchWorkflowStepTemplates,
|
||||
fetchPluginWorkflowStepTemplates,
|
||||
createWorkflowStepFromTemplate,
|
||||
fetchScripts,
|
||||
fetchModels,
|
||||
type WorkflowStepTemplate,
|
||||
type ModelInfo,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Shield,
|
||||
Zap,
|
||||
Eye,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
BookOpen,
|
||||
Terminal,
|
||||
MessageSquare,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
interface WorkflowStepManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
/** Optional: open the visual graph workflow editor. */
|
||||
onOpenGraphEditor?: () => void;
|
||||
}
|
||||
|
||||
interface StepFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
mode: WorkflowStepMode;
|
||||
phase: WorkflowStepPhase;
|
||||
prompt: string;
|
||||
gateMode: WorkflowStepGateMode;
|
||||
scriptName: string;
|
||||
enabled: boolean;
|
||||
defaultOn: boolean;
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
type TabId = "my-steps" | "templates";
|
||||
|
||||
const EMPTY_FORM: StepFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
mode: "prompt",
|
||||
phase: "pre-merge" as WorkflowStepPhase,
|
||||
prompt: "",
|
||||
gateMode: "advisory",
|
||||
scriptName: "",
|
||||
enabled: true,
|
||||
defaultOn: false,
|
||||
modelProvider: "",
|
||||
modelId: "",
|
||||
};
|
||||
|
||||
/** Build the combined "provider/modelId" value for CustomModelDropdown */
|
||||
function getModelDropdownValue(provider: string, modelId: string): string {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
|
||||
/** Parse "provider/modelId" dropdown value back into separate fields */
|
||||
function parseModelDropdownValue(value: string): { provider: string; modelId: string } {
|
||||
if (!value) return { provider: "", modelId: "" };
|
||||
const slashIndex = value.indexOf("/");
|
||||
if (slashIndex === -1) return { provider: "", modelId: "" };
|
||||
return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) };
|
||||
}
|
||||
|
||||
/** Map template icon names to Lucide components */
|
||||
function getTemplateIcon(iconName: string | undefined) {
|
||||
switch (iconName) {
|
||||
case "file-text":
|
||||
return FileText;
|
||||
case "check-circle":
|
||||
return CheckCircle;
|
||||
case "shield":
|
||||
return Shield;
|
||||
case "zap":
|
||||
return Zap;
|
||||
case "eye":
|
||||
return Eye;
|
||||
case "globe":
|
||||
return Globe;
|
||||
case "layout-grid":
|
||||
return LayoutGrid;
|
||||
case "puzzle":
|
||||
return Puzzle;
|
||||
default:
|
||||
return CheckCircle;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get category badge class name */
|
||||
function getCategoryClassName(category: string): string {
|
||||
switch (category.toLowerCase()) {
|
||||
case "quality":
|
||||
return "wfm-badge-category wfm-badge-category-quality";
|
||||
case "security":
|
||||
return "wfm-badge-category wfm-badge-category-security";
|
||||
default:
|
||||
return "wfm-badge-category";
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast, projectId, onOpenGraphEditor }: WorkflowStepManagerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
||||
const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]);
|
||||
const [pluginTemplateOwners, setPluginTemplateOwners] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("my-steps");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showCreateChooser, setShowCreateChooser] = useState(false);
|
||||
const [form, setForm] = useState<StepFormData>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refining, setRefining] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [addingTemplateId, setAddingTemplateId] = useState<string | null>(null);
|
||||
const [availableScripts, setAvailableScripts] = useState<Record<string, string>>({});
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:workflow-steps-modal-size");
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchWorkflowSteps(projectId);
|
||||
setSteps(data);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflow.errorLoadSteps", "Failed to load workflow steps"), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const loadScripts = useCallback(async () => {
|
||||
try {
|
||||
const scripts = await fetchScripts(projectId);
|
||||
setAvailableScripts(scripts || {});
|
||||
} catch {
|
||||
// Silently ignore — scripts are optional
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchModels();
|
||||
setAvailableModels(response.models || []);
|
||||
} catch {
|
||||
// Silently ignore — models are optional, dropdown will be empty
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
setTemplatesLoading(true);
|
||||
const [response, pluginResponse] = await Promise.all([
|
||||
fetchWorkflowStepTemplates(),
|
||||
fetchPluginWorkflowStepTemplates(),
|
||||
]);
|
||||
setTemplates(response.templates);
|
||||
const owners = Object.fromEntries(pluginResponse.templates.map(({ pluginId, template }) => [template.id, pluginId]));
|
||||
setPluginTemplateOwners(owners);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflow.errorLoadTemplates", "Failed to load templates"), "error");
|
||||
} finally {
|
||||
setTemplatesLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSteps();
|
||||
loadTemplates();
|
||||
loadScripts();
|
||||
loadModels();
|
||||
}
|
||||
}, [isOpen, loadSteps, loadTemplates, loadScripts, loadModels]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setShowCreateChooser(true);
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleCreateCustom = useCallback(() => {
|
||||
setShowCreateChooser(false);
|
||||
setIsCreating(true);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((step: WorkflowStep) => {
|
||||
setEditingId(step.id);
|
||||
setIsCreating(false);
|
||||
setForm({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
mode: step.mode || "prompt",
|
||||
phase: step.phase || "pre-merge",
|
||||
prompt: step.prompt,
|
||||
gateMode: step.gateMode || ((step.mode || "prompt") === "script" ? "gate" : "advisory"),
|
||||
scriptName: step.scriptName || "",
|
||||
enabled: step.enabled,
|
||||
defaultOn: step.defaultOn || false,
|
||||
modelProvider: step.modelProvider || "",
|
||||
modelId: step.modelId || "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(false);
|
||||
setShowCreateChooser(false);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim() || !form.description.trim()) {
|
||||
addToast(t("workflow.errorNameDescRequired", "Name and description are required"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Build model override: only include when both fields are set (prompt mode)
|
||||
const modelFields = form.mode === "prompt" && form.modelProvider && form.modelId
|
||||
? { modelProvider: form.modelProvider, modelId: form.modelId }
|
||||
: form.mode === "prompt"
|
||||
? { modelProvider: undefined, modelId: undefined }
|
||||
: {};
|
||||
|
||||
if (isCreating) {
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
mode: form.mode,
|
||||
phase: form.phase,
|
||||
prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined,
|
||||
gateMode: form.gateMode,
|
||||
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
|
||||
enabled: form.enabled,
|
||||
defaultOn: form.defaultOn || undefined,
|
||||
...modelFields,
|
||||
};
|
||||
await createWorkflowStep(input, projectId);
|
||||
addToast(t("workflow.stepCreated", "Workflow step created"), "success");
|
||||
} else if (editingId) {
|
||||
await updateWorkflowStep(editingId, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
mode: form.mode,
|
||||
phase: form.phase,
|
||||
prompt: form.mode === "prompt" ? form.prompt : "",
|
||||
gateMode: form.gateMode,
|
||||
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
|
||||
enabled: form.enabled,
|
||||
defaultOn: form.defaultOn,
|
||||
...modelFields,
|
||||
}, projectId);
|
||||
addToast(t("workflow.stepUpdated", "Workflow step updated"), "success");
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
await loadSteps();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflow.errorSaveStep", "Failed to save workflow step"), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, isCreating, editingId, addToast, loadSteps]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteWorkflowStep(id, projectId);
|
||||
addToast(t("workflow.stepDeleted", "Workflow step deleted"), "success");
|
||||
setDeleteConfirmId(null);
|
||||
if (editingId === id) {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
await loadSteps();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflow.errorDeleteStep", "Failed to delete workflow step"), "error");
|
||||
}
|
||||
}, [editingId, addToast, loadSteps]);
|
||||
|
||||
const handleRefine = useCallback(async () => {
|
||||
if (!editingId && !isCreating) return;
|
||||
// Refine only works for prompt mode
|
||||
if (form.mode !== "prompt") return;
|
||||
|
||||
// For new steps being created, we need to save first then refine
|
||||
if (isCreating) {
|
||||
if (!form.name.trim() || !form.description.trim()) {
|
||||
addToast(t("workflow.errorNameDescRequiredRefine", "Name and description are required before refining"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Build model override for the intermediate create-then-refine flow
|
||||
const modelFields = form.modelProvider && form.modelId
|
||||
? { modelProvider: form.modelProvider, modelId: form.modelId }
|
||||
: {};
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
mode: "prompt",
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
gateMode: form.gateMode,
|
||||
enabled: form.enabled,
|
||||
defaultOn: form.defaultOn || undefined,
|
||||
...modelFields,
|
||||
};
|
||||
const created = await createWorkflowStep(input, projectId);
|
||||
setIsCreating(false);
|
||||
setEditingId(created.id);
|
||||
|
||||
// Now refine
|
||||
setRefining(true);
|
||||
const result = await refineWorkflowStepPrompt(created.id, projectId);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast(t("workflow.promptRefined", "Prompt refined with AI"), "success");
|
||||
await loadSteps();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflow.errorRefinePrompt", "Failed to refine prompt"), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setRefining(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingId) return;
|
||||
|
||||
setRefining(true);
|
||||
try {
|
||||
const result = await refineWorkflowStepPrompt(editingId, projectId);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast(t("workflow.promptRefined", "Prompt refined with AI"), "success");
|
||||
await loadSteps();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflow.errorRefinePrompt", "Failed to refine prompt"), "error");
|
||||
} finally {
|
||||
setRefining(false);
|
||||
}
|
||||
}, [editingId, isCreating, form, addToast, loadSteps]);
|
||||
|
||||
const handleAddTemplate = useCallback(async (template: WorkflowStepTemplate) => {
|
||||
setAddingTemplateId(template.id);
|
||||
try {
|
||||
await createWorkflowStepFromTemplate(template.id, projectId);
|
||||
addToast(t("workflow.templateAdded", "Added {{name}} workflow step", { name: template.name }), "success");
|
||||
await loadSteps();
|
||||
// Switch to "My Workflow Steps" tab to show the newly added step
|
||||
setActiveTab("my-steps");
|
||||
setShowCreateChooser(false);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
if (msg?.includes("already exists")) {
|
||||
addToast(t("workflow.errorTemplateAlreadyExists", "A workflow step named '{{name}}' already exists", { name: template.name }), "error");
|
||||
} else {
|
||||
addToast(msg || t("workflow.errorAddTemplate", "Failed to add workflow step from template"), "error");
|
||||
}
|
||||
} finally {
|
||||
setAddingTemplateId(null);
|
||||
}
|
||||
}, [addToast, loadSteps]);
|
||||
|
||||
// useOverlayDismiss MUST be called before any early return — otherwise the
|
||||
// hook count differs between isOpen=false (returns early after the existing
|
||||
// useState/useEffect/useCallback hooks) and isOpen=true (also calls
|
||||
// useOverlayDismiss), which trips React error #310 the moment the modal
|
||||
// is opened. That bug currently breaks the workflow steps panel from
|
||||
// loading at all.
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isEditing = isCreating || editingId !== null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} data-testid="workflow-step-manager">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="modal workflow-step-manager-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("workflow.modalAriaLabel", "Workflow Steps")}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2>{t("workflow.modalTitle", "Workflow Steps")}</h2>
|
||||
<div className="wfm-header-actions">
|
||||
{onOpenGraphEditor && (
|
||||
<button className="wfm-graph-editor-link" onClick={onOpenGraphEditor}>
|
||||
<LayoutGrid size={14} /> {t("workflow.graphEditor", "Graph editor")}
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("common.close", "Close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wfm-body">
|
||||
{loading ? (
|
||||
<div className="wfm-loading">{t("common.loading", "Loading...")}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
{!isEditing && (
|
||||
<div className="wfm-tab-row">
|
||||
<button
|
||||
className={`btn ${activeTab === "my-steps" ? "btn-primary" : "btn-secondary"} wfm-tab-btn`}
|
||||
onClick={() => setActiveTab("my-steps")}
|
||||
data-testid="tab-my-steps"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
{t("workflow.tabMySteps", "My Workflow Steps ({{count}})", { count: steps.length })}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${activeTab === "templates" ? "btn-primary" : "btn-secondary"} wfm-tab-btn`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
data-testid="tab-templates"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
{t("workflow.tabTemplates", "Templates ({{count}})", { count: templates.length })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Workflow Steps Tab */}
|
||||
{activeTab === "my-steps" && !isEditing && !showCreateChooser && (
|
||||
<>
|
||||
{steps.length === 0 && (
|
||||
<div className="wfm-empty" data-testid="empty-state">
|
||||
{t("workflow.emptySteps", "No workflow steps defined. Create one to get started, or add one from the Templates tab.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && (
|
||||
<div className="wfm-step-list">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="wfm-step-card"
|
||||
data-testid={`workflow-step-${step.id}`}
|
||||
>
|
||||
<div className="wfm-step-card-top">
|
||||
<div className="wfm-step-card-info">
|
||||
<div className="wfm-step-card-title-row">
|
||||
<span className="wfm-step-card-name">{step.name}</span>
|
||||
<span className={`wfm-badge ${step.enabled ? "wfm-badge-enabled" : "wfm-badge-disabled"}`}>
|
||||
{step.enabled ? t("workflow.badgeEnabled", "Enabled") : t("workflow.badgeDisabled", "Disabled")}
|
||||
</span>
|
||||
<span className={`wfm-badge ${(step.mode || "prompt") === "script" ? "wfm-badge-script" : "wfm-badge-prompt"}`}>
|
||||
{(step.mode || "prompt") === "script" ? t("workflow.badgeScript", "Script") : t("workflow.badgeAiPrompt", "AI Prompt")}
|
||||
</span>
|
||||
<span className={`wfm-badge ${(step.phase || "pre-merge") === "post-merge" ? "wfm-badge-post-merge" : "wfm-badge-pre-merge"}`}>
|
||||
{(step.phase || "pre-merge") === "post-merge" ? t("workflow.badgePostMerge", "Post-merge") : t("workflow.badgePreMerge", "Pre-merge")}
|
||||
</span>
|
||||
<span className={`wfm-badge ${(step.gateMode || ((step.mode || "prompt") === "script" ? "gate" : "advisory")) === "gate" ? "wfm-badge-gate" : "wfm-badge-advisory"}`}>
|
||||
{(step.gateMode || ((step.mode || "prompt") === "script" ? "gate" : "advisory")) === "gate" ? t("workflow.badgeGate", "Gate") : t("workflow.badgeAdvisory", "Advisory")}
|
||||
</span>
|
||||
{step.defaultOn && (
|
||||
<span className="wfm-badge wfm-badge-default-on">
|
||||
{t("workflow.badgeDefaultOn", "Default on")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wfm-step-card-desc">
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="wfm-step-card-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(step)}
|
||||
title={t("common.edit", "Edit")}
|
||||
aria-label={t("workflow.editStepAriaLabel", "Edit {{name}}", { name: step.name })}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{deleteConfirmId === step.id ? (
|
||||
<div className="wfm-delete-confirm">
|
||||
<button
|
||||
className="btn-icon wfm-delete-confirm-btn"
|
||||
onClick={() => handleDelete(step.id)}
|
||||
title={t("workflow.confirmDeleteTitle", "Confirm delete")}
|
||||
aria-label={t("workflow.confirmDeleteAriaLabel", "Confirm delete {{name}}", { name: step.name })}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
title={t("workflow.cancelDeleteTitle", "Cancel delete")}
|
||||
aria-label={t("workflow.cancelDeleteAriaLabel", "Cancel delete")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(step.id)}
|
||||
title={t("common.delete", "Delete")}
|
||||
aria-label={t("workflow.deleteStepAriaLabel", "Delete {{name}}", { name: step.name })}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === "templates" && !isEditing && !showCreateChooser && (
|
||||
<>
|
||||
{templatesLoading ? (
|
||||
<div className="wfm-loading">
|
||||
<Loader2 size={24} className="spin wfm-spinner" />
|
||||
{t("workflow.loadingTemplates", "Loading templates...")}
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="wfm-empty" data-testid="no-templates-state">
|
||||
{t("workflow.noTemplates", "No templates available.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="wfm-template-list">
|
||||
{templates.map((template) => {
|
||||
const IconComponent = getTemplateIcon(template.icon);
|
||||
const categoryClassName = getCategoryClassName(template.category);
|
||||
const isAdding = addingTemplateId === template.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="wfm-template-card"
|
||||
data-testid={`template-${template.id}`}
|
||||
>
|
||||
<div className="wfm-template-inner">
|
||||
{/* Icon */}
|
||||
<div className="wfm-template-icon">
|
||||
<IconComponent size={20} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="wfm-template-content">
|
||||
<div className="wfm-template-title-row">
|
||||
<span className="wfm-template-name">
|
||||
{template.name}
|
||||
</span>
|
||||
<span className={categoryClassName}>
|
||||
{template.category}
|
||||
</span>
|
||||
{pluginTemplateOwners[template.id] && (
|
||||
<span className="wfm-badge-category wfm-badge-category-plugin">
|
||||
{pluginTemplateOwners[template.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wfm-template-desc">
|
||||
{template.description}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary wfm-template-add-btn"
|
||||
onClick={() => handleAddTemplate(template)}
|
||||
disabled={isAdding}
|
||||
data-testid={`add-template-${template.id}`}
|
||||
>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 size={12} className="spin" />
|
||||
{t("workflow.adding", "Adding...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={12} />
|
||||
{t("workflow.add", "Add")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create chooser */}
|
||||
{showCreateChooser && !isEditing && (
|
||||
<div className="wfm-create-chooser" data-testid="workflow-step-create-chooser">
|
||||
<h3 className="wfm-form-title">{t("workflow.createChooserTitle", "How would you like to create this workflow step?")}</h3>
|
||||
<p className="wfm-create-chooser-hint">
|
||||
{t("workflow.createChooserHint", "Start from a built-in template or create a fully custom workflow step.")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="btn wfm-create-custom-btn"
|
||||
onClick={handleCreateCustom}
|
||||
data-testid="create-custom-step"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("workflow.createCustomStep", "Custom workflow step")}
|
||||
</button>
|
||||
|
||||
{templatesLoading ? (
|
||||
<div className="wfm-loading" data-testid="create-chooser-template-loading">
|
||||
<Loader2 size={24} className="spin wfm-spinner" />
|
||||
{t("workflow.loadingBuiltInTemplates", "Loading built-in templates...")}
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="wfm-empty" data-testid="create-chooser-no-templates">
|
||||
{t("workflow.noBuiltInTemplates", "No built-in templates are available right now. You can still create a custom step.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="wfm-template-list" data-testid="create-chooser-templates">
|
||||
{templates.map((template) => {
|
||||
const IconComponent = getTemplateIcon(template.icon);
|
||||
const categoryClassName = getCategoryClassName(template.category);
|
||||
const isAdding = addingTemplateId === template.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="wfm-template-card"
|
||||
data-testid={`chooser-template-${template.id}`}
|
||||
>
|
||||
<div className="wfm-template-inner">
|
||||
<div className="wfm-template-icon">
|
||||
<IconComponent size={20} />
|
||||
</div>
|
||||
|
||||
<div className="wfm-template-content">
|
||||
<div className="wfm-template-title-row">
|
||||
<span className="wfm-template-name">{template.name}</span>
|
||||
<span className={categoryClassName}>{template.category}</span>
|
||||
{pluginTemplateOwners[template.id] && (
|
||||
<span className="wfm-badge-category wfm-badge-category-plugin">
|
||||
{pluginTemplateOwners[template.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wfm-template-desc">{template.description}</div>
|
||||
<button
|
||||
className="btn btn-primary wfm-template-add-btn"
|
||||
onClick={() => handleAddTemplate(template)}
|
||||
disabled={isAdding}
|
||||
data-testid={`chooser-add-template-${template.id}`}
|
||||
>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 size={12} className="spin" />
|
||||
{t("workflow.adding", "Adding...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={12} />
|
||||
{t("workflow.addTemplate", "Add template")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Create form */}
|
||||
{isEditing && (
|
||||
<div className="wfm-form" data-testid="workflow-step-form">
|
||||
<h3 className="wfm-form-title">
|
||||
{isCreating ? t("workflow.newStepTitle", "New Workflow Step") : t("workflow.editStepTitle", "Edit Workflow Step")}
|
||||
</h3>
|
||||
|
||||
<div className="wfm-form-fields">
|
||||
{/* Name */}
|
||||
<div className="wfm-field">
|
||||
<label>{t("workflow.formNameLabel", "Name")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t("workflow.formNamePlaceholder", "e.g. Documentation Review")}
|
||||
data-testid="workflow-step-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="wfm-field">
|
||||
<label>{t("workflow.formDescriptionLabel", "Description")}</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t("workflow.formDescriptionPlaceholder", "Brief description of what this step does")}
|
||||
rows={2}
|
||||
data-testid="workflow-step-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mode Selector */}
|
||||
<div className="wfm-field">
|
||||
<label>{t("workflow.executionModeLabel", "Execution Mode")}</label>
|
||||
<div className="wfm-mode-selector" data-testid="workflow-step-mode-selector">
|
||||
<button
|
||||
className={`btn ${form.mode === "prompt" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, mode: "prompt", gateMode: prev.gateMode || "advisory", scriptName: "" }))}
|
||||
data-testid="mode-prompt"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
{t("workflow.modeAiPrompt", "AI Prompt")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${form.mode === "script" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, mode: "script", gateMode: "gate", prompt: "", modelProvider: "", modelId: "" }))}
|
||||
data-testid="mode-script"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{t("workflow.modeScript", "Run Script")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Selector */}
|
||||
<div className="wfm-field">
|
||||
<label>{t("workflow.executionPhaseLabel", "Execution Phase")}</label>
|
||||
<div className="wfm-mode-selector" data-testid="workflow-step-phase-selector">
|
||||
<button
|
||||
className={`btn ${form.phase === "pre-merge" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, phase: "pre-merge" }))}
|
||||
data-testid="phase-pre-merge"
|
||||
>
|
||||
{t("workflow.phasePreMerge", "Pre-merge")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${form.phase === "post-merge" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, phase: "post-merge" }))}
|
||||
data-testid="phase-post-merge"
|
||||
>
|
||||
{t("workflow.phasePostMerge", "Post-merge")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="wfm-field-hint">
|
||||
{form.phase === "pre-merge"
|
||||
? t("workflow.phasePreMergeHint", "Runs before merge — can block merge on failure")
|
||||
: t("workflow.phasePostMergeHint", "Runs after merge success — failures are logged but do not block")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gate mode */}
|
||||
<div className="wfm-field">
|
||||
<label>{t("workflow.failureBehaviorLabel", "Failure Behavior")}</label>
|
||||
<div className="wfm-mode-selector" data-testid="workflow-step-gate-mode-selector">
|
||||
<button
|
||||
className={`btn ${form.gateMode === "advisory" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, gateMode: "advisory" }))}
|
||||
data-testid="gate-mode-advisory"
|
||||
>
|
||||
{t("workflow.gateModeAdvisory", "Advisory")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${form.gateMode === "gate" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, gateMode: "gate" }))}
|
||||
data-testid="gate-mode-gate"
|
||||
>
|
||||
{t("workflow.gateModeGate", "Gate")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="wfm-field-hint">
|
||||
{form.gateMode === "gate"
|
||||
? t("workflow.gateModeGateHint", "Failures block merge and request remediation.")
|
||||
: t("workflow.gateModeAdvisoryHint", "Failures are recorded as advisory and do not block merge.")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt (AI mode only) */}
|
||||
{form.mode === "prompt" && (
|
||||
<div className="wfm-field">
|
||||
<div className="wfm-prompt-header">
|
||||
<label>{t("workflow.agentPromptLabel", "Agent Prompt")}</label>
|
||||
<button
|
||||
className="btn-icon wfm-refine-btn"
|
||||
onClick={handleRefine}
|
||||
disabled={!form.description.trim() || refining}
|
||||
title={t("workflow.refineWithAiTitle", "Refine with AI")}
|
||||
aria-label={t("workflow.refineWithAiAriaLabel", "Refine prompt with AI")}
|
||||
data-testid="refine-btn"
|
||||
>
|
||||
{refining ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Sparkles size={12} />
|
||||
)}
|
||||
<span>{t("workflow.refineWithAi", "Refine with AI")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={form.prompt}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
|
||||
placeholder={t("workflow.agentPromptPlaceholder", "Leave empty to use AI refinement")}
|
||||
rows={6}
|
||||
className="wfm-prompt-textarea"
|
||||
data-testid="workflow-step-prompt"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model override (prompt mode only) */}
|
||||
{form.mode === "prompt" && (
|
||||
<div className="wfm-field" data-testid="workflow-step-model-field">
|
||||
<div className="wfm-model-header">
|
||||
<label>{t("workflow.modelOverrideLabel", "Model Override")}</label>
|
||||
{form.modelProvider && form.modelId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon wfm-model-clear-btn"
|
||||
onClick={() => setForm((prev) => ({ ...prev, modelProvider: "", modelId: "" }))}
|
||||
title={t("workflow.clearModelOverrideTitle", "Clear model override (use global default)")}
|
||||
data-testid="clear-model-override"
|
||||
>
|
||||
<X size={12} />
|
||||
<span>{t("workflow.useDefault", "Use default")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="wfm-model-hint">
|
||||
{form.modelProvider && form.modelId
|
||||
? t("workflow.modelHintCustom", "Using {{provider}}/{{modelId}}", { provider: form.modelProvider, modelId: form.modelId })
|
||||
: t("workflow.modelHintDefault", "Using global default model")}
|
||||
</span>
|
||||
<div data-testid="workflow-step-model-select">
|
||||
<CustomModelDropdown
|
||||
models={availableModels}
|
||||
value={getModelDropdownValue(form.modelProvider, form.modelId)}
|
||||
onChange={(value: string) => {
|
||||
const parsed = parseModelDropdownValue(value);
|
||||
setForm((prev) => ({ ...prev, modelProvider: parsed.provider, modelId: parsed.modelId }));
|
||||
}}
|
||||
placeholder={t("workflow.modelOverridePlaceholder", "Select a model override…")}
|
||||
label={t("workflow.modelOverrideDropdownLabel", "Model override for this workflow step")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script selector (script mode only) */}
|
||||
{form.mode === "script" && (
|
||||
<div className="wfm-field">
|
||||
<label>{t("workflow.scriptLabel", "Script")}</label>
|
||||
{Object.keys(availableScripts).length === 0 ? (
|
||||
<div className="wfm-no-scripts" data-testid="no-scripts-message">
|
||||
{t("workflow.noScripts", "No scripts configured. Add scripts in Settings → Scripts first.")}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={form.scriptName}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, scriptName: e.target.value }))}
|
||||
data-testid="workflow-step-script-select"
|
||||
>
|
||||
<option value="">{t("workflow.selectScript", "Select a script…")}</option>
|
||||
{Object.entries(availableScripts).map(([name, command]) => (
|
||||
<option key={name} value={name}>
|
||||
{name} ({command})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<label className="wfm-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
data-testid="workflow-step-enabled"
|
||||
/>
|
||||
{t("workflow.enabledCheckbox", "Enabled (available for selection on new tasks)")}
|
||||
</label>
|
||||
|
||||
{/* Default on toggle */}
|
||||
<label className="wfm-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.defaultOn}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, defaultOn: e.target.checked }))}
|
||||
data-testid="workflow-step-default-on"
|
||||
/>
|
||||
{t("workflow.defaultOnCheckbox", "Default on for new tasks")}
|
||||
</label>
|
||||
|
||||
{/* Form actions */}
|
||||
<div className="wfm-form-actions">
|
||||
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saving ||
|
||||
!form.name.trim() ||
|
||||
!form.description.trim() ||
|
||||
(form.mode === "script" && !form.scriptName.trim())
|
||||
}
|
||||
data-testid="save-workflow-step"
|
||||
>
|
||||
{saving ? t("common.saving", "Saving...") : isCreating ? t("common.create", "Create") : t("common.save", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isEditing && !showCreateChooser && (
|
||||
<div className="wfm-footer">
|
||||
<button
|
||||
className="btn btn-primary wfm-footer-add-btn"
|
||||
onClick={handleCreate}
|
||||
data-testid="add-workflow-step"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("workflow.addWorkflowStep", "Add Workflow Step")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,10 +100,6 @@ vi.mock("../GitManagerModal", () => ({
|
||||
GitManagerModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../WorkflowStepManager", () => ({
|
||||
WorkflowStepManager: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../AgentListModal", () => ({
|
||||
AgentListModal: () => null,
|
||||
}));
|
||||
@@ -190,7 +186,6 @@ describe("AppModals", () => {
|
||||
newTaskModalOpen: false,
|
||||
activityLogOpen: false,
|
||||
gitManagerOpen: false,
|
||||
workflowStepsOpen: false,
|
||||
agentsOpen: false,
|
||||
setupWizardOpen: false,
|
||||
modelOnboardingOpen: false,
|
||||
@@ -234,8 +229,6 @@ describe("AppModals", () => {
|
||||
closeActivityLog: vi.fn(),
|
||||
openGitManager: vi.fn(),
|
||||
closeGitManager: vi.fn(),
|
||||
openWorkflowSteps: vi.fn(),
|
||||
closeWorkflowSteps: vi.fn(),
|
||||
openAgents: vi.fn(),
|
||||
closeAgents: vi.fn(),
|
||||
openSetupWizard: vi.fn(),
|
||||
|
||||
@@ -84,6 +84,25 @@ describe("Header", () => {
|
||||
expect(screen.getByTitle("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
describe("workflows button", () => {
|
||||
it("renders the desktop workflows button and opens the editor on click", () => {
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
renderHeader({ onOpenWorkflowEditor }, "desktop");
|
||||
const btn = screen.getByTestId("workflow-steps-btn");
|
||||
expect(btn.getAttribute("title")).toBe("Workflows");
|
||||
fireEvent.click(btn);
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the editor from the mobile overflow menu", () => {
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
renderHeader({ onOpenWorkflowEditor }, "mobile");
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-workflow-steps-btn"));
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides GitHub import for desktop shell host", () => {
|
||||
renderHeader({ shellHost: { kind: "desktop-shell" } });
|
||||
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
|
||||
@@ -1617,7 +1636,7 @@ describe("Header", () => {
|
||||
const { container } = renderHeader({
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
onOpenWorkflowSteps: noop,
|
||||
onOpenWorkflowEditor: noop,
|
||||
onOpenFiles: noop,
|
||||
onOpenGitManager: noop,
|
||||
onOpenScripts: noop,
|
||||
@@ -1651,7 +1670,7 @@ describe("Header", () => {
|
||||
const { container } = renderHeader({
|
||||
onOpenUsage: noop,
|
||||
onOpenActivityLog: noop,
|
||||
onOpenWorkflowSteps: noop,
|
||||
onOpenWorkflowEditor: noop,
|
||||
onOpenFiles: noop,
|
||||
onOpenGitManager: noop,
|
||||
}, "mobile");
|
||||
|
||||
@@ -39,7 +39,7 @@ const createDefaultProps = () => ({
|
||||
mailboxUnreadCount: 0,
|
||||
mailboxPendingApprovalCount: 0,
|
||||
onOpenGitManager: vi.fn(),
|
||||
onOpenWorkflowSteps: vi.fn(),
|
||||
onOpenWorkflowEditor: vi.fn(),
|
||||
onOpenSchedules: vi.fn(),
|
||||
onOpenScripts: vi.fn(),
|
||||
onToggleTerminal: vi.fn(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,6 @@ export interface ModalManager {
|
||||
fileBrowserInitialFile: string | null;
|
||||
activityLogOpen: boolean;
|
||||
gitManagerOpen: boolean;
|
||||
workflowStepsOpen: boolean;
|
||||
workflowEditorOpen: boolean;
|
||||
agentsOpen: boolean;
|
||||
scriptsOpen: boolean;
|
||||
@@ -117,8 +116,6 @@ export interface ModalManager {
|
||||
openGitManager: () => void;
|
||||
closeGitManager: () => void;
|
||||
|
||||
openWorkflowSteps: () => void;
|
||||
closeWorkflowSteps: () => void;
|
||||
openWorkflowEditor: () => void;
|
||||
closeWorkflowEditor: () => void;
|
||||
|
||||
@@ -177,7 +174,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [fileBrowserInitialFile, setFileBrowserInitialFile] = useState<string | null>(null);
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
@@ -196,7 +192,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
todosOpen ||
|
||||
activityLogOpen ||
|
||||
gitManagerOpen ||
|
||||
workflowStepsOpen ||
|
||||
workflowEditorOpen ||
|
||||
scriptsOpen ||
|
||||
agentsOpen ||
|
||||
@@ -345,8 +340,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||
|
||||
const openWorkflowSteps = useCallback(() => setWorkflowStepsOpen(true), []);
|
||||
const closeWorkflowSteps = useCallback(() => setWorkflowStepsOpen(false), []);
|
||||
const openWorkflowEditor = useCallback(() => setWorkflowEditorOpen(true), []);
|
||||
const closeWorkflowEditor = useCallback(() => setWorkflowEditorOpen(false), []);
|
||||
|
||||
@@ -414,7 +407,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
fileBrowserInitialFile,
|
||||
activityLogOpen,
|
||||
gitManagerOpen,
|
||||
workflowStepsOpen,
|
||||
workflowEditorOpen,
|
||||
agentsOpen,
|
||||
scriptsOpen,
|
||||
@@ -458,8 +450,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
closeActivityLog,
|
||||
openGitManager,
|
||||
closeGitManager,
|
||||
openWorkflowSteps,
|
||||
closeWorkflowSteps,
|
||||
openWorkflowEditor,
|
||||
closeWorkflowEditor,
|
||||
openAgents,
|
||||
|
||||
@@ -2443,7 +2443,7 @@
|
||||
"viewActivityLog": "View Activity Log",
|
||||
"viewProjects": "View Projects",
|
||||
"viewUsage": "View usage",
|
||||
"workflowSteps": "Workflow Steps",
|
||||
"workflows": "Workflows",
|
||||
"workingBranch": "Working branch"
|
||||
},
|
||||
"health": {
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"terminal": "Terminal",
|
||||
"todos": "Todos",
|
||||
"usage": "Usage",
|
||||
"workflowSteps": "Workflow Steps"
|
||||
"workflows": "Workflows"
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "Add dependencies",
|
||||
|
||||
@@ -2443,8 +2443,8 @@
|
||||
"viewActivityLog": "Ver registro de actividad",
|
||||
"viewProjects": "Ver proyectos",
|
||||
"viewUsage": "Ver uso",
|
||||
"workflowSteps": "Pasos del flujo de trabajo",
|
||||
"workingBranch": "Rama de trabajo"
|
||||
"workingBranch": "Rama de trabajo",
|
||||
"workflows": ""
|
||||
},
|
||||
"health": {
|
||||
"activeTasks": "Tareas activas",
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"terminal": "Terminal",
|
||||
"todos": "Pendientes",
|
||||
"usage": "Uso",
|
||||
"workflowSteps": "Pasos del flujo de trabajo"
|
||||
"workflows": ""
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "Añadir dependencias",
|
||||
|
||||
@@ -2443,8 +2443,8 @@
|
||||
"viewActivityLog": "Voir le journal d'activité",
|
||||
"viewProjects": "Voir les projets",
|
||||
"viewUsage": "Voir l'utilisation",
|
||||
"workflowSteps": "Étapes du workflow",
|
||||
"workingBranch": "Branche de travail"
|
||||
"workingBranch": "Branche de travail",
|
||||
"workflows": ""
|
||||
},
|
||||
"health": {
|
||||
"activeTasks": "Tâches actives",
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"terminal": "Terminal",
|
||||
"todos": "Tâches à faire",
|
||||
"usage": "Utilisation",
|
||||
"workflowSteps": "Étapes de workflow"
|
||||
"workflows": ""
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "Ajouter des dépendances",
|
||||
|
||||
@@ -2439,12 +2439,12 @@
|
||||
"viewActivityLog": "활동 로그 보기",
|
||||
"viewProjects": "프로젝트 보기",
|
||||
"viewUsage": "사용량 보기",
|
||||
"workflowSteps": "워크플로 단계",
|
||||
"workingBranch": "작업 브랜치",
|
||||
"activePlanningSessions_one": "",
|
||||
"mailboxWithCount_one": "",
|
||||
"resumePlanningSessionCount_one": "",
|
||||
"unreadMessages_one": ""
|
||||
"unreadMessages_one": "",
|
||||
"workflows": ""
|
||||
},
|
||||
"health": {
|
||||
"activeTasks": "활성 작업",
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"terminal": "터미널",
|
||||
"todos": "할 일",
|
||||
"usage": "사용량",
|
||||
"workflowSteps": "워크플로우 단계"
|
||||
"workflows": ""
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "의존성 추가",
|
||||
|
||||
@@ -2439,12 +2439,12 @@
|
||||
"viewActivityLog": "查看活动日志",
|
||||
"viewProjects": "查看项目",
|
||||
"viewUsage": "查看用量",
|
||||
"workflowSteps": "工作流步骤",
|
||||
"workingBranch": "工作分支",
|
||||
"activePlanningSessions_one": "",
|
||||
"mailboxWithCount_one": "",
|
||||
"resumePlanningSessionCount_one": "",
|
||||
"unreadMessages_one": ""
|
||||
"unreadMessages_one": "",
|
||||
"workflows": ""
|
||||
},
|
||||
"health": {
|
||||
"activeTasks": "活动任务",
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"terminal": "终端",
|
||||
"todos": "待办",
|
||||
"usage": "用量",
|
||||
"workflowSteps": "工作流步骤"
|
||||
"workflows": ""
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "添加依赖项",
|
||||
|
||||
@@ -2439,12 +2439,12 @@
|
||||
"viewActivityLog": "查看活動記錄",
|
||||
"viewProjects": "查看專案",
|
||||
"viewUsage": "查看用量",
|
||||
"workflowSteps": "工作流程步驟",
|
||||
"workingBranch": "工作分支",
|
||||
"activePlanningSessions_one": "",
|
||||
"mailboxWithCount_one": "",
|
||||
"resumePlanningSessionCount_one": "",
|
||||
"unreadMessages_one": ""
|
||||
"unreadMessages_one": "",
|
||||
"workflows": ""
|
||||
},
|
||||
"health": {
|
||||
"activeTasks": "活躍任務",
|
||||
@@ -3459,7 +3459,7 @@
|
||||
"terminal": "終端機",
|
||||
"todos": "待辦",
|
||||
"usage": "用量",
|
||||
"workflowSteps": "工作流程步驟"
|
||||
"workflows": ""
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "新增相依性",
|
||||
|
||||
4
packages/i18n/src/resources.d.ts
vendored
4
packages/i18n/src/resources.d.ts
vendored
@@ -2445,7 +2445,7 @@ export default interface Resources {
|
||||
"viewActivityLog": "View Activity Log",
|
||||
"viewProjects": "View Projects",
|
||||
"viewUsage": "View usage",
|
||||
"workflowSteps": "Workflow Steps",
|
||||
"workflows": "Workflows",
|
||||
"workingBranch": "Working branch"
|
||||
},
|
||||
"health": {
|
||||
@@ -3461,7 +3461,7 @@ export default interface Resources {
|
||||
"terminal": "Terminal",
|
||||
"todos": "Todos",
|
||||
"usage": "Usage",
|
||||
"workflowSteps": "Workflow Steps"
|
||||
"workflows": "Workflows"
|
||||
},
|
||||
"newTaskModal": {
|
||||
"addDependencies": "Add dependencies",
|
||||
|
||||
Reference in New Issue
Block a user