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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/pull-request-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Pull Request Checks

on:
pull_request:
branches:
- main

permissions:
contents: read

jobs:
unit-tests:
name: unit-tests
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.2

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run unit tests
run: pnpm test
10 changes: 8 additions & 2 deletions apps/desktop/renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
Expand All @@ -25,7 +26,12 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^4.3.0",
"vite": "^6.0.0"
"jsdom": "^29.1.1",
"vite": "^6.0.0",
"vitest": "^4.1.6"
}
}
2 changes: 2 additions & 0 deletions apps/desktop/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export default function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/workflows/new" element={<SettingsPage />} />
<Route path="/settings/workflows/:filename/edit" element={<SettingsPage />} />
<Route path="/ticket/:id" element={<TicketPage />} />
</Routes>

Expand Down
109 changes: 109 additions & 0 deletions apps/desktop/renderer/src/WorkflowEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";

const mocks = vi.hoisted(() => ({
desktopApi: {},
}));

vi.mock("./lib/api-client", () => ({
getAppApi: () => mocks.desktopApi,
}));

vi.mock("@xyflow/react", async () => {
const React = await import("react");
return {
Background: () => null,
BaseEdge: () => null,
Controls: () => null,
Handle: () => null,
MarkerType: { ArrowClosed: "arrowclosed" },
MiniMap: () => null,
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
ReactFlow: ({ children }) => <div data-testid="workflow-canvas">{children}</div>,
useNodesState: (initialNodes) => {
const [nodes, setNodes] = React.useState(initialNodes);
return [nodes, setNodes, vi.fn()];
},
};
});

import WorkflowEditor from "./WorkflowEditor";
import { I18nProvider } from "./components/i18n-provider";
import { useWorkflowEditorStore } from "./stores/workflowEditorStore";

const workflow = {
id: "default-codex",
name: "Default Codex",
visible: true,
version: 1,
runtime: {
engine: "langgraph",
backend: "codex",
model: "",
workspaceAccess: "write",
options: {},
},
ui: {
layout: "vertical",
nodePositions: {},
},
worktree: {
enabled: false,
files: [],
customFiles: [],
removeOnComplete: false,
},
steps: [
{
id: "implement",
type: "agent",
label: "Implement",
prompt: "Implement the requested task.",
next: "",
inputs: [],
outputs: [{ key: "result", kind: "markdown", filename: "result.md" }],
},
],
};

function setupDesktopApi() {
Object.assign(mocks.desktopApi, {
getWorkflow: vi.fn().mockResolvedValue(workflow),
createWorkflow: vi.fn(),
createWorkflowDraft: vi.fn(),
updateWorkflow: vi.fn(),
updateWorkflowDraft: vi.fn(),
});
}

function renderWorkflowEditor(filename = "default-codex.json") {
return render(
<I18nProvider>
<WorkflowEditor filename={filename} onClose={vi.fn()} onSaved={vi.fn()} />
</I18nProvider>
);
}

describe("WorkflowEditor", () => {
beforeEach(() => {
sessionStorage.clear();
for (const key of Object.keys(mocks.desktopApi)) {
delete mocks.desktopApi[key];
}
useWorkflowEditorStore.getState().resetEditor();
setupDesktopApi();
});

test("renders while an existing workflow is loading and then hydrates the draft", async () => {
renderWorkflowEditor();

expect(screen.getByPlaceholderText("Workflow name")).toBeInTheDocument();
expect(screen.getByTestId("workflow-canvas")).toBeInTheDocument();

await waitFor(() => {
expect(mocks.desktopApi.getWorkflow).toHaveBeenCalledWith("default-codex.json");
expect(useWorkflowEditorStore.getState().currentFilename).toBe("default-codex.json");
});
expect(screen.getByDisplayValue("Default Codex")).toBeInTheDocument();
});
});
65 changes: 42 additions & 23 deletions apps/desktop/renderer/src/WorkflowEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { WindowChrome } from "./components/window-chrome";
import { cn } from "./lib/utils";
import { getAppApi } from "./lib/api-client";
import { useConfigStore } from "./stores/configStore";
import { getWorkflowEditorKey, useWorkflowEditorStore } from "./stores/workflowEditorStore";
import { useWorkflowStore } from "./stores/workflowStore";

const desktopApi = getAppApi();
Expand Down Expand Up @@ -557,12 +558,21 @@ function getReasoningLabel(value) {

export default function WorkflowEditor({ filename, onClose, onSaved }) {
const { t } = useI18n();
const [workflow, setWorkflow] = useState(() => createDefaultWorkflow({ includeStartStep: !filename }));
const [currentFilename, setCurrentFilename] = useState(filename || null);
const [selectedIdx, setSelectedIdx] = useState(filename ? null : 0);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const editorKey = useWorkflowEditorStore((s) => s.editorKey);
const storedWorkflow = useWorkflowEditorStore((s) => s.workflow);
const currentFilename = useWorkflowEditorStore((s) => s.currentFilename);
const selectedIdx = useWorkflowEditorStore((s) => s.selectedIdx);
const dirty = useWorkflowEditorStore((s) => s.dirty);
const saving = useWorkflowEditorStore((s) => s.saving);
const error = useWorkflowEditorStore((s) => s.error);
const initializeEditor = useWorkflowEditorStore((s) => s.initializeEditor);
const setWorkflow = useWorkflowEditorStore((s) => s.setWorkflow);
const setCurrentFilename = useWorkflowEditorStore((s) => s.setCurrentFilename);
const setSelectedIdx = useWorkflowEditorStore((s) => s.setSelectedIdx);
const setDirty = useWorkflowEditorStore((s) => s.setDirty);
const setSaving = useWorkflowEditorStore((s) => s.setSaving);
const setError = useWorkflowEditorStore((s) => s.setError);
const resetEditor = useWorkflowEditorStore((s) => s.resetEditor);
const [showBackConfirm, setShowBackConfirm] = useState(false);
const [confirmRemoveIdx, setConfirmRemoveIdx] = useState(null);
const [newCustomWorktreeFile, setNewCustomWorktreeFile] = useState("");
Expand All @@ -573,6 +583,8 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
const loadWorkflowConfig = useConfigStore((s) => s.loadWorkflowConfig);
const loadWorkflows = useConfigStore((s) => s.loadWorkflows);
const showToast = useWorkflowStore((s) => s.showToast);
const fallbackWorkflow = useMemo(() => createDefaultWorkflow({ includeStartStep: !filename }), [filename]);
const workflow = storedWorkflow || fallbackWorkflow;
const isNew = !currentFilename;
const selected = selectedIdx !== null ? workflow.steps[selectedIdx] : null;
const worktreeFiles = Array.isArray(workflow.worktree?.files) ? workflow.worktree.files : [];
Expand All @@ -590,9 +602,10 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
const selectedNodePanelRef = useRef(null);
const [flowInstance, setFlowInstance] = useState(null);

useEffect(() => {
setCurrentFilename(filename || null);
}, [filename]);
function closeEditor() {
resetEditor();
onClose();
}

useEffect(() => {
setLiveNodes((currentNodes) => {
Expand Down Expand Up @@ -634,23 +647,30 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
}, [flowInstance, selected?.id]);

useEffect(() => {
const nextEditorKey = getWorkflowEditorKey(filename);
if (editorKey === nextEditorKey && storedWorkflow) return;

if (!filename) {
const next = createDefaultWorkflow();
setWorkflow(next);
setSelectedIdx(next.steps.length > 0 ? 0 : null);
setDirty(false);
initializeEditor({
filename: null,
workflow: next,
selectedIdx: next.steps.length > 0 ? 0 : null,
});
return;
}

desktopApi.getWorkflow(filename)
.then((data) => {
const next = normalizeWorkflow(data);
setWorkflow(next);
setSelectedIdx(next.steps.length > 0 ? 0 : null);
setDirty(false);
initializeEditor({
filename,
workflow: next,
selectedIdx: next.steps.length > 0 ? 0 : null,
});
})
.catch(() => setError(t("editor.loadWorkflowFailed")));
}, [filename]);
}, [filename, editorKey, storedWorkflow, initializeEditor, setError, t]);

function updateWorkflow(patch) {
setWorkflow((prev) => relinkSteps({ ...prev, ...patch }));
Expand Down Expand Up @@ -1158,7 +1178,10 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
showToast(t(draft ? "settings.workflowDraftSaved" : "settings.workflowSaved"));
loadWorkflowConfig();
loadWorkflows();
if (shouldClose) onSaved(savedFilename);
if (shouldClose) {
resetEditor();
onSaved(savedFilename);
}
} catch (err) {
setError(err.message || t("editor.saveWorkflowFailed"));
} finally {
Expand All @@ -1170,7 +1193,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
<div className="flex h-full flex-col">
<WindowChrome />
<div className="flex items-center gap-4 border-b border-border/70 bg-background/55 px-8 py-5">
<BackButton onClick={() => dirty ? setShowBackConfirm(true) : onClose()} label={t("editor.back")} className="-ml-2" />
<BackButton onClick={() => dirty ? setShowBackConfirm(true) : closeEditor()} label={t("editor.back")} className="-ml-2" />
<Input
value={workflow.name}
onChange={(event) => {
Expand All @@ -1180,10 +1203,6 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
placeholder={t("editor.workflowName")}
className="no-drag max-w-xs"
/>
<Badge variant="outline" className="gap-1.5">
<Braces className="h-3.5 w-3.5" />
LangGraph DSL
</Badge>
<Button type="button" className="no-drag" size="sm" variant="outline" onClick={() => setShowWorkflowSetup(true)}>
Workflow Setup
</Button>
Expand Down Expand Up @@ -1906,7 +1925,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
<p className="mb-4 text-sm text-muted-foreground">{t("editor.unsavedChangesConfirm")}</p>
<div className="flex justify-end gap-3">
<Button variant="outline" size="sm" onClick={() => setShowBackConfirm(false)}>{t("common.cancel")}</Button>
<Button variant="destructive" size="sm" onClick={() => { setShowBackConfirm(false); onClose(); }}>{t("editor.discard")}</Button>
<Button variant="destructive" size="sm" onClick={() => { setShowBackConfirm(false); closeEditor(); }}>{t("editor.discard")}</Button>
</div>
</div>
</div>
Expand Down
25 changes: 16 additions & 9 deletions apps/desktop/renderer/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { Eye, EyeOff, Trash2 } from "lucide-react";
import WorkflowEditor from "../WorkflowEditor";
import { BackButton } from "../components/back-button";
Expand All @@ -13,9 +14,10 @@ import { useWorkflowStore } from "../stores/workflowStore";

export default function SettingsPage() {
const { t } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const { filename } = useParams();
const [activeTab, setActiveTab] = useState("workflows");
const [editingWorkflow, setEditingWorkflow] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const [savingMobileAccess, setSavingMobileAccess] = useState(false);
const [savingAiBackend, setSavingAiBackend] = useState(false);
const [confirmRemoveWorkflow, setConfirmRemoveWorkflow] = useState(null);
Expand Down Expand Up @@ -57,14 +59,19 @@ export default function SettingsPage() {
loadWorkFolders();
}, []);

if (showEditor) {
const isCreatingWorkflow = location.pathname === "/settings/workflows/new";
const isEditingWorkflow = Boolean(filename);

if (isCreatingWorkflow || isEditingWorkflow) {
return (
<WorkflowEditor
filename={editingWorkflow}
onClose={() => { setShowEditor(false); setEditingWorkflow(null); loadWorkflows(); }}
filename={filename || null}
onClose={() => {
loadWorkflows();
navigate("/settings");
}}
onSaved={() => {
setShowEditor(false);
setEditingWorkflow(null);
navigate("/settings");
}}
/>
);
Expand Down Expand Up @@ -200,7 +207,7 @@ export default function SettingsPage() {
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-[15px] font-semibold text-foreground">{t("settings.workflows")}</h3>
<Button variant="outline" size="sm" onClick={() => { setEditingWorkflow(null); setShowEditor(true); }}>{t("settings.newWorkflow")}</Button>
<Button variant="outline" size="sm" onClick={() => navigate("/settings/workflows/new")}>{t("settings.newWorkflow")}</Button>
</div>
{workflows.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card/50 p-6 text-center text-sm text-muted-foreground">
Expand Down Expand Up @@ -240,7 +247,7 @@ export default function SettingsPage() {
>
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<Button variant="outline" size="sm" onClick={() => { setEditingWorkflow(wf.filename); setShowEditor(true); }}>
<Button variant="outline" size="sm" onClick={() => navigate(`/settings/workflows/${encodeURIComponent(wf.filename)}/edit`)}>
{t("settings.edit")}
</Button>
<button
Expand Down
Loading
Loading