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

This commit is contained in:
Cindy Li 2026-09-29 21:17:15 -07:00 • committed by GitHub
parent 72b181760c
commit 4a2b0b8b1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 786 additions and 15 deletions

View file

@ -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">

View file

@ -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();
}}

View file

@ -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(

View file

@ -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,

View file

@ -1383,7 +1383,7 @@ function WorkflowsFlat() {
}}
/>
<WorkflowTemplates />
<WorkflowTemplates folderId={selectedFolderId} />
</div>
</div>
);

View file

@ -1103,7 +1103,7 @@ function WorkflowsTree() {
}}
/>
<WorkflowTemplates />
<WorkflowTemplates folderId={selectedFolderId} />
</div>
</div>
);

View file

@ -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">

View file

@ -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();
});
});

View file

@ -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 };

View file

@ -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}

View file

@ -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 });
}
});

View file

@ -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");
});
});

View 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}` : "";
}