mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-10-02 19:57:59 +00:00
Copy a home template on click and guide the user through it in Copilot (SKY-17448) (#8730)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
This commit is contained in:
parent
72b181760c
commit
4a2b0b8b1c
13 changed files with 786 additions and 15 deletions
|
|
@ -13,6 +13,8 @@ keywords:
|
|||
<div className="cl-page">
|
||||
<div className="cl-sidebar">
|
||||
<p className="cl-sidebar-title">TIMELINE</p>
|
||||
<a href="#week-sep-28-2026" className="cl-sidebar-link">Week of Sep 28, 2026</a>
|
||||
<a href="#week-sep-21-2026" className="cl-sidebar-link">Week of Sep 21, 2026</a>
|
||||
<a href="#week-sep-14-2026" className="cl-sidebar-link">Week of Sep 14, 2026</a>
|
||||
<a href="#week-sep-7-2026" className="cl-sidebar-link">Week of Sep 7, 2026</a>
|
||||
<a href="#week-aug-31-2026" className="cl-sidebar-link">Week of Aug 31, 2026</a>
|
||||
|
|
@ -49,6 +51,159 @@ keywords:
|
|||
<h1 className="cl-title">Changelog</h1>
|
||||
<p className="cl-subtitle">New features, improvements, and fixes in Skyvern</p>
|
||||
|
||||
<a id="week-sep-28-2026"></a>
|
||||
<Update label="Week of September 28, 2026" description="Copilot file downloads, live browser clipboard paste, Discover redesign, reliability fixes">
|
||||
|
||||
## New features
|
||||
|
||||
- **Downloadable Copilot-Generated Files** — Secure CodeBlocks and Copilot can now publish generated XLSX, PDF, CSV, and ZIP files as run downloads and chat download buttons. ([#8693](https://github.com/Skyvern-AI/skyvern/pull/8693))
|
||||
- **Clipboard Paste in Live Browser Control** — You can now paste your clipboard while in control of a live browser, from one control bar shared by CDP and VNC sessions. ([#8699](https://github.com/Skyvern-AI/skyvern/pull/8699))
|
||||
- **Redesigned Discover Home** — The Discover page's prompt, examples, and templates have been redesigned.
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Google Sheets Range Support** — Copilot now resolves a Sheets tab from a URL's `gid` at save time and shows the Range in append mode. ([#8696](https://github.com/Skyvern-AI/skyvern/pull/8696))
|
||||
- **Clearer Personal Account Naming** — A personal account is now named after its Skyvern organization in the org menu and settings sidebar.
|
||||
- **Recording Controls** — The Copilot recording sticky bar is now labeled and includes a stop control.
|
||||
- **Redesigned Prompt Box Settings** — Advanced prompt box settings now live in a gear popover.
|
||||
- **Clearer Credential Refresh** — The saved-login refresh link now explains what it does.
|
||||
- **Clearer Copilot Change Review** — Copilot now shows its proposed changes on one annotated canvas, with plans and credential receipts shown where they happened in the turn.
|
||||
- **File Input URLs for Copilot** — Copilot now knows a file input can accept a user-supplied URL, not just an upload.
|
||||
- **Pinned Copilot Questions** — Pending Copilot questions now dock above the composer instead of scrolling out of view.
|
||||
- **Flexible Copilot Answers** — Copilot questions can now be answered in your own words instead of only the offered choices. ([#8713](https://github.com/Skyvern-AI/skyvern/pull/8713))
|
||||
- **Chronological Copilot Activity Log** — Copilot's activity log now reads in the order things happened, with redesigned answered-question cards. ([#8718](https://github.com/Skyvern-AI/skyvern/pull/8718))
|
||||
- **More Resilient Workflow Studio** — Workflow Studio now contains pane crashes instead of taking down the editor.
|
||||
- **Skyvern 3.0 Recommended** — The Skyvern 3.0 engine badge is now labeled "Recommended" instead of "Beta".
|
||||
- **Code Block Goal Editing** — Editing a code block's Goal now keeps your text and offers to update the block to match. ([#8711](https://github.com/Skyvern-AI/skyvern/pull/8711))
|
||||
- **Faster Web Search** — Copilot and code blocks now search the web through a dedicated search API instead of a browser tab. ([#8708](https://github.com/Skyvern-AI/skyvern/pull/8708))
|
||||
- **Clearer Billing Errors** — Skyvern now shows the reason when a billing plan change fails.
|
||||
- **Faster Credential Failures** — A login block now fails fast with a clear reason when it isn't bound to any of the workflow's credentials, instead of opening a browser first.
|
||||
- **Copilot Test Results as Cards** — Each block Copilot tests now shows as one card with its status, code change, and failing line.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed the extension bridge processing page events out of order relative to earlier responses. ([#8694](https://github.com/Skyvern-AI/skyvern/pull/8694))
|
||||
- Fixed bot-challenge pages being reported as "no challenge" instead of correctly detected. ([#8695](https://github.com/Skyvern-AI/skyvern/pull/8695))
|
||||
- Fixed credential approvals being lost for credentials used past the recording evidence size cap.
|
||||
- Fixed run errors losing their mapped error code when the task also had an OTP timeout.
|
||||
- Fixed Copilot forgetting an accepted state across page reloads.
|
||||
- Fixed a closed Workflow Studio pane reopening at the wrong edge instead of where it was.
|
||||
- Fixed Copilot comparing proposals against a stale staged draft instead of the current canvas.
|
||||
- Fixed edits being lost when rejecting a Copilot proposal that only renamed the workflow or never reached the canvas.
|
||||
- Fixed Gemini rejecting the MCP tool list due to a missing schema on an output parameter.
|
||||
- Fixed the extension relay stopping when an event callback raised an error.
|
||||
- Fixed run failure messages leading with an outer error instead of the actual cause.
|
||||
- Fixed false "Copilot is locked" toasts when expanding chat steps or switching editor views.
|
||||
- Fixed runs failing at max retries instead of terminating with the correct mapped error code.
|
||||
- Fixed plan upgrades not rolling back correctly if an in-progress ACH payment failed.
|
||||
- Fixed the self-hosted Discover page crashing on sign-in hooks.
|
||||
- Fixed literal "N/A" and "NULL" text being dropped when parsing Excel files.
|
||||
- Fixed PDF generation failing permanently after a page-renderer crash; it now retries on a replacement tab.
|
||||
- Fixed clicks on natively disabled controls taking up to 15 seconds to fail; they now fail in 2 seconds.
|
||||
- Fixed the AI fallback for code blocks recovering at the wrong address for a wrapped or indented `goto`.
|
||||
- Fixed concurrent Copilot test runs overwriting each other's workflow version.
|
||||
- Fixed read-only and disabled fields reporting a successful fill or clear instead of being refused.
|
||||
- Fixed agents with no blocks being runnable.
|
||||
- Fixed the Recording and Screenshots pills showing on finished runs that have neither.
|
||||
- Fixed the onboarding questionnaire not reappearing after a page refresh. ([#8706](https://github.com/Skyvern-AI/skyvern/pull/8706))
|
||||
- Fixed a recording refinement draft being presented as a validated workflow before it was. ([#8712](https://github.com/Skyvern-AI/skyvern/pull/8712))
|
||||
- Fixed Skyvern 3.0 refusing a re-ask conversion whenever a settled page's layout shifted at all, instead of only when the underlying document changed.
|
||||
- Fixed Copilot's Review view landing on a pending-proposal card with no available actions. ([#8714](https://github.com/Skyvern-AI/skyvern/pull/8714))
|
||||
- Fixed the Copilot review-gate buttons wrapping in a narrow pane and its Test end-to-end action not being locked like Reject. ([#8715](https://github.com/Skyvern-AI/skyvern/pull/8715))
|
||||
- Fixed Skyvern 3.0 mishandling a covering layer over the target field and mis-scoring an action block whose one action succeeded but whose follow-up was refused by the step cap. ([#8722](https://github.com/Skyvern-AI/skyvern/pull/8722))
|
||||
- Fixed one-shot downloads not recovering correctly after a server error.
|
||||
- Fixed the organizations page showing a blank state instead of a full-page retry option when it failed to load.
|
||||
- Fixed Workflow Studio showing replay pills before there was anything to replay.
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="week-sep-21-2026"></a>
|
||||
<Update label="Week of September 21, 2026" description="Terminate blocks, SMS 2FA, new schedule types, Copilot CAPTCHA support, reliability fixes">
|
||||
|
||||
## New features
|
||||
|
||||
- **Terminate Block** — A new workflow block lets a run end early with a custom reason, instead of running through remaining blocks.
|
||||
- **Copilot-Managed Schedules** — Copilot can now create and manage the open workflow's recurring schedules directly from chat.
|
||||
- **SMS Two-Factor Authentication** — Credentials now support SMS-based two-factor authentication with managed phone numbers.
|
||||
- **Elapsed-Interval Schedules** — Workflow schedules can now run on a fixed interval measured from an anchor time, instead of only fixed calendar times. ([#8660](https://github.com/Skyvern-AI/skyvern/pull/8660))
|
||||
- **One-Time Scheduled Runs** — Workflow schedules can now be set to run once at a specific date and time. ([#8680](https://github.com/Skyvern-AI/skyvern/pull/8680))
|
||||
- **Copilot File Upload to Pages** — Copilot can now upload a file you attach in chat directly into a page's file picker. ([#8691](https://github.com/Skyvern-AI/skyvern/pull/8691))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Full YAML Workflow Editing** — The workflow editor's YAML view now exposes and accepts every workflow setting.
|
||||
- **Declared Code Block Return Schema** — Code blocks can now declare a return schema for Copilot, and AI-fallback output is recorded as the block's output.
|
||||
- **Optional Extension User Scripts** — Browser control no longer requires the User Scripts permission to be enabled.
|
||||
- **Copilot Repair Test Accuracy** — Copilot repair test runs now reuse the original failed run's inputs.
|
||||
- **Clearer Billing Navigation** — The sidebar "Add credits" button is now labeled "Upgrade plan" / "Plan & usage".
|
||||
- **Organization Terminology** — Settings, the org menu, and analytics banners now say "Organization" instead of "Workspace". ([#8697](https://github.com/Skyvern-AI/skyvern/pull/8697))
|
||||
- **Agent Creator Visibility** — The agents list now shows each agent's original creator.
|
||||
- **Agent Creation Date** — The agents list now shows each agent's first-version creation date.
|
||||
- **Local File Reads in Blocks** — Blocks can now read a local-storage upload referenced by a `file://` URI.
|
||||
- **Copilot Page-Challenge Recovery** — Copilot can now solve a page challenge or start a fresh browser when scouting a page.
|
||||
- **Clearer Copilot Run Results** — Copilot now reports how it picked a run for results and lists newer finished runs.
|
||||
- **Browser Profile Preview** — The workflow editor now shows a read-only preview of the browser profile a run would load.
|
||||
- **CAPTCHA Support in Code Blocks and Copilot** — Code blocks and Copilot browser-code cells can now solve image-text CAPTCHAs directly.
|
||||
- **More Resilient Browser Recordings** — Record Browser no longer rejects a recording outright; it now emits repair steps for actions it can't replay.
|
||||
- **Recording Visibility in Copilot Chat** — Browser recordings now show as a live chapter inside the Copilot chat.
|
||||
- **Save Credential on Enter** — Pressing Enter in the password field now saves the credential.
|
||||
- **Chat During Recording** — You can now talk to Copilot while a browser recording is capturing.
|
||||
- **Credential and Session Ownership** — Credentials and browser sessions now show who created them. ([#8654](https://github.com/Skyvern-AI/skyvern/pull/8654))
|
||||
- **Cached Script Retention** — Data-retention cleanup no longer removes cached scripts, keeping optimized runs fast.
|
||||
- **Customer Instructions Take Priority** — Under a new run setting, Skyvern 3.0 now lets your workflow instructions override its general behavior rules, and no longer lets page content override customer-provided values.
|
||||
- **Layout-Resilient Cached Code** — Saved code blocks can now target metric cards with a label-anchored selector, so they survive page layout changes.
|
||||
- **Cleaner Run Timelines** — Run timelines now show an action's result only when one was actually recorded.
|
||||
- **Plain-Language Failure Labels** — Run failures now show plain-language labels instead of raw internal category names.
|
||||
- **More Reliable CAPTCHA Solving** — Skyvern now remembers which CAPTCHA type a given site rejects, improving solve reliability on protected pages. ([#8657](https://github.com/Skyvern-AI/skyvern/pull/8657))
|
||||
- **Clearer Browser Block Validation** — The first browser block's URL is now required and validated before the block runs.
|
||||
- **Queued Message Indicator** — A queued Copilot message now shows as a strip on the message box. ([#8659](https://github.com/Skyvern-AI/skyvern/pull/8659))
|
||||
- **Quieter Browser Pane** — The browser pane header is now less cluttered, with an overflow menu, run-only replay tabs, and clearer browser status. ([#8668](https://github.com/Skyvern-AI/skyvern/pull/8668))
|
||||
- **Fewer Extension Permissions** — The Skyvern Agent browser extension no longer requests the User Scripts or unused host permissions.
|
||||
- **Search Block Improvements** — The Search block now uses standard error messages, and a Data Schema can be used without a Prompt. ([#8679](https://github.com/Skyvern-AI/skyvern/pull/8679))
|
||||
- **Copilot Test Accuracy** — Copilot build tests now start from the saved browser profile.
|
||||
- **Copilot CAPTCHA Reading** — Copilot can now read image-text CAPTCHAs while scouting a page and in saved code blocks. ([#8683](https://github.com/Skyvern-AI/skyvern/pull/8683))
|
||||
- **Run Attribution** — The Runs page now shows who started each workflow run. ([#8677](https://github.com/Skyvern-AI/skyvern/pull/8677))
|
||||
- **Clearer Copilot Status** — Copilot now shows when it's working or waiting on you above the message box, and the Stop button matches Send.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- Fixed the workflow editor losing your exact state after a rejected Copilot proposal; saves are now transactional.
|
||||
- Fixed one-time-code entry: Skyvern now retries once with a fresh code when a site rejects a multi-box code, and types full verification codes into one-character boxes reliably. ([#8717](https://github.com/Skyvern-AI/skyvern/pull/8717))
|
||||
- Fixed the Copilot credential card staying open after picking a saved login on a new chat.
|
||||
- Fixed intermittent loss of live-browser control by re-sending take-control on VNC reconnect and opening the live-view socket only after VNC connects.
|
||||
- Fixed a sent Copilot prompt occasionally staying stuck in the queue.
|
||||
- Fixed several Skyvern 3.0 typing issues: appended text is now reported and placed correctly, and an offered autocomplete row is committed when none exactly matches the typed value.
|
||||
- Fixed clicks into embedded frames failing when the frame hadn't finished rendering.
|
||||
- Fixed the Copilot activity log losing a finished step's title when new work started right after.
|
||||
- Fixed Skyvern 3.0 hiding a read of a different browser tab as if it were a repeated read.
|
||||
- Fixed Skyvern 3.0 accepting task completion on a blank page or without checking the result against the stated goal.
|
||||
- Fixed Copilot sending a canned message instead of a real reply when a tool call produced an empty model response.
|
||||
- Fixed credentials chosen during a browser recording not carrying into its refinement turn.
|
||||
- Fixed Skyvern 3.0 hiding or mislabeling clickable rows, menu options, and buttons combined with clickable containers during page observation. ([#8716](https://github.com/Skyvern-AI/skyvern/pull/8716))
|
||||
- Fixed Skyvern 3.0 not generating a real code for a credential's TOTP placeholder, unlike the previous engine.
|
||||
- Fixed dropdown search fields that only filter on Enter, so Skyvern 3.0 can search and filter windowed dropdowns correctly.
|
||||
- Fixed proxy tunnel and SOCKS failures being mislabeled as navigation failures.
|
||||
- Patched a security vulnerability in TLS certificate hostname validation used by outbound requests.
|
||||
- Fixed extension-bridge tabs and page reads breaking when a page changed its URL.
|
||||
- Fixed code blocks failing as busy under load instead of waiting for a free runner slot.
|
||||
- Fixed Copilot forgetting answers to its own questions after a few more tool calls.
|
||||
- Fixed Skyvern 3.0 writing dates into the wrong segment of a segmented date field.
|
||||
- Fixed Copilot's turn not finishing recovery correctly when a stream loss occurred but the turn ended normally.
|
||||
- Fixed the Search block returning results outside the requested site filter or missing results with no cached content.
|
||||
- Fixed Copilot showing a false "unsaved changes" warning on retry.
|
||||
- Fixed Reject retries in Copilot chat leaking across chats instead of staying scoped to the chat they came from.
|
||||
- Fixed the browser extension grouping tabs together in popup windows.
|
||||
- Fixed data extraction nulling out fields the goal asked to produce, instead of only nulling fields not present on the page.
|
||||
- Fixed Skyvern 3.0 skipping later toggle clicks in a batch after an earlier one failed.
|
||||
- Fixed code block page requests bypassing the run's configured browser proxy.
|
||||
- Fixed Skyvern 3.0 refusing to re-enter a login username the page had overwritten, while keeping the existing cap on password re-entry.
|
||||
- Fixed action steps being charged for calls Skyvern 3.0 refused before acting on the page. ([#8688](https://github.com/Skyvern-AI/skyvern/pull/8688))
|
||||
- Fixed Copilot's first workflow submissions failing on malformed syntax; they're now built from structured objects.
|
||||
- Fixed a terminated Skyvern 3.0 block not getting a retry when the site skipped a screen its criterion expected. ([#8687](https://github.com/Skyvern-AI/skyvern/pull/8687))
|
||||
- Fixed Copilot losing rows past the 50,000-row cut when paging a long run's results. ([#8684](https://github.com/Skyvern-AI/skyvern/pull/8684))
|
||||
|
||||
</Update>
|
||||
|
||||
<a id="week-sep-14-2026"></a>
|
||||
<Update label="Week of September 14, 2026" description="Workflow API updates preserve omitted settings">
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ArrowRightIcon, FileTextIcon } from "@radix-ui/react-icons";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/util/utils";
|
||||
import {
|
||||
|
|
@ -13,7 +14,7 @@ type Props = {
|
|||
category: TemplateCategory | null;
|
||||
popular?: boolean;
|
||||
to: string;
|
||||
onClick: () => void;
|
||||
onClick: (event?: MouseEvent<HTMLAnchorElement>) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ function WorkflowTemplateCard({
|
|||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
onClick={(event) => onClick(event)}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button === 1) onClick();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ vi.mock("../workflows/hooks/useGlobalWorkflowsQuery", () => ({
|
|||
vi.mock("@/hooks/useWorkflowStudioEnabled", () => ({
|
||||
useWorkflowStudioEnabled: () => false,
|
||||
}));
|
||||
const mutate = vi.hoisted(() => vi.fn());
|
||||
const mutation = vi.hoisted(() => ({ isPending: false }));
|
||||
vi.mock("../workflows/hooks/useCreateWorkflowMutation", () => ({
|
||||
useCreateWorkflowMutation: () => ({ mutate, isPending: mutation.isPending }),
|
||||
}));
|
||||
vi.mock("../workflows/editor/workflowEditorUtils", () => ({
|
||||
convert: (workflow: { title: string }) => ({ title: workflow.title }),
|
||||
}));
|
||||
vi.mock("@/util/homeTelemetry", () => ({
|
||||
HomeTelemetry: { templateClicked: vi.fn() },
|
||||
}));
|
||||
|
|
@ -34,7 +42,43 @@ function cardTitles() {
|
|||
}
|
||||
|
||||
describe("WorkflowTemplates", () => {
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mutate.mockClear();
|
||||
mutation.isPending = false;
|
||||
});
|
||||
|
||||
it("copies the template on a plain click but not a modified click", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WorkflowTemplates folderId="fld_1" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const card = screen.getAllByRole("link")[0]!;
|
||||
|
||||
fireEvent.click(card, { metaKey: true });
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(card);
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Invoice Downloading (copy)",
|
||||
folder_id: "fld_1",
|
||||
_via: "template",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores clicks while a copy is already being created", () => {
|
||||
mutation.isPending = true;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WorkflowTemplates />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole("link")[0]!);
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leads with the most used template and filters by category", () => {
|
||||
render(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled";
|
||||
import { workflowEditorPath } from "@/routes/workflows/studioNavigation";
|
||||
import { useGlobalWorkflowsQuery } from "../workflows/hooks/useGlobalWorkflowsQuery";
|
||||
import { useCreateWorkflowMutation } from "../workflows/hooks/useCreateWorkflowMutation";
|
||||
import { convert } from "../workflows/editor/workflowEditorUtils";
|
||||
import { TEMPLATE_VIA } from "../workflows/templateGuidance";
|
||||
import { WorkflowTemplateCard } from "./WorkflowTemplateCard";
|
||||
import { HomeTelemetry } from "@/util/homeTelemetry";
|
||||
import { TEMPORARY_TEMPLATE_IMAGES } from "./TemporaryTemplateImages";
|
||||
|
|
@ -20,9 +23,10 @@ const COLLAPSED_COUNT = 6;
|
|||
|
||||
type Filter = TemplateCategory | "all";
|
||||
|
||||
function WorkflowTemplates() {
|
||||
function WorkflowTemplates({ folderId }: { folderId?: string | null } = {}) {
|
||||
const { data: workflowTemplates, isLoading } = useGlobalWorkflowsQuery();
|
||||
const studioEnabled = useWorkflowStudioEnabled();
|
||||
const createWorkflow = useCreateWorkflowMutation();
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
|
|
@ -143,11 +147,31 @@ function WorkflowTemplates() {
|
|||
workflow.workflow_permanent_id,
|
||||
studioEnabled,
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
// The card calls onClick without an event for middle clicks; that must stay a non-plain click.
|
||||
const plainClick =
|
||||
!!event &&
|
||||
event.button === 0 &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey;
|
||||
if (plainClick && createWorkflow.isPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
HomeTelemetry.templateClicked({
|
||||
workflowPermanentId: workflow.workflow_permanent_id,
|
||||
title: workflow.title,
|
||||
});
|
||||
// Modified and middle clicks keep opening the read-only template.
|
||||
if (!plainClick) return;
|
||||
event.preventDefault();
|
||||
createWorkflow.mutate({
|
||||
...convert({ ...workflow, title: `${workflow.title} (copy)` }),
|
||||
folder_id: folderId,
|
||||
_via: TEMPLATE_VIA,
|
||||
});
|
||||
}}
|
||||
className={cn("w-64 shrink-0 snap-start md:w-auto", {
|
||||
"md:hidden": !expanded && index >= COLLAPSED_COUNT,
|
||||
|
|
|
|||
|
|
@ -1383,7 +1383,7 @@ function WorkflowsFlat() {
|
|||
}}
|
||||
/>
|
||||
|
||||
<WorkflowTemplates />
|
||||
<WorkflowTemplates folderId={selectedFolderId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1103,7 +1103,7 @@ function WorkflowsTree() {
|
|||
}}
|
||||
/>
|
||||
|
||||
<WorkflowTemplates />
|
||||
<WorkflowTemplates folderId={selectedFolderId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -135,6 +135,12 @@ import {
|
|||
import { WorkflowCopilotHistory } from "./WorkflowCopilotHistory";
|
||||
import { AutoAcceptChip } from "./AutoAcceptChip";
|
||||
import { PendingGoalChangesCard } from "./PendingGoalChangesCard";
|
||||
import {
|
||||
TEMPLATE_GUIDANCE_MESSAGE_ID,
|
||||
TEMPLATE_INPUTS_MESSAGE_ID,
|
||||
} from "../templateGuidance";
|
||||
import { TemplateInputsCard } from "./cards/TemplateInputsCard";
|
||||
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||
import { SelectedBlockChip } from "./SelectedBlockChip";
|
||||
import { readSelectedBlockLabel } from "./selectedBlockLabel";
|
||||
import { selectAutoBoundReceiptIndexes } from "./autoBoundReceiptIndexes";
|
||||
|
|
@ -657,6 +663,7 @@ export interface ChatMessage {
|
|||
kind?:
|
||||
| "run_lifecycle"
|
||||
| "status_notice"
|
||||
| "template_inputs"
|
||||
| "recording_refinement"
|
||||
| "initial_handoff";
|
||||
recoveryTurnId?: string;
|
||||
|
|
@ -1286,6 +1293,9 @@ interface WorkflowCopilotChatProps {
|
|||
requiresLiveBrowser?: boolean;
|
||||
isLiveBrowserReady?: boolean;
|
||||
initialMessage?: string;
|
||||
/** Assistant-authored first message shown when a template copy opens. */
|
||||
templateGuidance?: string;
|
||||
onTemplateGuidanceShown?: () => void;
|
||||
/** Files uploaded before the handoff; sent with the initial message. */
|
||||
initialAttachments?: Array<CopilotAttachedFile>;
|
||||
initialAction?: CopilotProductAction;
|
||||
|
|
@ -1520,6 +1530,8 @@ export function WorkflowCopilotChat({
|
|||
requiresLiveBrowser = false,
|
||||
isLiveBrowserReady = false,
|
||||
initialMessage,
|
||||
templateGuidance,
|
||||
onTemplateGuidanceShown,
|
||||
initialAttachments,
|
||||
initialAction,
|
||||
onInitialMessageConsumed,
|
||||
|
|
@ -1559,19 +1571,40 @@ export function WorkflowCopilotChat({
|
|||
// while the existing composer remains available for instructions and
|
||||
// clarifications. SOP upload and finishing a recording own it exclusively.
|
||||
const authoringInProgress = isUploadingSOP || recordingIsFinishing;
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
!initialAction && initialMessage
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => [
|
||||
...(templateGuidance
|
||||
? [
|
||||
{
|
||||
id: TEMPLATE_GUIDANCE_MESSAGE_ID,
|
||||
sender: "ai" as const,
|
||||
content: templateGuidance,
|
||||
kind: "status_notice" as const,
|
||||
},
|
||||
{
|
||||
id: TEMPLATE_INPUTS_MESSAGE_ID,
|
||||
sender: "ai" as const,
|
||||
content: "",
|
||||
kind: "template_inputs" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!initialAction && initialMessage
|
||||
? [
|
||||
{
|
||||
id: initialHandoffMessageId,
|
||||
sender: "user",
|
||||
sender: "user" as const,
|
||||
content: initialMessage,
|
||||
kind: "initial_handoff",
|
||||
kind: "initial_handoff" as const,
|
||||
attachedFiles: initialAttachments,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
: []),
|
||||
]);
|
||||
const templateGuidanceShownRef = useRef(onTemplateGuidanceShown);
|
||||
templateGuidanceShownRef.current = onTemplateGuidanceShown;
|
||||
useMountEffect(() => {
|
||||
if (templateGuidance) templateGuidanceShownRef.current?.();
|
||||
});
|
||||
const [workPlan, setWorkPlan] = useState<string[]>([]);
|
||||
const [proposedWorkflow, setProposedWorkflow] =
|
||||
useState<WorkflowApiResponse | null>(null);
|
||||
|
|
@ -3083,6 +3116,13 @@ export function WorkflowCopilotChat({
|
|||
message.content === initialHandoff.content,
|
||||
);
|
||||
const nextMessages: ChatMessage[] = [
|
||||
...(carryForwardLifecycle
|
||||
? prev.filter(
|
||||
(message) =>
|
||||
message.id === TEMPLATE_GUIDANCE_MESSAGE_ID ||
|
||||
message.id === TEMPLATE_INPUTS_MESSAGE_ID,
|
||||
)
|
||||
: []),
|
||||
...(initialHandoff && !historyIncludesInitialHandoff
|
||||
? [initialHandoff]
|
||||
: []),
|
||||
|
|
@ -9728,6 +9768,9 @@ export function WorkflowCopilotChat({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (message.kind === "template_inputs") {
|
||||
return <TemplateInputsCard key={message.id} />;
|
||||
}
|
||||
if (message.kind === "status_notice") {
|
||||
return (
|
||||
<div key={message.id} role="status" aria-live="polite">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||
import type { ParametersState } from "../../editor/types";
|
||||
import { TemplateInputsCard } from "./TemplateInputsCard";
|
||||
|
||||
const onSave = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../editor/hooks/useSaveWorkflow", () => ({
|
||||
useSaveWorkflow: () => onSave,
|
||||
}));
|
||||
vi.mock("../../components/CredentialCombobox", () => ({
|
||||
CredentialCombobox: ({
|
||||
onValueChange,
|
||||
}: {
|
||||
onValueChange: (value: string) => void;
|
||||
}) => (
|
||||
<button onClick={() => onValueChange("cred_new")}>pick credential</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/routes/credentials/CredentialsModal", () => ({
|
||||
CredentialsModal: () => null,
|
||||
}));
|
||||
|
||||
function seed(parameters: ParametersState) {
|
||||
useWorkflowParametersStore.setState({ parameters });
|
||||
}
|
||||
|
||||
const saveButton = () =>
|
||||
screen.getByRole("button", { name: "Save inputs" }) as HTMLButtonElement;
|
||||
|
||||
describe("TemplateInputsCard", () => {
|
||||
beforeEach(() => {
|
||||
onSave.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it("blocks an invalid number and saves a cleared number as null", async () => {
|
||||
seed([
|
||||
{
|
||||
key: "retries",
|
||||
parameterType: "workflow",
|
||||
dataType: "integer",
|
||||
defaultValue: 3,
|
||||
},
|
||||
]);
|
||||
render(<TemplateInputsCard />);
|
||||
const field = screen.getByLabelText("retries");
|
||||
|
||||
fireEvent.change(field, { target: { value: "abc" } });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
|
||||
fireEvent.change(field, { target: { value: "" } });
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(useWorkflowParametersStore.getState().parameters[0]).toMatchObject({
|
||||
defaultValue: null,
|
||||
});
|
||||
expect(await screen.findByText("Saved")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears the rotation pool when a credential is chosen", async () => {
|
||||
seed([
|
||||
{
|
||||
key: "login",
|
||||
parameterType: "credential",
|
||||
credentialId: "cred_old",
|
||||
credentialIds: ["cred_old", "cred_other"],
|
||||
selectionStrategy: "round_robin",
|
||||
fallbackCredentialIds: ["cred_other"],
|
||||
} as ParametersState[number],
|
||||
]);
|
||||
render(<TemplateInputsCard />);
|
||||
|
||||
fireEvent.click(screen.getByText("pick credential"));
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(useWorkflowParametersStore.getState().parameters[0]).toMatchObject({
|
||||
credentialId: "cred_new",
|
||||
credentialIds: null,
|
||||
selectionStrategy: null,
|
||||
fallbackCredentialIds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Not saved when the save fails", async () => {
|
||||
onSave.mockRejectedValue(new Error("save failed"));
|
||||
seed([
|
||||
{
|
||||
key: "url",
|
||||
parameterType: "workflow",
|
||||
dataType: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
]);
|
||||
render(<TemplateInputsCard />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("url"), {
|
||||
target: { value: "https://example.com" },
|
||||
});
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
expect(await screen.findByText("Not saved")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import { MixerVerticalIcon } from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||
import { refuseMutationDuringYamlCommit } from "@/store/WorkflowYamlEditorStore";
|
||||
|
||||
import { CredentialsModal } from "@/routes/credentials/CredentialsModal";
|
||||
|
||||
import { useSaveWorkflow } from "../../editor/hooks/useSaveWorkflow";
|
||||
import { CredentialCombobox } from "../../components/CredentialCombobox";
|
||||
import { parameterIsSkyvernCredential } from "../../editor/types";
|
||||
import { WorkflowParameterValueType } from "../../types/workflowTypes";
|
||||
import { CardHeader, CardPill, CopilotCard } from "./cardChrome";
|
||||
|
||||
const TEXT_TYPES: ReadonlySet<string> = new Set([
|
||||
WorkflowParameterValueType.String,
|
||||
WorkflowParameterValueType.Integer,
|
||||
WorkflowParameterValueType.Float,
|
||||
WorkflowParameterValueType.FileURL,
|
||||
]);
|
||||
|
||||
function numericError(dataType: string, value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return null;
|
||||
if (dataType === WorkflowParameterValueType.Integer) {
|
||||
return /^-?\d+$/.test(trimmed) ? null : "Enter a whole number";
|
||||
}
|
||||
if (dataType === WorkflowParameterValueType.Float) {
|
||||
return /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(trimmed)
|
||||
? null
|
||||
: "Enter a number";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function TemplateInputsCard() {
|
||||
const { parameters, setParametersFromUser } = useWorkflowParametersStore();
|
||||
const setHasChanges = useWorkflowHasChangesStore(
|
||||
(state) => state.setHasChanges,
|
||||
);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const onSave = useSaveWorkflow();
|
||||
const [saved, setSaved] = useState<"saved" | "applied" | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creatingFor, setCreatingFor] = useState<string | null>(null);
|
||||
|
||||
const inputs = parameters.filter(
|
||||
(parameter) =>
|
||||
parameter.parameterType === "workflow" ||
|
||||
parameter.parameterType === "credential",
|
||||
);
|
||||
if (inputs.length === 0) return null;
|
||||
|
||||
const hasInvalidEdit = inputs.some(
|
||||
(parameter) =>
|
||||
parameter.parameterType === "workflow" &&
|
||||
parameter.key in edits &&
|
||||
numericError(parameter.dataType, edits[parameter.key] ?? "") !== null,
|
||||
);
|
||||
|
||||
const setEdit = (key: string, value: string) => {
|
||||
setSaved(null);
|
||||
setEdits((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (hasInvalidEdit || refuseMutationDuringYamlCommit()) return;
|
||||
setParametersFromUser(
|
||||
parameters.map((parameter) => {
|
||||
if (!(parameter.key in edits)) return parameter;
|
||||
if (parameter.parameterType === "workflow") {
|
||||
const value = edits[parameter.key] ?? "";
|
||||
const numeric =
|
||||
parameter.dataType === WorkflowParameterValueType.Integer ||
|
||||
parameter.dataType === WorkflowParameterValueType.Float;
|
||||
return {
|
||||
...parameter,
|
||||
defaultValue: numeric && value.trim() === "" ? null : value,
|
||||
};
|
||||
}
|
||||
if (
|
||||
parameter.parameterType === "credential" &&
|
||||
parameterIsSkyvernCredential(parameter)
|
||||
) {
|
||||
// A rotation pool would override the primary the user just chose.
|
||||
return {
|
||||
...parameter,
|
||||
credentialId: edits[parameter.key] ?? "",
|
||||
credentialIds: null,
|
||||
selectionStrategy: null,
|
||||
fallbackCredentialIds: null,
|
||||
fallbackTrigger: null,
|
||||
};
|
||||
}
|
||||
return parameter;
|
||||
}),
|
||||
);
|
||||
setHasChanges(true);
|
||||
setEdits({});
|
||||
setSaving(true);
|
||||
// onSave already toasts a failure; the values stay applied in the editor for a retry via Save.
|
||||
onSave()
|
||||
.then(() => setSaved("saved"))
|
||||
.catch(() => setSaved("applied"))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<CopilotCard>
|
||||
<CardHeader
|
||||
icon={<MixerVerticalIcon className="size-3.5" />}
|
||||
title="Inputs"
|
||||
meta="fill in before running"
|
||||
right={
|
||||
saved ? (
|
||||
<CardPill tone={saved === "saved" ? "green" : "amber"}>
|
||||
{saved === "saved" ? "Saved" : "Not saved"}
|
||||
</CardPill>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
{inputs.map((parameter) => {
|
||||
const skyvernCredential =
|
||||
parameter.parameterType === "credential" &&
|
||||
parameterIsSkyvernCredential(parameter)
|
||||
? parameter
|
||||
: null;
|
||||
const isText =
|
||||
parameter.parameterType === "workflow" &&
|
||||
TEXT_TYPES.has(parameter.dataType);
|
||||
const stored =
|
||||
parameter.parameterType === "workflow"
|
||||
? parameter.defaultValue
|
||||
: skyvernCredential?.credentialId;
|
||||
const current =
|
||||
edits[parameter.key] ??
|
||||
(stored === null || stored === undefined ? "" : String(stored));
|
||||
const error =
|
||||
parameter.parameterType === "workflow"
|
||||
? numericError(parameter.dataType, current)
|
||||
: null;
|
||||
return (
|
||||
<div key={parameter.key} className="space-y-1">
|
||||
<div className="text-xs font-semibold text-foreground">
|
||||
{parameter.key}
|
||||
</div>
|
||||
{parameter.description ? (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{parameter.description}
|
||||
</div>
|
||||
) : null}
|
||||
{isText ? (
|
||||
<>
|
||||
<Input
|
||||
aria-label={parameter.key}
|
||||
value={current}
|
||||
aria-invalid={error !== null}
|
||||
onChange={(event) =>
|
||||
setEdit(parameter.key, event.target.value)
|
||||
}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="text-[11px] text-destructive">{error}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : skyvernCredential ? (
|
||||
<CredentialCombobox
|
||||
value={current}
|
||||
selectedCredentialId={current || undefined}
|
||||
onValueChange={(value) => setEdit(parameter.key, value)}
|
||||
onAddNew={() => setCreatingFor(parameter.key)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Set this one from Inputs on the start block.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={Object.keys(edits).length === 0 || saving || hasInvalidEdit}
|
||||
onClick={save}
|
||||
>
|
||||
Save inputs
|
||||
</Button>
|
||||
</div>
|
||||
<CredentialsModal
|
||||
isOpen={creatingFor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCreatingFor(null);
|
||||
}}
|
||||
onCredentialCreated={(id) => {
|
||||
if (creatingFor) setEdit(creatingFor, id);
|
||||
setCreatingFor(null);
|
||||
}}
|
||||
/>
|
||||
</CopilotCard>
|
||||
);
|
||||
}
|
||||
|
||||
export { TemplateInputsCard };
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
TEMPLATE_VIA,
|
||||
buildTemplateGuidanceMessage,
|
||||
withoutTemplateViaParam,
|
||||
} from "../templateGuidance";
|
||||
import {
|
||||
bindCopilotReviewClose,
|
||||
captureEditorState,
|
||||
|
|
@ -532,6 +537,19 @@ function Workspace({
|
|||
() => routeInitialCopilotMessage ?? storedInitialCopilotMessage,
|
||||
[routeInitialCopilotMessage, storedInitialCopilotMessage],
|
||||
);
|
||||
const templateGuidance = useMemo(
|
||||
() =>
|
||||
embedded && searchParams.get("via") === TEMPLATE_VIA
|
||||
? buildTemplateGuidanceMessage(workflow)
|
||||
: undefined,
|
||||
[embedded, searchParams, workflow],
|
||||
);
|
||||
const handleTemplateGuidanceShown = useCallback(() => {
|
||||
navigate(location.pathname + withoutTemplateViaParam(location.search), {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
}, [location.pathname, location.search, location.state, navigate]);
|
||||
const handleInitialCopilotMessageConsumed = useCallback(() => {
|
||||
if (!initialCopilotMessage && !initialCopilotAction) return;
|
||||
clearStoredInitialCopilotMessage();
|
||||
|
|
@ -558,7 +576,8 @@ function Workspace({
|
|||
if (
|
||||
shouldOpenCopilotPaneForHandoff({
|
||||
embedded,
|
||||
hasInitialCopilotMessage: Boolean(initialCopilotMessage),
|
||||
hasInitialCopilotMessage:
|
||||
Boolean(initialCopilotMessage) || Boolean(templateGuidance),
|
||||
copilotPaneOpen: studioCopilotOpen,
|
||||
})
|
||||
) {
|
||||
|
|
@ -3414,6 +3433,8 @@ function Workspace({
|
|||
{withStudioPaneBoundary(
|
||||
"copilot",
|
||||
<WorkflowCopilotChat
|
||||
templateGuidance={templateGuidance}
|
||||
onTemplateGuidanceShown={handleTemplateGuidanceShown}
|
||||
organizationId={workflow.organization_id}
|
||||
captureProductEvent={captureProductEvent}
|
||||
captureEditorState={captureLiveEditorState}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { usePostHog } from "posthog-js/react";
|
|||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||
import { TEMPLATE_VIA } from "../templateGuidance";
|
||||
|
||||
// Fires once on mount when a `?via=` entry point is present. Lives in a hook so
|
||||
// every editor surface a handoff can land on reports it — the studio
|
||||
|
|
@ -11,7 +12,7 @@ export function useViaEntryPointCapture(): void {
|
|||
const posthog = usePostHog();
|
||||
useMountEffect(() => {
|
||||
const via = searchParams.get("via");
|
||||
if (via) {
|
||||
if (via && via !== TEMPLATE_VIA) {
|
||||
posthog?.capture("copilot.discover.started", { entry_point: via });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTemplateGuidanceMessage,
|
||||
withoutTemplateViaParam,
|
||||
} from "./templateGuidance";
|
||||
|
||||
type Input = Parameters<typeof buildTemplateGuidanceMessage>[0];
|
||||
|
||||
const withParams = (parameters: unknown[], blocks: unknown[] = []) =>
|
||||
({
|
||||
title: "Invoice Downloading",
|
||||
description: null,
|
||||
workflow_definition: { parameters, blocks },
|
||||
}) as unknown as Input;
|
||||
|
||||
describe("buildTemplateGuidanceMessage", () => {
|
||||
it("points at the inputs card only when there are workflow inputs", () => {
|
||||
const withInput = buildTemplateGuidanceMessage(
|
||||
withParams([{ parameter_type: "workflow", key: "website_url" }]),
|
||||
);
|
||||
expect(withInput).toContain("inputs below");
|
||||
const outputOnly = buildTemplateGuidanceMessage(
|
||||
withParams([{ parameter_type: "output", key: "extracted" }]),
|
||||
);
|
||||
expect(outputOnly).toContain("run it as is");
|
||||
const credentialOnly = buildTemplateGuidanceMessage(
|
||||
withParams([{ parameter_type: "credential", key: "login" }]),
|
||||
);
|
||||
expect(credentialOnly).toContain("inputs below");
|
||||
expect(credentialOnly).toContain("never share a password");
|
||||
expect(credentialOnly).not.toContain("tell me the values");
|
||||
});
|
||||
|
||||
it("summarizes blocks as numbered steps, falling back to the block type", () => {
|
||||
const message = buildTemplateGuidanceMessage(
|
||||
withParams(
|
||||
[],
|
||||
[
|
||||
{
|
||||
block_type: "navigation",
|
||||
navigation_goal: "Open the invoices page. Then click Download.",
|
||||
},
|
||||
{ block_type: "goto_url", url: "https://example.com/login" },
|
||||
{ block_type: "wait" },
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(message).toContain("1. Open the invoices page.");
|
||||
expect(message).toContain("2. Open https://example.com/login");
|
||||
expect(message).toMatch(/\n3\. \S/);
|
||||
expect(message).not.toContain("Then click");
|
||||
});
|
||||
|
||||
it("says it can run as is when there are no inputs", () => {
|
||||
expect(buildTemplateGuidanceMessage(withParams([]))).toContain(
|
||||
"run it as is",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutTemplateViaParam", () => {
|
||||
it("drops only via=template", () => {
|
||||
expect(withoutTemplateViaParam("?via=template&panes=copilot")).toBe(
|
||||
"?panes=copilot",
|
||||
);
|
||||
expect(withoutTemplateViaParam("?via=discover")).toBe("?via=discover");
|
||||
});
|
||||
});
|
||||
92
skyvern-frontend/src/routes/workflows/templateGuidance.ts
Normal file
92
skyvern-frontend/src/routes/workflows/templateGuidance.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { workflowBlockTitle } from "./editor/nodes/types";
|
||||
import {
|
||||
WorkflowParameterValueType,
|
||||
type WorkflowApiResponse,
|
||||
type WorkflowBlock,
|
||||
} from "./types/workflowTypes";
|
||||
|
||||
export const TEMPLATE_VIA = "template";
|
||||
const MAX_STEP_LENGTH = 110;
|
||||
const MAX_STEPS = 8;
|
||||
export const TEMPLATE_GUIDANCE_MESSAGE_ID = "template-guidance";
|
||||
export const TEMPLATE_INPUTS_MESSAGE_ID = "template-inputs";
|
||||
|
||||
function describeBlock(block: WorkflowBlock): string {
|
||||
const fields = block as unknown as Record<string, unknown>;
|
||||
const field = [
|
||||
"navigation_goal",
|
||||
"data_extraction_goal",
|
||||
"prompt",
|
||||
"goal",
|
||||
"url",
|
||||
].find((name) => typeof fields[name] === "string" && fields[name]);
|
||||
const goal = field ? String(fields[field]).trim() : "";
|
||||
const text = goal
|
||||
? field === "url"
|
||||
? `Open ${goal}`
|
||||
: (goal.split(/(?<=[.!?])\s/)[0] ?? goal)
|
||||
: workflowBlockTitle[block.block_type];
|
||||
const oneLine = text.replace(/\s+/g, " ");
|
||||
const step =
|
||||
oneLine.length > MAX_STEP_LENGTH
|
||||
? `${oneLine.slice(0, MAX_STEP_LENGTH)}…`
|
||||
: oneLine;
|
||||
return block.block_type === "for_loop" ? `Repeat: ${step}` : step;
|
||||
}
|
||||
|
||||
function buildSteps(blocks: Array<WorkflowBlock>): string {
|
||||
if (blocks.length === 0) return "";
|
||||
const shown = blocks
|
||||
.slice(0, MAX_STEPS)
|
||||
.map((block, index) => `${index + 1}. ${describeBlock(block)}`);
|
||||
const more =
|
||||
blocks.length > MAX_STEPS ? [`…and ${blocks.length - MAX_STEPS} more`] : [];
|
||||
return `**What it does**\n\n${[...shown, ...more].join("\n")}`;
|
||||
}
|
||||
|
||||
export function buildTemplateGuidanceMessage(
|
||||
workflow: Pick<
|
||||
WorkflowApiResponse,
|
||||
"title" | "description" | "workflow_definition"
|
||||
>,
|
||||
): string {
|
||||
const inputs = workflow.workflow_definition.parameters.filter(
|
||||
(parameter) =>
|
||||
parameter.parameter_type === "workflow" ||
|
||||
parameter.parameter_type === "credential",
|
||||
);
|
||||
const isCredential = (parameter: (typeof inputs)[number]) =>
|
||||
parameter.parameter_type === "credential" ||
|
||||
(parameter.parameter_type === "workflow" &&
|
||||
parameter.workflow_parameter_type ===
|
||||
WorkflowParameterValueType.CredentialId);
|
||||
const hasCredential = inputs.some(isCredential);
|
||||
const hasOther = inputs.some((parameter) => !isCredential(parameter));
|
||||
const steps = buildSteps(workflow.workflow_definition.blocks);
|
||||
const intro = [
|
||||
`I copied **${workflow.title}** into your agents.`,
|
||||
workflow.description,
|
||||
steps,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (inputs.length === 0) {
|
||||
return `${intro}\n\nIt has no inputs to fill in, so you can run it as is.`;
|
||||
}
|
||||
const ask = !hasCredential
|
||||
? "fill in the inputs below, or tell me the values here."
|
||||
: `fill in the inputs below. Choose or add credentials in the card, and never share a password in chat.${
|
||||
hasOther ? " Other values you can also tell me here." : ""
|
||||
}`;
|
||||
return `${intro}\n\n**To run it**, ${ask}`;
|
||||
}
|
||||
|
||||
export function withoutTemplateViaParam(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if (params.get("via") !== TEMPLATE_VIA) {
|
||||
return search;
|
||||
}
|
||||
params.delete("via");
|
||||
const next = params.toString();
|
||||
return next ? `?${next}` : "";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue