fix(web): render workflow description in settings (#2418)

This commit is contained in:
nova28
2026-08-09 18:49:04 +08:00
committed by GitHub
parent 61378b9b3b
commit b309d2beb6
18 changed files with 627 additions and 19 deletions
@@ -762,6 +762,7 @@ func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, wo
"workspace_id": workflow.WorkspaceID,
"name": workflow.Name,
"description": workflow.Description,
"prompt": workflow.Prompt,
"agent_profile_id": workflow.AgentProfileID,
"hidden": workflow.Hidden,
"source": workflow.Source,
@@ -2824,6 +2824,37 @@ func TestService_UpdateWorkflow(t *testing.T) {
t.Errorf("expected name 'Updated', got %s", updated.Name)
}
}
func TestService_UpdateWorkflow_PublishesPromptOnWorkflowEvent(t *testing.T) {
// Cross-tab WS sync (use-workflow-settings.ts) relies on workflow.updated
// carrying the current prompt, the same way it already carries
// description and agent_profile_id.
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Original"}
_ = repo.CreateWorkflow(ctx, workflow)
newPrompt := "Updated prompt"
req := &UpdateWorkflowRequest{Prompt: &newPrompt}
if _, err := svc.UpdateWorkflow(ctx, "wf-123", req); err != nil {
t.Fatalf("failed to update workflow: %v", err)
}
if len(eventBus.publishedEvents) == 0 {
t.Fatal("expected a workflow.updated event to be published")
}
last := eventBus.publishedEvents[len(eventBus.publishedEvents)-1]
data, ok := last.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected event data to be a map, got %T", last.Data)
}
if data["prompt"] != newPrompt {
t.Errorf("expected published prompt %q, got %v", newPrompt, data["prompt"])
}
}
func TestService_DeleteWorkflow(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
@@ -161,12 +161,12 @@ function useWorkflowImportExport(
};
}
function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) {
export function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) {
return (
current.name !== savedFrom.name ||
current.description !== savedFrom.description ||
(current.description ?? "") !== (savedFrom.description ?? "") ||
(current.prompt ?? "") !== (savedFrom.prompt ?? "") ||
current.agent_profile_id !== savedFrom.agent_profile_id
(current.agent_profile_id ?? "") !== (savedFrom.agent_profile_id ?? "")
);
}
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import { workflowId as toWorkflowId, type Workflow } from "@/lib/types/http";
import {
agentProfileId as toAgentProfileId,
workflowId as toWorkflowId,
type Workflow,
} from "@/lib/types/http";
import {
alignSavedWorkflowsToDraftOrder,
getWorkflowOrderDirtyIds,
hasNewerWorkflowMetadata,
} from "./workspace-workflows-client";
function workflow(id: string, name = id): Workflow {
@@ -68,3 +73,46 @@ describe("getWorkflowOrderDirtyIds", () => {
expect([...getWorkflowOrderDirtyIds([first, draft], [first])]).toEqual([draft.id]);
});
});
describe("hasNewerWorkflowMetadata", () => {
it("treats an empty description as unchanged from an absent saved description", () => {
// Backend omits `description` when empty (omitempty), so a save response's
// description is undefined, not "". A draft that was never edited beyond
// that (or was edited then cleared) must not be treated as "newer" than
// what was just submitted, or a save reconciliation discards the response.
const saved: Workflow = { ...workflow("wf-1"), description: undefined };
const current: Workflow = { ...workflow("wf-1"), description: "" };
expect(hasNewerWorkflowMetadata(current, saved)).toBe(false);
});
it("still detects a real description change", () => {
const saved: Workflow = { ...workflow("wf-1"), description: "old" };
const current: Workflow = { ...workflow("wf-1"), description: "new" };
expect(hasNewerWorkflowMetadata(current, saved)).toBe(true);
});
it("treats an empty agent_profile_id as unchanged from an absent saved agent_profile_id", () => {
// Same omitempty class of bug as description: clearing a profile override
// produces agent_profile_id === "" locally, but the save response omits
// the empty field, so saved.agent_profile_id is undefined.
const saved: Workflow = { ...workflow("wf-1"), agent_profile_id: undefined };
const current: Workflow = { ...workflow("wf-1"), agent_profile_id: toAgentProfileId("") };
expect(hasNewerWorkflowMetadata(current, saved)).toBe(false);
});
it("still detects a real agent_profile_id change", () => {
const saved: Workflow = {
...workflow("wf-1"),
agent_profile_id: toAgentProfileId("profile-a"),
};
const current: Workflow = {
...workflow("wf-1"),
agent_profile_id: toAgentProfileId("profile-b"),
};
expect(hasNewerWorkflowMetadata(current, saved)).toBe(true);
});
});
@@ -30,6 +30,7 @@ import { useWorkflowMutationGuard } from "./workflow-mutation-guard";
import { WorkflowCycleGuardDialog } from "./workflow-cycle-diagnostic";
import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor";
import { WorkflowPromptSection } from "./workflow-prompt-section";
import { WorkflowDescriptionField } from "./workflow-description-field";
const TEMP_WORKFLOW_PREFIX = "temp-workflow-";
@@ -342,6 +343,12 @@ function WorkflowCardBody({
</Select>
</div>
</div>
<WorkflowDescriptionField
workflow={workflow}
savedWorkflow={savedWorkflow}
readOnly={readOnly}
onUpdate={(description) => onUpdateWorkflow({ description })}
/>
<WorkflowPromptSection
workflow={workflow}
savedWorkflow={savedWorkflow}
@@ -0,0 +1,41 @@
"use client";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import type { Workflow } from "@/lib/types/http";
import { isWorkflowFieldDirty } from "./workflow-dirty-state";
type WorkflowDescriptionFieldProps = {
workflow: Workflow;
savedWorkflow?: Workflow;
readOnly?: boolean;
onUpdate: (description: string) => void;
};
// Always visible, unlike WorkflowPromptSection's collapsible — every synced workflow carries one.
export function WorkflowDescriptionField({
workflow,
savedWorkflow,
readOnly,
onUpdate,
}: WorkflowDescriptionFieldProps) {
const { t } = useTranslation();
const inputId = `workflow-description-input-${workflow.id}`;
return (
<div className="space-y-1.5">
<Label htmlFor={inputId}>{t("workflows:workflowDescription")}</Label>
<Textarea
id={inputId}
value={workflow.description ?? ""}
onChange={(e) => onUpdate(e.target.value)}
disabled={readOnly}
placeholder={t("workflows:workflowDescriptionPlaceholder")}
rows={2}
className="min-h-16 text-sm"
data-testid="workflow-description-input"
data-settings-dirty={isWorkflowFieldDirty(workflow, savedWorkflow, "description")}
/>
</div>
);
}
@@ -214,7 +214,6 @@ test.describe("GitLab MR badge on the Kanban card", () => {
await kanban.goto();
const card = kanban.taskCard(task.id);
await expect(card).toBeVisible({ timeout: 45_000 });
const prIcon = card.getByTestId(`pr-task-icon-${task.id}`);
const mrIcon = card.getByTestId(`mr-task-icon-${task.id}`);
await expect(prIcon).toBeVisible({ timeout: 15_000 });
@@ -479,6 +479,91 @@ test.describe("Workflow settings", () => {
await page.goto(seedData.workspaceId);
await expect(await page.findWorkflowCard("Manually Saved Workflow Name")).toBeVisible();
});
test("edits and persists a workflow description", async ({ testPage, apiClient, seedData }) => {
const workflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Workflow Description Save",
);
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard(workflow.name);
const descriptionInput = card.getByLabel("Description", { exact: true });
await expect(descriptionInput).toBeVisible();
await expect(descriptionInput).toBeEnabled();
await descriptionInput.fill("rev 1 (2026-08-08) — test description");
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "true");
expect(
(await apiClient.listWorkflows(seedData.workspaceId)).workflows.find(
(candidate) => candidate.id === workflow.id,
)?.description,
).toBeFalsy();
await page.saveChanges();
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "false");
await page.goto(seedData.workspaceId);
const reloadedCard = await page.findWorkflowCard(workflow.name);
await expect(reloadedCard.getByTestId("workflow-description-input")).toHaveValue(
"rev 1 (2026-08-08) — test description",
);
});
test("syncs a cross-tab description/prompt update live without clobbering an unsaved edit", async ({
testPage,
apiClient,
seedData,
}) => {
// Regression coverage for the WS store-sync effect in use-workflow-settings.ts:
// it used to only propagate `name` from workflow.updated events, leaving
// description/prompt/agent_profile_id stale on an open settings page until a
// full reload. Simulates "another tab" by mutating via the API (which fires the
// real workflow.updated WS event) while this page stays open — no page.goto.
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Cross-Tab Sync Test");
await apiClient.updateWorkflow(workflow.id, { description: "Old description" });
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard(workflow.name);
const descriptionInput = card.getByLabel("Description", { exact: true });
await expect(descriptionInput).toHaveValue("Old description");
// Local, unsaved edit to a different field (name) — must survive the cross-tab sync below.
const nameInput = card.locator("input").first();
await nameInput.fill("Unsaved local name");
await expect(nameInput).toHaveAttribute("data-settings-dirty", "true");
// "Another tab" edits description and prompt via the API.
await apiClient.updateWorkflow(workflow.id, {
description: "New description from another tab",
prompt: "New prompt from another tab",
});
// Description and prompt update live via WS, with no local draft on either field.
await expect(descriptionInput).toHaveValue("New description from another tab");
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "false");
const promptInput = card.getByTestId("workflow-prompt-input");
await expect(promptInput).toBeVisible();
await expect(promptInput).toHaveValue("New prompt from another tab");
await expect(promptInput).toHaveAttribute("data-settings-dirty", "false");
// The unsaved local name edit was not clobbered by the cross-tab sync.
await expect(nameInput).toHaveValue("Unsaved local name");
await expect(nameInput).toHaveAttribute("data-settings-dirty", "true");
// The saved-baseline is what future dirty checks compare against, so saving now
// must persist the local name edit alongside the already-synced description/prompt.
await page.saveChanges();
const persisted = (await apiClient.listWorkflows(seedData.workspaceId)).workflows.find(
(candidate) => candidate.id === workflow.id,
);
expect(persisted?.name).toBe("Unsaved local name");
expect(persisted?.description).toBe("New description from another tab");
expect(persisted?.prompt).toBe("New prompt from another tab");
});
});
test.describe("Seed protection", () => {
@@ -3,10 +3,13 @@ import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
const GITLAB_PROJECT = "platform/kandev";
const SYNCED_WORKFLOW_DESCRIPTION = "rev 1 (2026-08-08) — synced from GitLab";
const VALID_WORKFLOW_YAML = `version: 1
type: kandev_workflow
workflows:
- name: GitLab Synced Workflow
description: "${SYNCED_WORKFLOW_DESCRIPTION}"
steps:
- name: Open
position: 0
@@ -114,6 +117,13 @@ test.describe("GitLab workflow sync", () => {
await expect(card).toBeVisible();
await expect(card.getByText("Open")).toBeVisible();
await expect(card.getByText("Closed")).toBeVisible();
// Synced workflows are read-only: the description renders the synced
// value but must not be editable, or a later reconcile would just
// revert a user's in-UI edit.
const descriptionInput = card.getByLabel("Description", { exact: true });
await expect(descriptionInput).toHaveValue(SYNCED_WORKFLOW_DESCRIPTION);
await expect(descriptionInput).toBeDisabled();
});
test("requires a project path before Save is enabled", async ({ testPage, seedData }) => {
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
agentProfileId,
workflowId as toWorkflowId,
workspaceId as toWorkspaceId,
type Workflow,
@@ -11,6 +12,8 @@ type StoreWorkflow = {
workspaceId: string;
name: string;
description?: string | null;
prompt?: string;
agent_profile_id?: string;
hidden?: boolean;
style?: "kanban" | "office" | "custom";
};
@@ -43,6 +46,24 @@ const NAME_B1 = "Workflow B1";
const STORE_A1: StoreWorkflow = { id: "wf-a1", workspaceId: "ws-a", name: NAME_A1 };
const STORE_B1: StoreWorkflow = { id: "wf-b1", workspaceId: "ws-b", name: NAME_B1 };
const OLD_DESCRIPTION = "Old description";
const NEW_DESCRIPTION = "New description";
const OLD_PROMPT = "Old prompt";
const NEW_PROMPT = "New prompt";
const UNSAVED_LOCAL_DRAFT = "Unsaved local draft";
// Shared by the cross-tab field-sync tests below: renders the hook with a
// rerender-able store snapshot so a test can simulate a WS-driven store update.
function renderWithStoreItem(initial: Workflow[], store: StoreWorkflow) {
return renderHook(
({ storeItems }: { storeItems: StoreWorkflow[] }) => {
setStore(storeItems);
return useWorkflowSettings(initial, "ws-b");
},
{ initialProps: { storeItems: [store] } },
);
}
beforeEach(() => {
setStore([]);
});
@@ -171,6 +192,237 @@ describe("useWorkflowSettings", () => {
expect(result.current.savedWorkflowItems[0].name).toBe("Remote name");
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
});
it("does not report a workflow dirty after its description is edited then cleared back to an absent saved description", () => {
// Backend omits `description` entirely when empty (omitempty), so a
// workflow with no saved description has `saved.description === undefined`,
// not "". Typing into the description field and then clearing it sets the
// draft to "" — that must still compare equal to the absent saved value.
const initial: Workflow[] = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: undefined }];
const { result } = renderHook(() => useWorkflowSettings(initial, "ws-b"));
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, description: "test description" })),
);
});
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, description: "" })),
);
});
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
});
});
describe("useWorkflowSettings — cross-tab field sync", () => {
it("syncs a description update from the store within the current workspace", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: OLD_DESCRIPTION }];
const store: StoreWorkflow = { ...STORE_B1, description: OLD_DESCRIPTION };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
});
expect(result.current.workflowItems[0].description).toBe(NEW_DESCRIPTION);
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
});
it("does not overwrite a dirty description when the store refreshes, but still moves the dirty-flag baseline", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: OLD_DESCRIPTION }];
const store: StoreWorkflow = { ...STORE_B1, description: OLD_DESCRIPTION };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, description: UNSAVED_LOCAL_DRAFT })),
);
});
act(() => {
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
});
expect(result.current.workflowItems[0].description).toBe(UNSAVED_LOCAL_DRAFT);
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
});
it("syncs a prompt update from the store within the current workspace", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), prompt: OLD_PROMPT }];
const store: StoreWorkflow = { ...STORE_B1, prompt: OLD_PROMPT };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
rerender({ storeItems: [{ ...store, prompt: NEW_PROMPT }] });
});
expect(result.current.workflowItems[0].prompt).toBe(NEW_PROMPT);
expect(result.current.savedWorkflowItems[0].prompt).toBe(NEW_PROMPT);
});
it("does not overwrite a dirty prompt when the store refreshes, but still moves the dirty-flag baseline", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), prompt: OLD_PROMPT }];
const store: StoreWorkflow = { ...STORE_B1, prompt: OLD_PROMPT };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, prompt: UNSAVED_LOCAL_DRAFT })),
);
});
act(() => {
rerender({ storeItems: [{ ...store, prompt: NEW_PROMPT }] });
});
expect(result.current.workflowItems[0].prompt).toBe(UNSAVED_LOCAL_DRAFT);
expect(result.current.savedWorkflowItems[0].prompt).toBe(NEW_PROMPT);
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
});
it("syncs an agent_profile_id update from the store within the current workspace", () => {
const initial = [
{ ...wf("wf-b1", "ws-b", NAME_B1), agent_profile_id: agentProfileId("agent-old") },
];
const store: StoreWorkflow = { ...STORE_B1, agent_profile_id: "agent-old" };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
rerender({ storeItems: [{ ...store, agent_profile_id: "agent-new" }] });
});
expect(result.current.workflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
expect(result.current.savedWorkflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
});
it("does not overwrite a dirty agent_profile_id when the store refreshes, but still moves the dirty-flag baseline", () => {
const initial = [
{ ...wf("wf-b1", "ws-b", NAME_B1), agent_profile_id: agentProfileId("agent-old") },
];
const store: StoreWorkflow = { ...STORE_B1, agent_profile_id: "agent-old" };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, agent_profile_id: agentProfileId("agent-draft") })),
);
});
act(() => {
rerender({ storeItems: [{ ...store, agent_profile_id: "agent-new" }] });
});
expect(result.current.workflowItems[0].agent_profile_id).toBe(agentProfileId("agent-draft"));
expect(result.current.savedWorkflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
});
});
// Regression coverage: a field that starts absent (undefined), gets typed into and
// cleared back to "" is not dirty per `isWorkflowDirty` (which normalizes via `?? ""`),
// so a remote update landing afterward must still sync — not get stuck behind a
// false-positive "local draft" that also silently drags the dirty-flag baseline out
// from under the stuck display, priming the next Save to clobber the remote value.
describe("useWorkflowSettings — cross-tab field sync (absent-vs-empty normalization)", () => {
it("syncs a description update after a local edit is cleared back to the absent saved value", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: undefined }];
const store: StoreWorkflow = { ...STORE_B1 };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, description: "typed then cleared" })),
);
});
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, description: "" })),
);
});
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
act(() => {
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
});
expect(result.current.workflowItems[0].description).toBe(NEW_DESCRIPTION);
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
});
it("syncs a prompt update after a local edit is cleared back to the absent saved value", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), prompt: undefined }];
const store: StoreWorkflow = { ...STORE_B1 };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, prompt: "typed then cleared" })),
);
});
act(() => {
result.current.setWorkflowItems((items) => items.map((item) => ({ ...item, prompt: "" })));
});
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
act(() => {
rerender({ storeItems: [{ ...store, prompt: NEW_PROMPT }] });
});
expect(result.current.workflowItems[0].prompt).toBe(NEW_PROMPT);
expect(result.current.savedWorkflowItems[0].prompt).toBe(NEW_PROMPT);
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
});
it("syncs an agent_profile_id update after a local edit is cleared back to the absent saved value", () => {
// Mirrors the UI: selecting "None" in the agent profile picker sets
// agent_profile_id to "" (workflow-card.tsx), not undefined.
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), agent_profile_id: undefined }];
const store: StoreWorkflow = { ...STORE_B1 };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, agent_profile_id: agentProfileId("agent-draft") })),
);
});
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, agent_profile_id: agentProfileId("") })),
);
});
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
act(() => {
rerender({ storeItems: [{ ...store, agent_profile_id: "agent-new" }] });
});
expect(result.current.workflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
expect(result.current.savedWorkflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
});
it("does not sync a description update while a different field (name) has a genuine local draft", () => {
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: OLD_DESCRIPTION }];
const store: StoreWorkflow = { ...STORE_B1, description: OLD_DESCRIPTION };
const { result, rerender } = renderWithStoreItem(initial, store);
act(() => {
result.current.setWorkflowItems((items) =>
items.map((item) => ({ ...item, name: UNSAVED_LOCAL_DRAFT })),
);
});
act(() => {
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
});
// The name draft is preserved, and the unrelated description field still syncs live.
expect(result.current.workflowItems[0].name).toBe(UNSAVED_LOCAL_DRAFT);
expect(result.current.workflowItems[0].description).toBe(NEW_DESCRIPTION);
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
});
});
describe("useWorkflowSettings visibility", () => {
@@ -2,7 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { workflowId, workspaceId as toWorkspaceId, type Workflow } from "@/lib/types/http";
import {
agentProfileId,
workflowId,
workspaceId as toWorkspaceId,
type Workflow,
} from "@/lib/types/http";
/**
* Manages workflow list state for the settings page, synced with WS events
@@ -84,10 +89,9 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?:
const updated = filtered.map((w) => {
if (w.id.startsWith("temp-")) return w;
const sw = scopedStoreWorkflows.find((s) => s.id === w.id);
if (!sw) return w;
const saved = savedWorkflowItemsRef.current.find((item) => item.id === w.id);
const hasLocalNameDraft = saved && w.name !== saved.name;
if (sw && sw.name !== w.name && !hasLocalNameDraft) return { ...w, name: sw.name };
return w;
return mergeDisplayedWorkflowFromStore(w, saved, sw);
});
if (
@@ -105,9 +109,7 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?:
const filtered = prev.filter((w) => !deletedIds.has(w.id));
const updated = filtered.map((workflow) => {
const server = scopedStoreWorkflows.find((item) => item.id === workflow.id);
return server && server.name !== workflow.name
? { ...workflow, name: server.name }
: workflow;
return server ? mergeSavedWorkflowFromServer(workflow, server) : workflow;
});
if (
toAdd.length === 0 &&
@@ -140,18 +142,107 @@ function workflowIsDirty(workflow: Workflow, savedWorkflows: Map<string, Workflo
if (!saved) return true;
return (
workflow.name !== saved.name ||
workflow.description !== saved.description ||
(workflow.description ?? "") !== (saved.description ?? "") ||
(workflow.prompt ?? "") !== (saved.prompt ?? "") ||
(workflow.agent_profile_id ?? "") !== (saved.agent_profile_id ?? "")
);
}
type EditableWorkflowField = "name" | "description" | "prompt" | "agent_profile_id";
// Whether the displayed value for `field` has already diverged from the last-known-saved
// baseline — i.e. the user has an uncommitted local edit that a store refresh must not clobber.
// No baseline (a workflow not yet tracked in savedWorkflowItems) is treated as "no draft" so a
// fresh store sync can still populate it, mirroring the pre-existing name-only behavior.
// Normalizes absent values via `?? ""` the same way `workflowIsDirty` does — otherwise a field
// that starts undefined, gets typed into, and is cleared back to "" reads as a phantom draft:
// it blocks a legitimate store sync while the saved baseline still advances underneath it,
// leaving the field falsely dirty and primed to clobber the remote value on the next Save.
function hasLocalDraft(
displayed: Workflow,
saved: Workflow | undefined,
field: EditableWorkflowField,
): boolean {
return saved != null && (displayed[field] ?? "") !== (saved[field] ?? "");
}
type StoreWorkflowItem = {
id: string;
workspaceId: string;
name: string;
description?: string | null;
prompt?: string;
agent_profile_id?: string;
};
// Pulls fresh values from the store into the displayed/editable draft, field by field, skipping
// any field the user is mid-edit on so a cross-tab update never clobbers an uncommitted change.
function mergeDisplayedWorkflowFromStore(
displayed: Workflow,
saved: Workflow | undefined,
server: StoreWorkflowItem,
): Workflow {
const next = { ...displayed };
let changed = false;
if (!hasLocalDraft(displayed, saved, "name") && server.name !== displayed.name) {
next.name = server.name;
changed = true;
}
if (
!hasLocalDraft(displayed, saved, "description") &&
server.description !== displayed.description
) {
next.description = server.description;
changed = true;
}
if (!hasLocalDraft(displayed, saved, "prompt") && server.prompt !== displayed.prompt) {
next.prompt = server.prompt;
changed = true;
}
const serverAgentProfileId =
server.agent_profile_id !== undefined ? agentProfileId(server.agent_profile_id) : undefined;
if (
!hasLocalDraft(displayed, saved, "agent_profile_id") &&
serverAgentProfileId !== displayed.agent_profile_id
) {
next.agent_profile_id = serverAgentProfileId;
changed = true;
}
return changed ? next : displayed;
}
// The dirty-flag baseline always tracks the server's current value for every editable field —
// unlike the displayed draft, it is never guarded by a local-draft check.
function mergeSavedWorkflowFromServer(saved: Workflow, server: StoreWorkflowItem): Workflow {
const serverAgentProfileId =
server.agent_profile_id !== undefined ? agentProfileId(server.agent_profile_id) : undefined;
if (
server.name === saved.name &&
server.description === saved.description &&
server.prompt === saved.prompt &&
serverAgentProfileId === saved.agent_profile_id
) {
return saved;
}
return {
...saved,
name: server.name,
description: server.description,
prompt: server.prompt,
agent_profile_id: serverAgentProfileId,
};
}
function useScopedStoreWorkflows<
T extends {
id: string;
workspaceId: string;
name: string;
description?: string | null;
prompt?: string;
agent_profile_id?: string;
hidden?: boolean;
style?: string;
},
@@ -166,17 +257,15 @@ function useScopedStoreWorkflows<
}, [storeWorkflows, workspaceId]);
}
function storeItemToWorkflow(sw: {
id: string;
workspaceId: string;
name: string;
description?: string | null;
}): Workflow {
function storeItemToWorkflow(sw: StoreWorkflowItem): Workflow {
return {
id: workflowId(sw.id),
workspace_id: toWorkspaceId(sw.workspaceId),
name: sw.name,
description: sw.description,
prompt: sw.prompt,
agent_profile_id:
sw.agent_profile_id !== undefined ? agentProfileId(sw.agent_profile_id) : undefined,
created_at: "",
updated_at: "",
};
+15
View File
@@ -35,6 +35,7 @@ function makeWorkflow(id: string, workspaceId: string) {
workspace_id: workspaceId,
name: id,
description: null,
prompt: undefined as string | undefined,
sort_order: 0,
agent_profile_id: null,
hidden: false,
@@ -190,6 +191,20 @@ describe("useWorkflows — explicit workspace selection", () => {
]),
);
});
it("maps the workflow's prompt into the store item", async () => {
mockListWorkflows.mockResolvedValueOnce({
workflows: [{ ...makeWorkflow("wf-B", "ws-B"), prompt: "Do the thing" }],
});
renderHook(() => useWorkflows("ws-B", true));
await waitFor(() =>
expect(mockSetWorkflows).toHaveBeenCalledWith([
expect.objectContaining({ id: "wf-B", prompt: "Do the thing" }),
]),
);
});
});
describe("useEnsureWorkspaceWorkflows", () => {
+1
View File
@@ -40,6 +40,7 @@ function useWorkflowsFetchEffect(
workspaceId: workflow.workspace_id,
name: workflow.name,
description: workflow.description,
prompt: workflow.prompt,
sortOrder: workflow.sort_order ?? 0,
agent_profile_id: workflow.agent_profile_id,
hidden: workflow.hidden,
@@ -137,6 +137,7 @@ export type WorkflowsState = {
workspaceId: string;
name: string;
description?: string | null;
prompt?: string;
sortOrder?: number;
agent_profile_id?: string;
hidden?: boolean;
@@ -152,6 +152,28 @@ describe("workflow.updated handler — hidden flag reconciles activeId", () => {
expect(store.getState().workflows.activeId).toBe("wf-1");
expect(store.getState().workflows.items[0]?.name).toBe("New Name");
});
it("propagates description and prompt from the payload", () => {
const store = makeStore(
[{ id: "wf-1", workspaceId: "ws-1", name: "Workflow", hidden: false }],
"wf-1",
);
const handlers = registerWorkflowsHandlers(store);
handlers["workflow.updated"]?.(
updatedMessage({
id: "wf-1",
workspace_id: "ws-1",
name: "Workflow",
description: "Updated from another tab",
prompt: "Updated prompt",
}),
);
const item = store.getState().workflows.items[0];
expect(item?.description).toBe("Updated from another tab");
expect(item?.prompt).toBe("Updated prompt");
});
});
describe("workflow step handlers", () => {
+2
View File
@@ -51,6 +51,8 @@ function applyWorkflowUpdated(state: AppState, payload: WorkflowPayload): AppSta
? {
...item,
name: payload.name,
description: payload.description,
prompt: payload.prompt,
agent_profile_id: payload.agent_profile_id,
hidden: payload.hidden !== undefined ? Boolean(payload.hidden) : item.hidden,
style: payload.style ?? item.style,
+2
View File
@@ -245,6 +245,8 @@
"workflowCycleBlockingBody": "This path re-enters \"{{stepName}}\" and can start the agent again without another user action.",
"workflowCycleReplayPathFor": "Replay path for {{stepName}}",
"workflowCycleWarningBody": "This path can re-enter \"{{stepName}}\" and start the agent again after a user action.",
"workflowDescription": "Description",
"workflowDescriptionPlaceholder": "Optional description",
"workflowDetails": "Workflow details",
"workflowName": "Workflow Name",
"workflowsReadOnlyImprove": "Workflows in this workspace are managed by Improve Kandev and cannot be changed.",
@@ -245,6 +245,8 @@
"workflowCycleBlockingBody": "Ţĥĩś ƥàţĥ ŕē-ēńţēŕś \"{{stepName}}\" àńď ćàń śţàŕţ ţĥē àĝēńţ àĝàĩń ŵĩţĥōũţ àńōţĥēŕ ũśēŕ àćţĩōń.",
"workflowCycleReplayPathFor": "Ŕēƥĺàŷ ƥàţĥ ƒōŕ {{stepName}}",
"workflowCycleWarningBody": "Ţĥĩś ƥàţĥ ćàń ŕē-ēńţēŕ \"{{stepName}}\" àńď śţàŕţ ţĥē àĝēńţ àĝàĩń àƒţēŕ à ũśēŕ àćţĩōń.",
"workflowDescription": "Ďēśćŕĩƥţĩōń",
"workflowDescriptionPlaceholder": "Ōƥţĩōńàĺ ďēśćŕĩƥţĩōń",
"workflowDetails": "Ŵōŕķƒĺōŵ ďēţàĩĺś",
"workflowName": "Ŵōŕķƒĺōŵ Ńàḿē",
"workflowsReadOnlyImprove": "Ŵōŕķƒĺōŵś ĩń ţĥĩś ŵōŕķśƥàćē àŕē ḿàńàĝēď ƀŷ Ĩḿƥŕōvē Ķàńďēv àńď ćàńńōţ ƀē ćĥàńĝēď.",