feat(dashboard): Workflows, Import Tasks, Automations as left-sidebar main views
- New built-in task views: workflows, import-tasks, automations (left-sidebar destinations rendering in the main content area). - WorkflowNodeEditor, GitHubImportModal, and ScheduledTasksModal gain a presentation=embedded mode (inline, no overlay/close, modal-only behaviors disabled). Automations embedded view uses a Command Center-style header and a responsive two-pane (list + detail) layout when wide enough. - Left sidebar adds Workflows, Import Tasks, Automations entries; renderMainContent renders the embedded views. - Remove github-import and automation from the right dock; hide the desktop Header Workflow button when the left sidebar owns Workflows. Mobile overflow keeps the modal entry points unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,13 @@ const DevServerView = lazy(() => import("./components/DevServerView").then((m) =
|
||||
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
|
||||
const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView })));
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
Workflows, Import Tasks (GitHub import), and Automations render as embedded main-content views (presentation="embedded") via these lazy chunks; the same components still mount as modals in AppModals for the mobile overflow path.
|
||||
*/
|
||||
const WorkflowEditorView = lazy(() => import("./components/WorkflowNodeEditor").then((m) => ({ default: m.WorkflowNodeEditor })));
|
||||
const ImportTasksView = lazy(() => import("./components/GitHubImportModal").then((m) => ({ default: m.GitHubImportModal })));
|
||||
const AutomationsView = lazy(() => import("./components/ScheduledTasksModal").then((m) => ({ default: m.ScheduledTasksModal })));
|
||||
|
||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
||||
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
||||
@@ -1832,6 +1839,58 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
Workflows, Import Tasks (GitHub import), and Automations are left-sidebar destinations that render embedded in the main content area instead of as modal overlays. Closing returns to the board. The same components still mount as modals in AppModals for the mobile overflow path.
|
||||
*/
|
||||
if (taskView === "workflows") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<WorkflowEditorView
|
||||
isOpen={true}
|
||||
onClose={() => handleChangeTaskView("board")}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
presentation="embedded"
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "import-tasks") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<ImportTasksView
|
||||
isOpen={true}
|
||||
onClose={() => handleChangeTaskView("board")}
|
||||
onImport={handleGitHubImport}
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
presentation="embedded"
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "automations") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<AutomationsView
|
||||
onClose={() => handleChangeTaskView("board")}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
presentation="embedded"
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "devserver" || taskView === "dev-server") {
|
||||
if (!settingsLoaded || !devServerEnabled) {
|
||||
return null;
|
||||
|
||||
@@ -937,4 +937,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:RightDockEmbedding 2026-06-22-00:00:
|
||||
Right-dock redesign renders the GitHub import surface inline in the main content area instead of as a fixed popup overlay.
|
||||
The embedded root is a plain flow box that fills the host; the inner shell sheds overlay-only chrome (fixed sizing, box-shadow, rounded corners, resize) and fills 100% so the main panel owns the frame. No close button is rendered in embedded mode.
|
||||
*/
|
||||
.github-import-embedded.right-dock-embedded-view {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.github-import-modal.github-import-modal--embedded {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
position: static;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ interface GitHubImportModalProps {
|
||||
onImport: (task: Task) => void;
|
||||
tasks: Task[];
|
||||
projectId?: string;
|
||||
/*
|
||||
FNXC:RightDockEmbedding 2026-06-22-00:00:
|
||||
Right-dock redesign renders the GitHub import surface inline inside the main content area instead of as a fixed popup overlay.
|
||||
"embedded" drops the modal overlay/close button and disables modal-only chrome (scroll lock, resize persistence, escape/overlay dismiss); "modal" (default) keeps the original byte-identical overlay behavior.
|
||||
*/
|
||||
presentation?: "modal" | "embedded";
|
||||
}
|
||||
|
||||
// Mobile and two-pane breakpoints in pixels
|
||||
@@ -50,8 +56,9 @@ function formatPreviewBody(body: string | null | undefined, isMobile: boolean) {
|
||||
return body.slice(0, 200) + (body.length > 200 ? "…" : "");
|
||||
}
|
||||
|
||||
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
|
||||
useMobileScrollLock(isOpen);
|
||||
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
|
||||
const isEmbedded = presentation === "embedded";
|
||||
useMobileScrollLock(isOpen && !isEmbedded);
|
||||
const { t } = useTranslation("app");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
@@ -80,7 +87,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
||||
const mountedRef = useRef(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:github-modal-size");
|
||||
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:github-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
// Responsive view state
|
||||
@@ -281,14 +288,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
}, [owner, repo, labels, activeTab, isOpen, loading, importing, handleLoad, handleLoadPulls]);
|
||||
|
||||
// Handle escape key
|
||||
// FNXC:RightDockEmbedding 2026-06-22-00:00: Escape-to-close is a modal-only affordance; embedded mode has no dismiss.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen || isEmbedded) return;
|
||||
const handleKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen, isEmbedded, onClose]);
|
||||
|
||||
// Detect responsive viewport bands
|
||||
useEffect(() => {
|
||||
@@ -480,9 +488,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const showPullsError = Boolean(error) && pulls.length > 0 && !isPullsEmpty;
|
||||
const showInlineErrorBanner = activeTab === "issues" ? showIssuesError : showPullsError;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg github-import-modal" ref={modalRef}>
|
||||
/*
|
||||
FNXC:RightDockEmbedding 2026-06-22-00:00:
|
||||
Embedded mode renders the import surface as a main-content-area view (no fixed .modal-overlay, no close button, no overlay-dismiss).
|
||||
Modal mode is kept byte-identical: same overlay wrapper, header with subtitle + close button, and overlay-dismiss props.
|
||||
*/
|
||||
const inner = (
|
||||
<div className={`modal modal-lg github-import-modal${isEmbedded ? " github-import-modal--embedded" : ""}`} ref={modalRef}>
|
||||
{isEmbedded ? (
|
||||
<div className="modal-header github-import-modal__header">
|
||||
<h3>{t("git.importTasksHeading", "Import Tasks")}</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="modal-header github-import-modal__header">
|
||||
<div>
|
||||
<h3>{t("git.importFromGitHub", "Import from GitHub")}</h3>
|
||||
@@ -494,6 +511,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-body github-import-modal__body">
|
||||
{/* Tab Navigation */}
|
||||
@@ -861,7 +879,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isEmbedded) {
|
||||
return <div className="github-import-embedded right-dock-embedded-view">{inner}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -945,8 +945,11 @@ export function Header({
|
||||
FN-6886 removes the header Lightbulb affordances because Planning Mode is now a primary left-sidebar destination after Command Center and a single canonical MobileNavBar More item on compact breakpoints.
|
||||
*/}
|
||||
|
||||
{/* Workflows - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenWorkflowEditor && (
|
||||
{/*
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
When the left sidebar is active it owns Workflows as a main-content destination, so the Header drops its duplicate desktop Workflow button. The flag-off desktop layout keeps the Header button; mobile/tablet keep the overflow entry.
|
||||
*/}
|
||||
{!isCompact && !leftSidebarNavActive && onOpenWorkflowEditor && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenWorkflowEditor}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileText,
|
||||
Gauge,
|
||||
GitPullRequestArrow,
|
||||
Lightbulb,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
Settings,
|
||||
Sparkles,
|
||||
Target,
|
||||
Workflow,
|
||||
Zap,
|
||||
type LucideProps,
|
||||
} from "lucide-react";
|
||||
@@ -346,6 +349,37 @@ export function LeftSidebarNav({
|
||||
Secrets and Todos are intentionally omitted from the left sidebar. They live in the right dock through RightDock/overflowViewRegistry, while mobile keeps its More-sheet entries and the Header opt-out layout keeps its overflow entries.
|
||||
*/
|
||||
const secondaryEntries: SidebarNavEntry[] = [
|
||||
/*
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
Workflows, Import Tasks, and Automations are left-sidebar destinations that load in the main content area (not modals). Import Tasks is the GitHub import view (labeled "Import Tasks", not "Import from GitHub").
|
||||
*/
|
||||
{
|
||||
id: "workflows",
|
||||
label: t("nav.workflows", "Workflows"),
|
||||
view: "workflows" as TaskView,
|
||||
isActive: view === "workflows",
|
||||
icon: Workflow,
|
||||
testId: "sidebar-nav-workflows",
|
||||
onSelect: () => onChangeView("workflows"),
|
||||
},
|
||||
{
|
||||
id: "import-tasks",
|
||||
label: t("nav.importTasks", "Import Tasks"),
|
||||
view: "import-tasks" as TaskView,
|
||||
isActive: view === "import-tasks",
|
||||
icon: GitPullRequestArrow,
|
||||
testId: "sidebar-nav-import-tasks",
|
||||
onSelect: () => onChangeView("import-tasks"),
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
label: t("nav.automations", "Automations"),
|
||||
view: "automations" as TaskView,
|
||||
isActive: view === "automations",
|
||||
icon: Clock,
|
||||
testId: "sidebar-nav-automations",
|
||||
onSelect: () => onChangeView("automations"),
|
||||
},
|
||||
...(experimentalFeatures?.evalsView
|
||||
? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
|
||||
: []),
|
||||
|
||||
@@ -25,15 +25,26 @@ const POLL_INTERVAL_MS = 30_000;
|
||||
/** Scheduling scope: global (user-level) or project-scoped. */
|
||||
export type SchedulingScope = "global" | "project";
|
||||
|
||||
/**
|
||||
* FNXC:AutomationsEmbedded 2026-06-22-00:00:
|
||||
* Automations can render either as a fixed modal overlay ("modal", the default and historical path) or inline
|
||||
* as a main-content-area view ("embedded"). The embedded presentation fills the main panel like Command Center:
|
||||
* no overlay, no card/shadow/border chrome, a plain `.cc-header`-style title row, and a responsive two-pane
|
||||
* body (list + detail) that collapses to a single column below ~900px. The modal path is kept byte-identical;
|
||||
* modal-only behaviors (scroll lock via resize-persist, escape-to-close, overlay dismiss) are disabled when embedded.
|
||||
*/
|
||||
interface ScheduledTasksModalProps {
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
/** Optional project ID for project-scoped scheduling. When provided, scope defaults to "project". */
|
||||
projectId?: string;
|
||||
/** Presentation surface. "modal" (default) renders a fixed overlay; "embedded" renders inline in the main content area. */
|
||||
presentation?: "modal" | "embedded";
|
||||
}
|
||||
|
||||
export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledTasksModalProps) {
|
||||
export function ScheduledTasksModal({ onClose, addToast, projectId, presentation = "modal" }: ScheduledTasksModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const isEmbedded = presentation === "embedded";
|
||||
// Scope state: defaults to "project" when projectId exists, else "global"
|
||||
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
|
||||
|
||||
@@ -43,9 +54,12 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
const [editingRoutine, setEditingRoutine] = useState<Routine | undefined>();
|
||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||
const [lastRunOutput, setLastRunOutput] = useState<Record<string, { output: string; error?: string; success: boolean }>>({});
|
||||
// FNXC:AutomationsEmbedded 2026-06-22-00:00: Two-pane embedded layout tracks the routine selected in the left list to render its detail on the right.
|
||||
const [selectedRoutineId, setSelectedRoutineId] = useState<string | null>(null);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:automation-modal-size");
|
||||
// Resize-persist is a modal-only affordance; the embedded view fills its host and never resizes.
|
||||
useModalResizePersist(modalRef, !isEmbedded, "fusion:automation-modal-size");
|
||||
|
||||
// Build scope options for API calls
|
||||
const scopeOptions = useMemo(() => ({
|
||||
@@ -91,8 +105,10 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
return () => clearInterval(interval);
|
||||
}, [loadRoutines]);
|
||||
|
||||
// Close on Escape (only when not in a sub-form)
|
||||
// Close on Escape (only when not in a sub-form).
|
||||
// FNXC:AutomationsEmbedded 2026-06-22-00:00: Escape-to-close is a modal-only affordance; the embedded view lives in the main content area and must not hijack Escape.
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (routineView !== "list") {
|
||||
@@ -105,7 +121,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, routineView]);
|
||||
}, [onClose, routineView, isEmbedded]);
|
||||
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
@@ -224,6 +240,18 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
setLastRunOutput({});
|
||||
}, []);
|
||||
|
||||
// FNXC:AutomationsEmbedded 2026-06-22-00:00: Keep the embedded detail-pane selection valid; clear it when the selected routine disappears from the (possibly re-scoped/re-polled) list.
|
||||
useEffect(() => {
|
||||
if (selectedRoutineId && !routines.some((r) => r.id === selectedRoutineId)) {
|
||||
setSelectedRoutineId(null);
|
||||
}
|
||||
}, [routines, selectedRoutineId]);
|
||||
|
||||
const selectedRoutine = useMemo(
|
||||
() => routines.find((r) => r.id === selectedRoutineId) ?? null,
|
||||
[routines, selectedRoutineId],
|
||||
);
|
||||
|
||||
// ── Render content ─────────────────────────────────────────────────────
|
||||
|
||||
const renderRoutinesContent = () => {
|
||||
@@ -286,6 +314,132 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
// Determine if we're in "list" view for showing the "New" button
|
||||
const isShowingList =
|
||||
routineView === "list" && routines.length > 0;
|
||||
|
||||
// Shared scope/count/new-automation toolbar, used by both the modal and embedded presentations.
|
||||
const toolbar = (
|
||||
<div className="scheduling-toolbar" aria-live="polite">
|
||||
<div className="scheduling-toolbar-left" role="group" aria-label={t("schedule.scopeGroup", "Scheduling scope")}>
|
||||
<div className="scheduling-scope-selector">
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-btn${activeScope === "global" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("global")}
|
||||
aria-pressed={activeScope === "global"}
|
||||
title={t("schedule.globalScope", "Global (user-level) automations")}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{t("schedule.global", "Global")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-btn${activeScope === "project" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("project")}
|
||||
aria-pressed={activeScope === "project"}
|
||||
title={t("schedule.projectScope", "Project-scoped automations")}
|
||||
>
|
||||
<Folder size={14} />
|
||||
{t("schedule.project", "Project")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="scheduling-count">
|
||||
<Zap size={14} />
|
||||
{t("schedule.automationCount", "{{count}} automation{{plural}}", { count: routines.length, plural: routines.length === 1 ? "" : "s" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scheduling-toolbar-right">
|
||||
{isShowingList && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setRoutineView("create")}
|
||||
aria-label={t("schedule.createNew", "Create new automation")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("schedule.newAutomation", "New Automation")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Embedded (main-content-area) presentation ───────────────────────────
|
||||
// FNXC:AutomationsEmbedded 2026-06-22-00:00:
|
||||
// Renders inline like Command Center: no overlay/close, a plain .cc-header title row, --space-lg view padding,
|
||||
// no card chrome. The body is a responsive two-pane layout: a left list pane and a right detail pane that
|
||||
// collapse to a single column below ~900px (see .automations-embedded CSS). In list view the left pane shows a
|
||||
// compact selectable rail; selecting a routine renders its full RoutineCard on the right. In create/edit view the
|
||||
// editor spans the full width.
|
||||
if (isEmbedded) {
|
||||
const isListView = routineView === "list";
|
||||
return (
|
||||
<div className="automations-embedded right-dock-embedded-view">
|
||||
<div className="automations-embedded-view">
|
||||
<div className="cc-header automations-embedded-header">
|
||||
<h3 className="cc-title" id="schedules-modal-title">
|
||||
<Zap size={20} className="icon-triage" />
|
||||
{t("schedule.title", "Automations")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{toolbar}
|
||||
|
||||
{isListView && routines.length > 0 ? (
|
||||
<div className="automations-two-pane">
|
||||
{/* Left pane: compact selectable list of automations */}
|
||||
<div className="automations-list-pane" role="listbox" aria-label={t("schedule.title", "Automations")}>
|
||||
{routines.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedRoutineId === r.id}
|
||||
className={`automation-list-row${selectedRoutineId === r.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedRoutineId(r.id)}
|
||||
>
|
||||
<Zap size={14} className="icon-triage" />
|
||||
<span className="automation-list-row-name">{r.name}</span>
|
||||
{!r.enabled && (
|
||||
<span className="automation-list-row-badge">{t("schedule.disabled", "Disabled")}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right pane: detail for the selected automation, or an empty prompt */}
|
||||
<div className="automations-detail-pane">
|
||||
{selectedRoutine ? (
|
||||
<div className="routine-list">
|
||||
<RoutineCard
|
||||
key={selectedRoutine.id}
|
||||
routine={selectedRoutine}
|
||||
onEdit={handleEditRoutine}
|
||||
onDelete={handleDeleteRoutine}
|
||||
onRun={handleRunRoutine}
|
||||
onToggle={handleToggleRoutine}
|
||||
running={runningRoutineId === selectedRoutine.id}
|
||||
lastRunOutput={lastRunOutput[selectedRoutine.id] ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="routine-empty-state automations-detail-empty">
|
||||
<Zap size={48} strokeWidth={1} />
|
||||
<h4>{t("schedule.selectAutomation", "Select an automation")}</h4>
|
||||
<p>{t("schedule.selectAutomationHint", "Choose an automation from the list to view its details.")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Empty state, create, and edit views span the full width (single column).
|
||||
<div className="automations-single-pane">
|
||||
{renderContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal (fixed overlay) presentation ──────────────────────────────────
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps}>
|
||||
<div ref={modalRef} className="modal modal-lg automation-modal" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
|
||||
@@ -299,48 +453,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scheduling-toolbar" aria-live="polite">
|
||||
<div className="scheduling-toolbar-left" role="group" aria-label={t("schedule.scopeGroup", "Scheduling scope")}>
|
||||
<div className="scheduling-scope-selector">
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-btn${activeScope === "global" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("global")}
|
||||
aria-pressed={activeScope === "global"}
|
||||
title={t("schedule.globalScope", "Global (user-level) automations")}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{t("schedule.global", "Global")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-btn${activeScope === "project" ? " active" : ""}`}
|
||||
onClick={() => handleScopeSwitch("project")}
|
||||
aria-pressed={activeScope === "project"}
|
||||
title={t("schedule.projectScope", "Project-scoped automations")}
|
||||
>
|
||||
<Folder size={14} />
|
||||
{t("schedule.project", "Project")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="scheduling-count">
|
||||
<Zap size={14} />
|
||||
{t("schedule.automationCount", "{{count}} automation{{plural}}", { count: routines.length, plural: routines.length === 1 ? "" : "s" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scheduling-toolbar-right">
|
||||
{isShowingList && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setRoutineView("create")}
|
||||
aria-label={t("schedule.createNew", "Create new automation")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("schedule.newAutomation", "New Automation")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{toolbar}
|
||||
|
||||
<div className="schedule-modal-content" id="scheduled-tasks-content">
|
||||
{renderContent()}
|
||||
|
||||
@@ -1215,6 +1215,132 @@ The embedded root is a plain flow box that fills the dock; the inner panel sheds
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AutomationsEmbedded 2026-06-22-00:00:
|
||||
Automations can render inline in the main content area (presentation="embedded") instead of as a fixed modal overlay.
|
||||
The embedded root fills its host and sheds all modal chrome — no overlay, no card/shadow/border/radius — so the view
|
||||
blends into the main panel like Command Center. The view container carries --space-lg padding and a plain .cc-header
|
||||
title row (reused from Command Center). The body is a responsive two-pane layout (list + detail) via container query
|
||||
when supported, falling back to a min-width media breakpoint, that collapses to a single column below ~900px.
|
||||
*/
|
||||
.automations-embedded.right-dock-embedded-view {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.automations-embedded-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
min-height: 0;
|
||||
inline-size: 100%;
|
||||
padding: var(--space-lg);
|
||||
/* Enable container-query-driven two-pane breakpoint scoped to the view's own width, not the viewport. */
|
||||
container-type: inline-size;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Two-pane body: single column by default (narrow); two columns when the container is wide enough. */
|
||||
.automations-two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-lg);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.automations-single-pane {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Left list rail */
|
||||
.automations-list-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.automation-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.automation-list-row:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.automation-list-row.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle, var(--card));
|
||||
}
|
||||
|
||||
.automation-list-row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.automation-list-row-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
/* Right detail pane */
|
||||
.automations-detail-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.automations-detail-empty {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Two columns once the embedded container is wide enough (~900px). */
|
||||
@container (min-width: 900px) {
|
||||
.automations-two-pane {
|
||||
grid-template-columns: minmax(0, 18rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Fallback for browsers without container-query support: use a viewport media query. Harmless where container
|
||||
queries already apply (the container-query rule above also fires and produces the same two-column layout).
|
||||
*/
|
||||
@media (min-width: 900px) {
|
||||
.automations-two-pane {
|
||||
grid-template-columns: minmax(0, 18rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-log-header {
|
||||
/* Extends shared .modal-header with activity-log-specific overrides */
|
||||
gap: var(--space-sm);
|
||||
|
||||
@@ -14,6 +14,35 @@
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowEditorEmbedding 2026-06-22-00:00:
|
||||
Embedded presentation renders the editor inline as a main-content-area view
|
||||
filling the right-dock panel instead of as a centered fixed modal. The wrapper
|
||||
takes the full panel box and the modal element drops its modal chrome
|
||||
(fixed sizing, box-shadow, border-radius, resize grip) so it reads as a flush
|
||||
embedded view.
|
||||
*/
|
||||
.workflow-editor-embedded {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wf-editor-modal--embedded {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: static;
|
||||
resize: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.wf-create-modal {
|
||||
--wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs));
|
||||
}
|
||||
|
||||
@@ -197,6 +197,17 @@ interface WorkflowNodeEditorProps {
|
||||
initialAction?: "create";
|
||||
/** Workflow id to preselect when the editor opens from workflow-aware surfaces. */
|
||||
initialWorkflowId?: string;
|
||||
/*
|
||||
FNXC:WorkflowEditorEmbedding 2026-06-22-00:00:
|
||||
The workflow editor can render either as a fixed modal overlay ("modal", the
|
||||
default and historical behavior) or inline as a main-content-area view
|
||||
("embedded") that fills the right-dock panel like a Command Center view.
|
||||
In embedded mode the editor drops the .modal-overlay shell, the X close
|
||||
button, native resize, and all modal-only dismiss paths (Escape, overlay
|
||||
click) so it reads as a persistent view rather than a dismissible dialog.
|
||||
The modal path stays byte-identical when presentation is "modal"/undefined.
|
||||
*/
|
||||
presentation?: "modal" | "embedded";
|
||||
}
|
||||
|
||||
let nodeSeq = 0;
|
||||
@@ -697,7 +708,11 @@ function InnerEditor({
|
||||
initialAction,
|
||||
initialWorkflowId,
|
||||
modalRef,
|
||||
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
isEmbedded = false,
|
||||
}: Omit<WorkflowNodeEditorProps, "isOpen" | "presentation"> & {
|
||||
modalRef: React.RefObject<HTMLDivElement | null>;
|
||||
isEmbedded?: boolean;
|
||||
}) {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const viewportMode = useViewportMode();
|
||||
@@ -2435,11 +2450,15 @@ function InnerEditor({
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
|
||||
// FNXC:WorkflowEditorEmbedding 2026-06-22-00:00:
|
||||
// Embedded mode renders the editor inline inside the right-dock panel: no
|
||||
// fixed .modal-overlay shell, no overlay-click dismiss, no Escape-to-close,
|
||||
// and a --embedded sized variant of the modal element. The modal path stays
|
||||
// byte-identical (same overlay + overlayProps + Escape handler) when not
|
||||
// embedded.
|
||||
const modalElement = (
|
||||
<div
|
||||
className="modal wf-editor-modal"
|
||||
className={`modal wf-editor-modal${isEmbedded ? " wf-editor-modal--embedded" : ""}`}
|
||||
ref={modalRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
@@ -2447,6 +2466,8 @@ function InnerEditor({
|
||||
// Ignore Escape originating from inputs/textareas/selects so inline
|
||||
// editors (name/description) keep their own Escape-to-cancel behavior.
|
||||
if (e.key !== "Escape") return;
|
||||
// Embedded views are persistent; Escape must not dismiss them.
|
||||
if (isEmbedded) return;
|
||||
// The create dialog (rendered as a child) owns its own Escape; if it's
|
||||
// open, let it handle the event (it stops propagation already).
|
||||
if (createOpen) return;
|
||||
@@ -2459,9 +2480,13 @@ function InnerEditor({
|
||||
>
|
||||
<header className="wf-editor-header">
|
||||
<h2>{t("workflows.title", "Workflows")}</h2>
|
||||
<button className="wf-editor-close" onClick={requestClose} aria-label={t("workflows.closeEditor", "Close workflow editor")}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
{/* FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: embedded views keep a
|
||||
Command Center-style header title but drop the modal X close button. */}
|
||||
{!isEmbedded ? (
|
||||
<button className="wf-editor-close" onClick={requestClose} aria-label={t("workflows.closeEditor", "Close workflow editor")}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{showMigrationNotice ? (
|
||||
@@ -4598,7 +4623,20 @@ function InnerEditor({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isEmbedded ? (
|
||||
// FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: inline main-content
|
||||
// wrapper (no fixed overlay, no overlayProps overlay-click dismiss).
|
||||
<div className="workflow-editor-embedded right-dock-embedded-view">
|
||||
{modalElement}
|
||||
</div>
|
||||
) : (
|
||||
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
|
||||
{modalElement}
|
||||
</div>
|
||||
)}
|
||||
{promptFullscreenOverlay}
|
||||
</>
|
||||
);
|
||||
@@ -4612,9 +4650,14 @@ export function WorkflowNodeEditor({
|
||||
initialPanel,
|
||||
initialAction,
|
||||
initialWorkflowId,
|
||||
presentation = "modal",
|
||||
}: WorkflowNodeEditorProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:workflow-node-editor-size");
|
||||
const isEmbedded = presentation === "embedded";
|
||||
// FNXC:WorkflowEditorEmbedding 2026-06-22-00:00:
|
||||
// Size persistence + native resize are modal-only; an embedded view fills its
|
||||
// host panel (width/height:100%) so persisting a saved pixel size is wrong.
|
||||
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:workflow-node-editor-size");
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
@@ -4626,6 +4669,7 @@ export function WorkflowNodeEditor({
|
||||
initialAction={initialAction}
|
||||
initialWorkflowId={initialWorkflowId}
|
||||
modalRef={modalRef}
|
||||
isEmbedded={isEmbedded}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Suspense, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Folder,
|
||||
GitBranch,
|
||||
GitPullRequestArrow,
|
||||
History,
|
||||
type LucideProps,
|
||||
} from "lucide-react";
|
||||
@@ -25,10 +23,8 @@ import { GitManagerModal } from "./GitManagerModal";
|
||||
export type OverflowViewKey =
|
||||
| "usage"
|
||||
| "activity-log"
|
||||
| "github-import"
|
||||
| "git-manager"
|
||||
| "files"
|
||||
| "automation"
|
||||
| `plugin:${string}:${string}`;
|
||||
|
||||
export interface OverflowViewFeatureState {
|
||||
@@ -149,13 +145,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
||||
/>,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "github-import",
|
||||
label: "Import from GitHub",
|
||||
icon: GitPullRequestArrow,
|
||||
testId: "right-dock-tab-github-import",
|
||||
onActivate: (props) => props.onOpenGitHubImport?.(),
|
||||
},
|
||||
{
|
||||
key: "git-manager",
|
||||
label: "Git Manager",
|
||||
@@ -179,13 +168,6 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
||||
testId: "right-dock-tab-files",
|
||||
render: (props) => wrapOverflowView(<InlineFilesView projectId={props.projectId} openFile={props.openFile} />),
|
||||
},
|
||||
{
|
||||
key: "automation",
|
||||
label: "Automation",
|
||||
icon: Clock,
|
||||
testId: "right-dock-tab-automation",
|
||||
onActivate: (props) => props.onOpenSchedules?.(),
|
||||
},
|
||||
];
|
||||
|
||||
function buildPluginOverflowViewEntries(pluginDashboardViews: PluginDashboardViewEntry[] = []): OverflowViewEntry[] {
|
||||
|
||||
@@ -5,7 +5,11 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests";
|
||||
/*
|
||||
FNXC:ViewState 2026-06-22-00:00:
|
||||
Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center.
|
||||
*/
|
||||
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations";
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||
|
||||
@@ -40,6 +44,9 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
||||
"devserver",
|
||||
"dev-server",
|
||||
"pull-requests",
|
||||
"workflows",
|
||||
"import-tasks",
|
||||
"automations",
|
||||
];
|
||||
|
||||
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
||||
|
||||
Reference in New Issue
Block a user