mirror of
https://github.com/cline/cline.git
synced 2026-10-29 17:42:34 +08:00
fix(vscode): restore legacy workflow invocation and management UI (#12562)
* fix(vscode): restore legacy workflow invocation and management UI - Expand /workflow slash commands typed with the legacy .md filename spelling (what the autocomplete menu inserts) and mid-message, and honor the user's workflow enable/disable toggles, instead of only expanding a leading extension-less /name via the SDK resolver. - Restore the Workflows tab in the rules modal (view, toggle, create, edit, delete; enterprise section) that was dropped in the SDK-backed extension while all its gRPC handlers remained wired. * chore: add changeset for workflow fixes * fix(vscode): refresh workflow toggles on webview launch The slash command menu is driven by workflowToggles state, but nothing refreshed it at startup in the SDK-backed extension (only opening the rules modal or creating a rule file did), so workflows never appeared in the chat autocomplete until the user opened the modal. Legacy refreshed toggles on task init. * feat(vscode): move Workflows tab last and add deprecation warning Workflows tab now appears after Rules/Hooks/Skills, and its view leads with a warning banner: workflows are being deprecated in favor of skills, with a docs link. * chore: update changeset for workflow deprecation notice * fix(vscode): address review findings on workflow expansion - Honor remoteWorkflowToggles (and locked alwaysEnabled remote workflows) when building the disabled set, so disabled enterprise workflows no longer expand. - Treat a workflow as disabled only when no scope has it enabled, so a disabled workspace file no longer shadows a same-named enabled global one (legacy expanded the enabled scope). - Strip all workflow extensions the SDK discovers (.md/.markdown/.txt) when matching typed commands, not just .md. - Re-read toggle state after the async directory scan in refreshWorkflowToggles so a toggle flipped mid-scan is not overwritten by the stale snapshot. * fix(vscode): map workflow toggles to records so frontmatter names are governed Compute the disabled set from the discovered workflow records (listRecords) instead of toggle-path basenames alone: a file's toggle is matched by its basename and disables the record's actual command name, so a frontmatter 'name' that differs from the filename is still governed by the Workflows toggle. Remote-config-materialized records are governed by the name-keyed remote toggles (locked alwaysEnabled remain on). * fix(vscode): harden workflow toggle-name mapping for expansion - A command name shared by several records now counts as enabled when any record is enabled, so a disabled local workflow can no longer suppress an enabled or locked (alwaysEnabled) enterprise workflow. - Remote toggles/locks are matched via a sanitizeSegment-compatible key, so config names that get rewritten during materialization (e.g. 'Org Standards' -> org-standards.md) still govern expansion. - Typed filenames (e.g. /my-workflow.md from autocomplete) now resolve to workflows whose frontmatter renames the command, via the record's file basename. * fix(vscode): govern each workflow command by its own record's toggle Key the disabled set by exact command name and decide each record independently instead of OR-aggregating by canonical name: distinct commands whose names only differ by case or extension (e.g. a local 'Release' and a remote 'release') no longer influence each other, so an enabled local workflow cannot keep a disabled enterprise workflow expandable, and a disabled one cannot suppress a locked enterprise workflow. * fix(vscode): exact remote-name sanitization and keep mid-scan toggle additions - Port @cline/shared's sanitizeSegment verbatim (incl. the 80-char cap) for remote workflow name comparison, so long enterprise workflow names cannot bypass a disabled toggle after filename truncation. - The post-scan toggle merge now also keeps entries added while the scan was running (e.g. a workflow created via the modal), instead of pruning them with the deleted files. * fix(vscode): handle mid-scan deletions and sanitized remote-name collisions - The post-scan toggle merge now also drops entries that were removed from state while the scan ran, so a workflow deleted mid-refresh is not restored by the stale scan result. - Remote toggle names that sanitize to the same materialized name merge as enabled-if-any-enabled instead of last-write-wins. * fix(vscode): serialize workflow toggle refreshes Queue refreshWorkflowToggles runs on a promise chain so overlapping refreshes (webview launch, modal open, file create/delete) cannot interleave scans and writes. Combined with the post-scan merge for direct toggle flips, this closes the remaining stale-refresh races. * fix(vscode): key remote workflow toggles off the materialized filename The materializer names remote workflow files from the config name, so derive the remote toggle key from the file basename instead of the parsed command name; a frontmatter alias can no longer bypass a disabled remote toggle.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
|
||||
@@ -4,10 +4,68 @@ import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
/**
|
||||
* Merge a directory-scan result with the toggle state as it stands *after* the
|
||||
* scan. The scan in `synchronizeRuleToggles` is async, so state can change
|
||||
* while it runs:
|
||||
* - a toggle the user flips mid-scan must win over the stale snapshot value;
|
||||
* - an entry added mid-scan (e.g. a workflow file created via the modal) must
|
||||
* be kept even though the older scan didn't see the file;
|
||||
* - an entry removed from state mid-scan (e.g. the workflow was deleted via
|
||||
* the modal, which deletes the file and its entry) must stay removed even
|
||||
* though the older scan still saw the file;
|
||||
* - entries that existed before the scan but whose files the scan no longer
|
||||
* found are pruned (the file was deleted).
|
||||
*/
|
||||
function mergeToggleStateAfterScan(
|
||||
scanned: ClineRulesToggles,
|
||||
preScan: ClineRulesToggles,
|
||||
current: ClineRulesToggles,
|
||||
): ClineRulesToggles {
|
||||
const merged: ClineRulesToggles = {}
|
||||
for (const [key, value] of Object.entries(scanned)) {
|
||||
if (key in current) {
|
||||
merged[key] = current[key]
|
||||
} else if (!(key in preScan)) {
|
||||
merged[key] = value
|
||||
}
|
||||
// else: the entry was removed from state while the scan ran — keep it removed.
|
||||
}
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (!(key in scanned) && !(key in preScan)) {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes refresh runs. Overlapping refreshes (webview launch, the rules
|
||||
* modal opening, workflow file creation) would otherwise interleave their
|
||||
* scans and writes and could publish stale state; queueing them makes each
|
||||
* scan atomic relative to other refreshes, and any file create/delete that
|
||||
* happens mid-scan triggers its own refresh that queues behind the running
|
||||
* one and corrects the outcome. The merge in `mergeToggleStateAfterScan`
|
||||
* covers the remaining non-refresh writer: direct toggle flips.
|
||||
*/
|
||||
let refreshQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Refresh the workflow toggles
|
||||
*/
|
||||
export async function refreshWorkflowToggles(
|
||||
export function refreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
globalWorkflowToggles: ClineRulesToggles
|
||||
localWorkflowToggles: ClineRulesToggles
|
||||
}> {
|
||||
const run = refreshQueue.then(() => doRefreshWorkflowToggles(controller, workingDirectory))
|
||||
refreshQueue = run.catch(() => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
async function doRefreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
@@ -17,12 +75,24 @@ export async function refreshWorkflowToggles(
|
||||
// Global workflows
|
||||
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
|
||||
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
|
||||
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
const scannedGlobalToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
// Re-read state after the async scans: no `await` between here and the
|
||||
// writes below, so concurrent toggle updates cannot be lost.
|
||||
const updatedGlobalWorkflowToggles = mergeToggleStateAfterScan(
|
||||
scannedGlobalToggles,
|
||||
globalWorkflowToggles,
|
||||
controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles"),
|
||||
)
|
||||
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
|
||||
|
||||
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
|
||||
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
const scannedWorkspaceToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
const updatedWorkflowToggles = mergeToggleStateAfterScan(
|
||||
scannedWorkspaceToggles,
|
||||
workflowRulesToggles,
|
||||
controller.stateManager.getWorkspaceStateKey("workflowToggles"),
|
||||
)
|
||||
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
|
||||
import { refreshWorkflowToggles } from "@/core/context/instructions/user-instructions/workflows"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { GlobalStateAndSettings } from "@/shared/storage/state-keys"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import type { Controller } from "../index"
|
||||
import { refreshBasetenModels } from "../models/refreshBasetenModels"
|
||||
|
||||
@@ -20,6 +22,17 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
|
||||
*/
|
||||
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
try {
|
||||
// Sync workflow toggles with the files on disk so the chat input's slash
|
||||
// command menu knows about workflows without requiring the user to open
|
||||
// the Workflows modal first (which is the only other place that refreshes
|
||||
// them). Fire-and-forget: the state post makes the toggles reach the webview.
|
||||
getCwd(getDesktopDir())
|
||||
.then(async (cwd) => {
|
||||
await refreshWorkflowToggles(controller, cwd)
|
||||
await controller.postStateToWebview()
|
||||
})
|
||||
.catch((error) => Logger.warn("Failed to refresh workflow toggles on webview launch:", error))
|
||||
|
||||
// Post last cached models as soon as possible for immediate availability in the UI
|
||||
const lastCachedModels = await controller.readOpenRouterModels()
|
||||
if (lastCachedModels) {
|
||||
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
isSyntheticSdkUserMessage,
|
||||
type SdkUserMessage,
|
||||
} from "./sdk-user-message-mapping"
|
||||
import { buildDisabledWorkflowNames, expandSlashCommands } from "./slash-command-expansion"
|
||||
import { StatePostDebouncer } from "./state-post-debouncer"
|
||||
import { createTaskProxy, type TaskProxy } from "./task-proxy"
|
||||
import { syncTelemetrySettingFromSharedGlobalSettings } from "./telemetry-settings-sync"
|
||||
@@ -841,9 +842,13 @@ export class Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a leading `/workflow` or `/skill` slash command into its instruction
|
||||
* body. Mirrors the CLI's `buildUserInputMessage`. Returns the input unchanged
|
||||
* if it is not a known command or expansion fails.
|
||||
* Expand a `/workflow` or `/skill` slash command into its instruction body.
|
||||
* Serves the same purpose as the CLI's `buildUserInputMessage`, but is more
|
||||
* permissive than the SDK's leading-only resolver: it accepts the legacy
|
||||
* `/my-workflow.md` spelling the webview autocomplete inserts, matches
|
||||
* commands mid-message (anything the chat input highlights as a command),
|
||||
* and honors the user's workflow enable/disable toggles. Returns the input
|
||||
* unchanged if no known command matches or expansion fails.
|
||||
*/
|
||||
private async resolveSlashCommands(text: string): Promise<string> {
|
||||
if (this.isDisposed) {
|
||||
@@ -852,7 +857,18 @@ export class Controller {
|
||||
try {
|
||||
const workspaceRoot = await this.getWorkspaceRoot()
|
||||
const service = await this.ensureUserInstructionService(workspaceRoot)
|
||||
return service.resolveRuntimeSlashCommand(text)
|
||||
const remoteWorkflows = this.stateManager.getRemoteConfigSettings()?.remoteGlobalWorkflows ?? []
|
||||
const workflowRecords = service
|
||||
.listRecords("workflow")
|
||||
.map((record) => ({ name: record.item.name, filePath: record.filePath }))
|
||||
const disabledWorkflowNames = buildDisabledWorkflowNames({
|
||||
records: workflowRecords,
|
||||
globalToggles: this.stateManager.getGlobalSettingsKey("globalWorkflowToggles"),
|
||||
workspaceToggles: this.stateManager.getWorkspaceStateKey("workflowToggles"),
|
||||
remoteToggles: this.stateManager.getGlobalStateKey("remoteWorkflowToggles"),
|
||||
remoteAlwaysEnabledNames: remoteWorkflows.filter((workflow) => workflow.alwaysEnabled).map((w) => w.name),
|
||||
})
|
||||
return expandSlashCommands(text, service.listRuntimeCommands(), { disabledWorkflowNames, workflowRecords })
|
||||
} catch (error) {
|
||||
Logger.warn("[SdkController] Slash command resolution failed, using raw text:", error)
|
||||
return text
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { AvailableRuntimeCommand } from "@cline/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildDisabledWorkflowNames, expandSlashCommands } from "./slash-command-expansion"
|
||||
|
||||
function workflow(name: string, instructions: string): AvailableRuntimeCommand {
|
||||
return { id: name, name, instructions, kind: "workflow" }
|
||||
}
|
||||
|
||||
function skill(name: string, instructions: string): AvailableRuntimeCommand {
|
||||
return { id: name, name, instructions, kind: "skill" }
|
||||
}
|
||||
|
||||
describe("expandSlashCommands", () => {
|
||||
const commands = [workflow("release", "Run the release workflow."), skill("debug", "Use the debugging skill.")]
|
||||
|
||||
it("expands a leading workflow command", () => {
|
||||
expect(expandSlashCommands("/release", commands)).toBe("Run the release workflow.")
|
||||
expect(expandSlashCommands("/release now", commands)).toBe("Run the release workflow. now")
|
||||
})
|
||||
|
||||
it("expands the legacy filename spelling with the .md extension", () => {
|
||||
expect(expandSlashCommands("/release.md now", commands)).toBe("Run the release workflow. now")
|
||||
})
|
||||
|
||||
it("expands the other workflow file extensions the SDK discovers", () => {
|
||||
expect(expandSlashCommands("/release.markdown", commands)).toBe("Run the release workflow.")
|
||||
expect(expandSlashCommands("/release.txt", commands)).toBe("Run the release workflow.")
|
||||
})
|
||||
|
||||
it("matches case-insensitively as a fallback, like webview validation", () => {
|
||||
expect(expandSlashCommands("/Release.MD", commands)).toBe("Run the release workflow.")
|
||||
})
|
||||
|
||||
it("resolves a typed filename to a frontmatter-renamed workflow via records", () => {
|
||||
const renamed = [workflow("ship-it", "Ship it carefully.")]
|
||||
const records = [{ name: "ship-it", filePath: "/repo/.clinerules/workflows/release.md" }]
|
||||
expect(expandSlashCommands("/release.md", renamed, { workflowRecords: records })).toBe("Ship it carefully.")
|
||||
// The renamed command stays governed by its file's toggle.
|
||||
expect(
|
||||
expandSlashCommands("/release.md", renamed, {
|
||||
workflowRecords: records,
|
||||
disabledWorkflowNames: new Set(["ship-it"]),
|
||||
}),
|
||||
).toBe("/release.md")
|
||||
})
|
||||
|
||||
it("expands a command that appears mid-message after whitespace", () => {
|
||||
expect(expandSlashCommands("please run /release.md for v2", commands)).toBe("please run Run the release workflow. for v2")
|
||||
})
|
||||
|
||||
it("only expands the first matching command", () => {
|
||||
expect(expandSlashCommands("/release then /debug", commands)).toBe("Run the release workflow. then /debug")
|
||||
})
|
||||
|
||||
it("skips unknown commands but still expands a later known one", () => {
|
||||
expect(expandSlashCommands("/newtask use /release", commands)).toBe("/newtask use Run the release workflow.")
|
||||
})
|
||||
|
||||
it("expands skills by name", () => {
|
||||
expect(expandSlashCommands("/debug this failure", commands)).toBe("Use the debugging skill. this failure")
|
||||
})
|
||||
|
||||
it("does not treat path segments as commands", () => {
|
||||
expect(expandSlashCommands("look at /release/notes.txt", commands)).toBe("look at /release/notes.txt")
|
||||
})
|
||||
|
||||
it("returns unknown commands unchanged", () => {
|
||||
expect(expandSlashCommands("/missing", commands)).toBe("/missing")
|
||||
expect(expandSlashCommands("no commands here", commands)).toBe("no commands here")
|
||||
})
|
||||
|
||||
it("skips workflows the user disabled via toggles", () => {
|
||||
const disabled = new Set(["release"])
|
||||
expect(expandSlashCommands("/release", commands, { disabledWorkflowNames: disabled })).toBe("/release")
|
||||
expect(expandSlashCommands("/release.md", commands, { disabledWorkflowNames: disabled })).toBe("/release.md")
|
||||
// Skills are governed by frontmatter, not workflow toggles.
|
||||
expect(expandSlashCommands("/debug", commands, { disabledWorkflowNames: new Set(["debug"]) })).toBe(
|
||||
"Use the debugging skill.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildDisabledWorkflowNames", () => {
|
||||
it("disables records whose file toggle is off, by exact command name", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [
|
||||
{ name: "Release", filePath: "/home/user/Documents/Cline/Workflows/Release.md" },
|
||||
{ name: "notes", filePath: "/home/user/Documents/Cline/Workflows/notes.txt" },
|
||||
{ name: "keep", filePath: "/home/user/Documents/Cline/Workflows/keep.md" },
|
||||
],
|
||||
globalToggles: {
|
||||
"/home/user/Documents/Cline/Workflows/Release.md": false,
|
||||
"/home/user/Documents/Cline/Workflows/notes.txt": false,
|
||||
"/home/user/Documents/Cline/Workflows/keep.md": true,
|
||||
},
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["Release", "notes"]))
|
||||
})
|
||||
|
||||
it("matches the toggle by file basename even when frontmatter renames the command", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "ship-it", filePath: "/repo/.clinerules/workflows/release.md" }],
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/release.md": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["ship-it"]))
|
||||
})
|
||||
|
||||
it("keeps a name enabled when any scope has it enabled", () => {
|
||||
// Legacy expansion searched enabled workflows across scopes, so a
|
||||
// disabled workspace file must not shadow an enabled global one.
|
||||
const records = [{ name: "release", filePath: "/repo/.clinerules/workflows/release.md" }]
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
globalToggles: { "/global/dir/release.md": true },
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/release.md": false },
|
||||
}),
|
||||
).toEqual(new Set())
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
globalToggles: { "/global/dir/release.md": false },
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/release.md": true },
|
||||
}),
|
||||
).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("governs each command by its own record when similar names span scopes", () => {
|
||||
// Distinct commands whose names only differ by case/extension must not
|
||||
// influence each other: the disabled remote command stays disabled even
|
||||
// though the similarly-named local one is enabled, and vice versa.
|
||||
const records = [
|
||||
{ name: "Release", filePath: "/repo/.clinerules/workflows/Release.md" },
|
||||
{ name: "release", filePath: "/repo/.cline/remote-config/workflows/release.md" },
|
||||
]
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/Release.md": true },
|
||||
remoteToggles: { release: false },
|
||||
}),
|
||||
).toEqual(new Set(["release"]))
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
workspaceToggles: { "/repo/.clinerules/workflows/Release.md": false },
|
||||
remoteAlwaysEnabledNames: ["release"],
|
||||
}),
|
||||
).toEqual(new Set(["Release"]))
|
||||
})
|
||||
|
||||
it("treats records without any toggle entry as enabled", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "fresh", filePath: "/home/user/.cline/workflows/fresh.md" }],
|
||||
globalToggles: { "/global/dir/other.md": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("governs remote-config records by name-keyed remote toggles", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [
|
||||
{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" },
|
||||
{ name: "org-review", filePath: "/repo/.cline/remote-config/workflows/org-review.md" },
|
||||
{ name: "org-default", filePath: "C:\\repo\\.cline\\remote-config\\workflows\\org-default.md" },
|
||||
],
|
||||
remoteToggles: { "org-standards": false, "org-review": true },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["org-standards"]))
|
||||
})
|
||||
|
||||
it("keys remote toggles off the materialized filename even when frontmatter aliases the command", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "friendly-alias", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }],
|
||||
remoteToggles: { "Org Standards": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["friendly-alias"]))
|
||||
})
|
||||
|
||||
it("matches remote toggles whose config names get sanitized during materialization", () => {
|
||||
// "Org Standards" materializes as org-standards.md, and the record is
|
||||
// named after the sanitized basename.
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }],
|
||||
remoteToggles: { "Org Standards": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["org-standards"]))
|
||||
})
|
||||
|
||||
it("merges colliding sanitized remote names as enabled-if-any-enabled", () => {
|
||||
// "Org Standards" and "org standards" both sanitize to org-standards.
|
||||
const records = [{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }]
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
remoteToggles: { "Org Standards": false, "org standards": true },
|
||||
}),
|
||||
).toEqual(new Set())
|
||||
expect(
|
||||
buildDisabledWorkflowNames({
|
||||
records,
|
||||
remoteToggles: { "Org Standards": false, "org standards": false },
|
||||
}),
|
||||
).toEqual(new Set(["org-standards"]))
|
||||
})
|
||||
|
||||
it("matches remote toggles for names longer than the materializer's 80-char cap", () => {
|
||||
const longConfigName = "a".repeat(100)
|
||||
const materializedName = "a".repeat(80)
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: materializedName, filePath: `/repo/.cline/remote-config/workflows/${materializedName}.md` }],
|
||||
remoteToggles: { [longConfigName]: false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set([materializedName]))
|
||||
})
|
||||
|
||||
it("treats locked (alwaysEnabled) remote workflows as enabled despite stale toggles", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" }],
|
||||
remoteToggles: { "Org Standards": false },
|
||||
remoteAlwaysEnabledNames: ["Org Standards"],
|
||||
})
|
||||
expect(disabled).toEqual(new Set())
|
||||
})
|
||||
|
||||
it("collects disabled names across local and remote scopes", () => {
|
||||
const disabled = buildDisabledWorkflowNames({
|
||||
records: [
|
||||
{ name: "deploy", filePath: "/global/dir/deploy.md" },
|
||||
{ name: "keep", filePath: "/global/dir/keep.md" },
|
||||
{ name: "hotfix", filePath: "C:\\repo\\.clinerules\\workflows\\hotfix.md" },
|
||||
{ name: "org-standards", filePath: "/repo/.cline/remote-config/workflows/org-standards.md" },
|
||||
],
|
||||
globalToggles: { "/global/dir/deploy.md": false, "/global/dir/keep.md": true },
|
||||
workspaceToggles: { "C:\\repo\\.clinerules\\workflows\\hotfix.md": false },
|
||||
remoteToggles: { "org-standards": false },
|
||||
})
|
||||
expect(disabled).toEqual(new Set(["deploy", "hotfix", "org-standards"]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { AvailableRuntimeCommand } from "@cline/core"
|
||||
|
||||
/**
|
||||
* Matches a slash-command token that is either at the start of the message or
|
||||
* preceded by whitespace, and followed by whitespace or end-of-string. Kept in
|
||||
* sync with the webview's `slashCommandRegex` (webview-ui/src/utils/slash-commands.ts)
|
||||
* so anything the chat input highlights/autocompletes as a command can be expanded.
|
||||
*/
|
||||
const SLASH_COMMAND_TOKEN_REGEX = /(^|\s)(\/[a-zA-Z0-9_.:@-]+)(?=\s|$)/g
|
||||
|
||||
/**
|
||||
* File extensions the SDK's workflow discovery accepts (`MARKDOWN_EXTENSIONS`
|
||||
* in @cline/core's user-instruction-config-loader). The SDK strips the
|
||||
* extension when naming the command; the webview autocomplete and legacy
|
||||
* toggle state keep it.
|
||||
*/
|
||||
const WORKFLOW_FILE_EXTENSION_REGEX = /\.(md|markdown|txt)$/i
|
||||
|
||||
/**
|
||||
* Canonical form used to compare workflow names across the places they appear:
|
||||
* typed slash commands and toggle paths keep the file extension, while SDK
|
||||
* command names and remote workflow names do not.
|
||||
*/
|
||||
function canonicalWorkflowName(value: string): string {
|
||||
const stripped = value.replace(WORKFLOW_FILE_EXTENSION_REGEX, "").toLowerCase()
|
||||
return stripped || value.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim port of @cline/shared's private `sanitizeSegment`
|
||||
* (src/remote-config/materializer.ts), which names the files that remote
|
||||
* workflows materialize to — lower-cased, disallowed character runs collapsed
|
||||
* to `-`, capped at 80 characters. Keep in sync with the original.
|
||||
*/
|
||||
function sanitizeRemoteSegment(value: string): string {
|
||||
let result = ""
|
||||
let pendingSeparator = false
|
||||
for (const char of value.trim().toLowerCase()) {
|
||||
const code = char.charCodeAt(0)
|
||||
const isAllowed =
|
||||
(code >= 97 && code <= 122) || (code >= 48 && code <= 57) || char === "." || char === "_" || char === "-"
|
||||
if (isAllowed) {
|
||||
if (pendingSeparator && result && result[result.length - 1] !== "-") {
|
||||
result += "-"
|
||||
}
|
||||
pendingSeparator = false
|
||||
result += char
|
||||
} else {
|
||||
pendingSeparator = true
|
||||
}
|
||||
if (result.length >= 80) {
|
||||
break
|
||||
}
|
||||
}
|
||||
while (result.endsWith("-")) {
|
||||
result = result.slice(0, -1)
|
||||
}
|
||||
while (result.startsWith("-")) {
|
||||
result = result.slice(1)
|
||||
}
|
||||
return result || "item"
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison key for remote workflow names. The discovered record is named
|
||||
* after the sanitized file basename, while remote toggles are keyed by the
|
||||
* original config name (e.g. "Org Standards"), so apply the materializer's
|
||||
* exact transformation to both sides before comparing (it is idempotent on
|
||||
* already-sanitized names).
|
||||
*/
|
||||
function remoteWorkflowNameKey(value: string): string {
|
||||
return sanitizeRemoteSegment(value.replace(WORKFLOW_FILE_EXTENSION_REGEX, ""))
|
||||
}
|
||||
|
||||
function fileBasename(filePath: string): string {
|
||||
return filePath.replace(/^.*[/\\]/, "")
|
||||
}
|
||||
|
||||
/** Matches files materialized from remote config (`.cline/remote-config/…`). */
|
||||
const REMOTE_CONFIG_PATH_REGEX = /[/\\]\.cline[/\\]remote-config[/\\]/
|
||||
|
||||
/** The discovered workflow files toggle filtering and matching operate on. */
|
||||
export interface WorkflowRecordRef {
|
||||
/** Command name (frontmatter `name`, or file basename without extension). */
|
||||
name: string
|
||||
/** Absolute path of the workflow file. */
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface ExpandSlashCommandsOptions {
|
||||
/**
|
||||
* Exact command names of workflows the user disabled via the Workflows
|
||||
* toggles, from {@link buildDisabledWorkflowNames}. Disabled workflows are
|
||||
* left unexpanded, matching legacy semantics.
|
||||
*/
|
||||
disabledWorkflowNames?: ReadonlySet<string>
|
||||
/**
|
||||
* Discovered workflow records, used to also match a typed file name (e.g.
|
||||
* `/my-workflow.md`, what the autocomplete inserts) against a workflow
|
||||
* whose frontmatter `name` differs from its filename.
|
||||
*/
|
||||
workflowRecords?: ReadonlyArray<WorkflowRecordRef>
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the runtime command matching a typed slash-command name.
|
||||
*
|
||||
* The SDK names workflows by frontmatter `name` or file basename *without* the
|
||||
* extension, but the webview autocomplete (and legacy Cline versions) surface
|
||||
* workflow files as `/my-workflow.md`. Accept both spellings — and resolve a
|
||||
* typed file name to its frontmatter-renamed command — so workflows created
|
||||
* under the legacy extension keep working after an upgrade.
|
||||
*/
|
||||
function findRuntimeCommand(
|
||||
commands: readonly AvailableRuntimeCommand[],
|
||||
typedName: string,
|
||||
workflowRecords: ReadonlyArray<WorkflowRecordRef>,
|
||||
): AvailableRuntimeCommand | undefined {
|
||||
const withoutExtension = typedName.replace(WORKFLOW_FILE_EXTENSION_REGEX, "")
|
||||
const candidates = withoutExtension && withoutExtension !== typedName ? [typedName, withoutExtension] : [typedName]
|
||||
for (const candidate of candidates) {
|
||||
const exact = commands.find((command) => command.name === candidate)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
// The webview highlights/validates slash commands case-insensitively, so
|
||||
// fall back to a case-insensitive match rather than silently not expanding.
|
||||
for (const candidate of candidates) {
|
||||
const lowered = candidate.toLowerCase()
|
||||
const insensitive = commands.find((command) => command.name.toLowerCase() === lowered)
|
||||
if (insensitive) {
|
||||
return insensitive
|
||||
}
|
||||
}
|
||||
// Typed file name (autocomplete inserts `/my-workflow.md`) whose workflow
|
||||
// was renamed via frontmatter: resolve through the record's file basename.
|
||||
const typedCanonical = canonicalWorkflowName(typedName)
|
||||
const record = workflowRecords.find((r) => canonicalWorkflowName(fileBasename(r.filePath)) === typedCanonical)
|
||||
if (record) {
|
||||
const recordCanonical = canonicalWorkflowName(record.name)
|
||||
return commands.find((command) => canonicalWorkflowName(command.name) === recordCanonical)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the first slash command in `text` that resolves to a known
|
||||
* workflow/skill into its instruction body.
|
||||
*
|
||||
* Unlike the SDK's `resolveRuntimeSlashCommand` (leading `/command` only), this
|
||||
* matches commands anywhere in the message — the webview lets users insert a
|
||||
* slash command after whitespace mid-message, and the legacy extension expanded
|
||||
* those too. Only the first matching command is expanded, mirroring legacy
|
||||
* behavior and the webview menu (which only offers suggestions for the first
|
||||
* command in a message).
|
||||
*/
|
||||
export function expandSlashCommands(
|
||||
text: string,
|
||||
commands: readonly AvailableRuntimeCommand[],
|
||||
options: ExpandSlashCommandsOptions = {},
|
||||
): string {
|
||||
if (!text.includes("/") || commands.length === 0) {
|
||||
return text
|
||||
}
|
||||
const disabledWorkflowNames = options.disabledWorkflowNames ?? new Set()
|
||||
const workflowRecords = options.workflowRecords ?? []
|
||||
for (const match of text.matchAll(SLASH_COMMAND_TOKEN_REGEX)) {
|
||||
const token = match[2]
|
||||
const typedName = token.slice(1)
|
||||
const command = findRuntimeCommand(commands, typedName, workflowRecords)
|
||||
if (!command) {
|
||||
continue
|
||||
}
|
||||
if (command.kind === "workflow" && disabledWorkflowNames.has(command.name)) {
|
||||
continue
|
||||
}
|
||||
const start = (match.index ?? 0) + match[1].length
|
||||
const end = start + token.length
|
||||
return text.slice(0, start) + command.instructions + text.slice(end)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export interface BuildDisabledWorkflowNamesOptions {
|
||||
/** Discovered workflow records from `listRecords("workflow")`. */
|
||||
records: ReadonlyArray<WorkflowRecordRef>
|
||||
/** `globalWorkflowToggles` (global settings) — keyed by absolute file path. */
|
||||
globalToggles?: Record<string, boolean>
|
||||
/** Workspace `workflowToggles` — keyed by absolute file path. */
|
||||
workspaceToggles?: Record<string, boolean>
|
||||
/** `remoteWorkflowToggles` (global state) — keyed by remote workflow name. */
|
||||
remoteToggles?: Record<string, boolean>
|
||||
/** Names of remote workflows the organization locks on (`alwaysEnabled`). */
|
||||
remoteAlwaysEnabledNames?: Iterable<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the set of exact command names whose workflows the user disabled via
|
||||
* the Workflows toggles (local, global, and enterprise/remote scopes).
|
||||
*
|
||||
* Each command is governed by the toggle state of its own record — the file
|
||||
* whose body would actually expand — so a disabled workflow in one scope can
|
||||
* neither suppress nor unlock a *different* command that happens to share a
|
||||
* similar name in another scope. (The SDK keeps one record per command name,
|
||||
* so per-record evaluation is per-command evaluation.)
|
||||
*
|
||||
* Toggle state is matched to a record by its file basename, so a frontmatter
|
||||
* `name` that differs from the filename is still governed by the file's
|
||||
* toggle. A basename toggled in several scopes counts as enabled when *any*
|
||||
* scope has it enabled: those files collapse into a single record, and legacy
|
||||
* expansion only searched enabled workflows across scopes, so a disabled
|
||||
* workspace file must not shadow a same-named enabled global one (or vice
|
||||
* versa). Files materialized from remote config are governed by the
|
||||
* name-keyed remote toggles instead, and locked (`alwaysEnabled`) remote
|
||||
* workflows always count as enabled.
|
||||
*/
|
||||
export function buildDisabledWorkflowNames(options: BuildDisabledWorkflowNamesOptions): Set<string> {
|
||||
const enabledByBasename = new Map<string, boolean>()
|
||||
for (const toggles of [options.globalToggles ?? {}, options.workspaceToggles ?? {}]) {
|
||||
for (const [filePath, enabled] of Object.entries(toggles)) {
|
||||
const key = canonicalWorkflowName(fileBasename(filePath))
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
enabledByBasename.set(key, (enabledByBasename.get(key) ?? false) || enabled)
|
||||
}
|
||||
}
|
||||
// Distinct config names can sanitize to the same materialized name (case,
|
||||
// punctuation, or the 80-char cap); merge collisions as enabled-if-any-
|
||||
// enabled rather than letting the last entry win arbitrarily.
|
||||
const remoteToggles = new Map<string, boolean>()
|
||||
for (const [name, enabled] of Object.entries(options.remoteToggles ?? {})) {
|
||||
const key = remoteWorkflowNameKey(name)
|
||||
remoteToggles.set(key, (remoteToggles.get(key) ?? false) || enabled)
|
||||
}
|
||||
const remoteAlwaysEnabled = new Set([...(options.remoteAlwaysEnabledNames ?? [])].map(remoteWorkflowNameKey))
|
||||
|
||||
const disabled = new Set<string>()
|
||||
for (const record of options.records) {
|
||||
if (!record.name) {
|
||||
continue
|
||||
}
|
||||
let enabled: boolean
|
||||
if (REMOTE_CONFIG_PATH_REGEX.test(record.filePath)) {
|
||||
// Key off the materialized file basename — the materializer derives it
|
||||
// from the remote config name, so it stays correct even when the file's
|
||||
// frontmatter aliases the command name to something else.
|
||||
const remoteKey = remoteWorkflowNameKey(fileBasename(record.filePath))
|
||||
enabled = remoteAlwaysEnabled.has(remoteKey) || remoteToggles.get(remoteKey) !== false
|
||||
} else {
|
||||
enabled = enabledByBasename.get(canonicalWorkflowName(fileBasename(record.filePath))) ?? true
|
||||
}
|
||||
if (!enabled) {
|
||||
disabled.add(record.name)
|
||||
}
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ToggleCursorRuleRequest,
|
||||
ToggleSkillRequest,
|
||||
ToggleWindsurfRuleRequest,
|
||||
ToggleWorkflowRequest,
|
||||
} from "@shared/proto/cline/file"
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
@@ -32,6 +33,8 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
localCursorRulesToggles = {},
|
||||
localWindsurfRulesToggles = {},
|
||||
localAgentsRulesToggles = {},
|
||||
localWorkflowToggles = {},
|
||||
globalWorkflowToggles = {},
|
||||
hooksEnabled,
|
||||
setGlobalClineRulesToggles,
|
||||
setLocalClineRulesToggles,
|
||||
@@ -58,7 +61,7 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
|
||||
const [arrowPosition, setArrowPosition] = useState(0)
|
||||
const [menuPosition, setMenuPosition] = useState(0)
|
||||
const [currentView, setCurrentView] = useState<"rules" | "hooks" | "skills">("rules")
|
||||
const [currentView, setCurrentView] = useState<"rules" | "workflows" | "hooks" | "skills">("rules")
|
||||
|
||||
// Auto-switch to rules tab if hooks become disabled while viewing hooks tab
|
||||
useEffect(() => {
|
||||
@@ -205,10 +208,20 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const localWorkflows = Object.entries(localWorkflowToggles || {})
|
||||
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const globalWorkflows = Object.entries(globalWorkflowToggles || {})
|
||||
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
const remoteConfigSettings = useRemoteConfigSettings(isVisible)
|
||||
const remoteRules = remoteConfigSettings.filter((s) => s.type === "rule")
|
||||
const remoteWorkflows = remoteConfigSettings.filter((s) => s.type === "workflow")
|
||||
const remoteSkills = remoteConfigSettings.filter((s) => s.type === "skill")
|
||||
const hasRemoteRules = remoteRules.length > 0
|
||||
const hasRemoteWorkflows = remoteWorkflows.length > 0
|
||||
const hasRemoteSkills = remoteSkills.length > 0
|
||||
|
||||
// Handle toggle rule using gRPC
|
||||
@@ -289,6 +302,29 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Handle toggle workflow using gRPC
|
||||
const toggleWorkflow = (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
|
||||
FileServiceClient.toggleWorkflow(
|
||||
ToggleWorkflowRequest.create({
|
||||
workflowPath,
|
||||
enabled,
|
||||
scope: isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL,
|
||||
}),
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.toggles) {
|
||||
if (isGlobal) {
|
||||
setGlobalWorkflowToggles(response.toggles)
|
||||
} else {
|
||||
setLocalWorkflowToggles(response.toggles)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error("Failed to toggle workflow:", err)
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle hook handler
|
||||
const toggleHook = (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
|
||||
FileServiceClient.toggleHook({
|
||||
@@ -401,17 +437,24 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
<TabButton isActive={currentView === "skills"} onClick={() => setCurrentView("skills")}>
|
||||
Skills
|
||||
</TabButton>
|
||||
<TabButton isActive={currentView === "workflows"} onClick={() => setCurrentView("workflows")}>
|
||||
Workflows
|
||||
</TabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote config banner */}
|
||||
{(currentView === "rules" && hasRemoteRules) || (currentView === "skills" && hasRemoteSkills) ? (
|
||||
{(currentView === "rules" && hasRemoteRules) ||
|
||||
(currentView === "workflows" && hasRemoteWorkflows) ||
|
||||
(currentView === "skills" && hasRemoteSkills) ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-textBlockQuote-background border-l-[3px] border-vscode-textLink-foreground">
|
||||
<i className="codicon codicon-lock text-sm" />
|
||||
<span className="text-base">
|
||||
{currentView === "rules"
|
||||
? "Your organization manages some rules"
|
||||
: "Your organization manages some skills"}
|
||||
: currentView === "workflows"
|
||||
? "Your organization manages some workflows"
|
||||
: "Your organization manages some skills"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -429,6 +472,17 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
Docs
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
) : currentView === "workflows" ? (
|
||||
<p>
|
||||
Workflows allow you to define a series of steps to guide Cline through a repetitive set of
|
||||
tasks, such as deploying a service or submitting a PR. To invoke a workflow, type{" "}
|
||||
<span className="text-foreground font-bold">/workflow-name</span> in the chat.{" "}
|
||||
<VSCodeLink
|
||||
className="text-xs inline"
|
||||
href="https://docs.cline.bot/features/slash-commands/workflows">
|
||||
Docs
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
) : currentView === "skills" ? (
|
||||
<p>
|
||||
Skills are reusable instruction sets that Cline can activate on-demand. When a task matches a
|
||||
@@ -530,6 +584,75 @@ const ClineRulesToggleModal: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : currentView === "workflows" ? (
|
||||
<>
|
||||
{/* Deprecation warning banner */}
|
||||
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-inputValidation-warningBackground border-l-[3px] border-vscode-inputValidation-warningBorder">
|
||||
<i className="codicon codicon-warning text-sm" />
|
||||
<span className="text-base">
|
||||
Workflows are being deprecated. Use skills instead.{" "}
|
||||
<VSCodeLink
|
||||
href="https://docs.cline.bot/customization/skills"
|
||||
style={{ display: "inline", fontSize: "inherit" }}>
|
||||
Learn more
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Workflows Section (remote) */}
|
||||
{hasRemoteWorkflows && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-normal mb-2">Enterprise Workflows</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
{remoteWorkflows
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workflow) => {
|
||||
const enabled = workflow.locked || workflow.enabled
|
||||
return (
|
||||
<RuleRow
|
||||
alwaysEnabled={workflow.locked}
|
||||
enabled={enabled}
|
||||
isGlobal={true}
|
||||
isRemote={true}
|
||||
key={workflow.name}
|
||||
rulePath={workflow.name}
|
||||
ruleType="workflow"
|
||||
toggleRule={workflow.toggle}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Workflows Section */}
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-normal mb-2">Global Workflows</div>
|
||||
<RulesToggleList
|
||||
isGlobal={true}
|
||||
listGap="small"
|
||||
rules={globalWorkflows}
|
||||
ruleType={"workflow"}
|
||||
showNewRule={true}
|
||||
showNoRules={false}
|
||||
toggleRule={(rulePath, enabled) => toggleWorkflow(true, rulePath, enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Local Workflows Section */}
|
||||
<div className="-mb-2.5">
|
||||
<div className="text-sm font-normal mb-2">Workspace Workflows</div>
|
||||
<RulesToggleList
|
||||
isGlobal={false}
|
||||
listGap="small"
|
||||
rules={localWorkflows}
|
||||
ruleType={"workflow"}
|
||||
showNewRule={true}
|
||||
showNoRules={false}
|
||||
toggleRule={(rulePath, enabled) => toggleWorkflow(false, rulePath, enabled)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : currentView === "hooks" ? (
|
||||
<>
|
||||
<div className="text-xs text-description mb-4">
|
||||
|
||||
Reference in New Issue
Block a user