diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index e1e100ebde4..ab8b00dceba 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -293,6 +293,7 @@ export type SetupChannelsOptions = { onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void; promptAccountIds?: boolean; forceAllowFromChannels?: ChannelId[]; + deferStatusUntilSelection?: boolean; skipStatusNote?: boolean; skipDmPolicyPrompt?: boolean; skipConfirm?: boolean; diff --git a/src/commands/configure.channels.test.ts b/src/commands/configure.channels.test.ts new file mode 100644 index 00000000000..f4d19533b08 --- /dev/null +++ b/src/commands/configure.channels.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const select = vi.hoisted(() => vi.fn()); +const confirm = vi.hoisted(() => vi.fn()); +const note = vi.hoisted(() => vi.fn()); + +vi.mock("../channels/chat-meta.js", () => ({ + listChatChannels: () => [ + { id: "telegram", label: "Telegram" }, + { id: "twitch", label: "Twitch" }, + ], +})); + +vi.mock("../terminal/note.js", () => ({ + note: (...args: unknown[]) => note(...args), +})); + +vi.mock("./configure.shared.js", () => ({ + select: (params: unknown) => select(params), + confirm: (params: unknown) => confirm(params), +})); + +import { removeChannelConfigWizard } from "./configure.channels.js"; + +describe("removeChannelConfigWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + confirm.mockResolvedValue(true); + }); + + it("lists configured channels from openclaw.json even when no plugins are loaded", async () => { + select.mockResolvedValue("done"); + + await removeChannelConfigWizard( + { + channels: { + twitch: {}, + unknown: {}, + telegram: {}, + }, + } as never, + {} as never, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Remove which channel config?", + options: [ + expect.objectContaining({ value: "telegram", label: "Telegram" }), + expect.objectContaining({ value: "twitch", label: "Twitch" }), + expect.objectContaining({ value: "unknown", label: "unknown" }), + { value: "done", label: "Done" }, + ], + }), + ); + }); + + it("deletes the selected channel block from openclaw.json", async () => { + select.mockResolvedValueOnce("telegram").mockResolvedValueOnce("done"); + + const next = await removeChannelConfigWizard( + { + channels: { + telegram: { token: "secret" }, + twitch: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete Telegram configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(next.channels).toEqual({ twitch: { token: "secret" } }); + expect(note).toHaveBeenCalledWith( + "Telegram removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); +}); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index c90da7a4aa2..f221d761e43 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -1,28 +1,52 @@ -import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { listChatChannels } from "../channels/chat-meta.js"; import { formatCliCommand } from "../cli/command-format.js"; import { CONFIG_PATH } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; -import { shouldShowChannelInSetup } from "./channel-setup/discovery.js"; import { confirm, select } from "./configure.shared.js"; import { guardCancel } from "./onboard-helpers.js"; +type ConfiguredChannelRemovalChoice = { + id: string; + label: string; +}; + +function listConfiguredChannelRemovalChoices( + cfg: OpenClawConfig, +): ConfiguredChannelRemovalChoice[] { + const channels = cfg.channels; + if (!channels) { + return []; + } + const labelsById = new Map(listChatChannels().map((meta) => [meta.id, meta.label])); + return Object.keys(channels) + .map((id) => ({ + id, + label: labelsById.get(id) ?? id, + })) + .toSorted(compareChannelRemovalChoices); +} + +function compareChannelRemovalChoices( + left: ConfiguredChannelRemovalChoice, + right: ConfiguredChannelRemovalChoice, +): number { + return ( + left.label.localeCompare(right.label, undefined, { numeric: true, sensitivity: "base" }) || + left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" }) + ); +} + export async function removeChannelConfigWizard( cfg: OpenClawConfig, runtime: RuntimeEnv, ): Promise { let next = { ...cfg }; - const listConfiguredChannels = () => - listChannelPlugins() - .map((plugin) => plugin.meta) - .filter((meta) => shouldShowChannelInSetup(meta)) - .filter((meta) => next.channels?.[meta.id] !== undefined); - while (true) { - const configured = listConfiguredChannels(); + const configured = listConfiguredChannelRemovalChoices(next); if (configured.length === 0) { note( [ @@ -53,7 +77,7 @@ export async function removeChannelConfigWizard( return next; } - const label = getChannelPlugin(channel)?.meta.label ?? channel; + const label = configured.find((entry) => entry.id === channel)?.label ?? channel; const confirmed = guardCancel( await confirm({ message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH)}?`, diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index b17d421425d..842d1d9c3b5 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => { isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => Boolean(config.auth?.profiles?.["openai-codex:default"]), ), + setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), }; }); @@ -104,7 +105,7 @@ vi.mock("./onboard-skills.js", () => ({ })); vi.mock("./onboard-channels.js", () => ({ - setupChannels: vi.fn(), + setupChannels: mocks.setupChannels, })); vi.mock("./onboard-search.js", () => ({ @@ -327,6 +328,28 @@ describe("runConfigureWizard", () => { ); }); + it("defers channel status checks until a channel is selected", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: ["local", "configure"], + confirm: [], + }); + + await runConfigureWizard({ command: "configure", sections: ["channels"] }, createRuntime()); + + expect(mocks.setupChannels).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ mode: "local" }), + }), + expect.anything(), + expect.anything(), + expect.objectContaining({ + deferStatusUntilSelection: true, + skipStatusNote: true, + }), + ); + }); + it("still supports keyless web search providers through the shared setup flow", async () => { setupBaseWizardState(); mocks.resolveSearchProviderOptions.mockReturnValue([ diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index e64b7cf2630..91de71280f4 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -35,7 +35,7 @@ import { } from "./configure.shared.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { healthCommand } from "./health.js"; -import { noteChannelStatus, setupChannels } from "./onboard-channels.js"; +import { setupChannels } from "./onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -561,12 +561,12 @@ export async function runConfigureWizard( }; const configureChannelsSection = async () => { - await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, + deferStatusUntilSelection: true, skipConfirm: true, skipStatusNote: true, }); diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts new file mode 100644 index 00000000000..525b128ba54 --- /dev/null +++ b/src/flows/channel-setup.status.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +const listChatChannels = vi.hoisted(() => vi.fn(() => [{ id: "discord" }, { id: "bluebubbles" }])); + +vi.mock("../channels/chat-meta.js", () => ({ + listChatChannels: () => listChatChannels(), +})); + +vi.mock("../channels/registry.js", () => ({ + formatChannelPrimerLine: vi.fn(() => ""), + formatChannelSelectionLine: vi.fn(() => ""), +})); + +vi.mock("../commands/channel-setup/discovery.js", () => ({ + resolveChannelSetupEntries: vi.fn(() => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), + shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) => + meta.showInSetup !== false && meta.exposure?.setup !== false, +})); + +import { resolveChannelSetupSelectionContributions } from "./channel-setup.status.js"; + +describe("resolveChannelSetupSelectionContributions", () => { + it("sorts channels alphabetically by picker label", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + }, + }, + { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord (Bot API)", + }, + }, + { + id: "bluebubbles", + meta: { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + }, + }, + ] as never, + statusByChannel: new Map(), + resolveDisabledHint: () => undefined, + }); + + expect(contributions.map((contribution) => contribution.option.label)).toEqual([ + "BlueBubbles (macOS app)", + "Discord (Bot API)", + "Zalo (Bot API)", + ]); + }); + + it("does not invent hints before status has been collected", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + quickstartAllowFrom: true, + }, + }, + ] as never, + statusByChannel: new Map(), + resolveDisabledHint: () => undefined, + }); + + expect(contributions.map((contribution) => contribution.option)).toEqual([ + { + value: "zalo", + label: "Zalo (Bot API)", + }, + ]); + }); + + it("combines real status and disabled hints when available", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + quickstartAllowFrom: true, + }, + }, + ] as never, + statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]), + resolveDisabledHint: () => "disabled", + }); + + expect(contributions[0]?.option).toEqual({ + value: "zalo", + label: "Zalo (Bot API)", + hint: "configured · disabled", + }); + }); +}); diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index fd1a5d97fc3..f84ac0d1817 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -35,6 +35,18 @@ export type ChannelSetupSelectionContribution = FlowContribution & { source: "catalog" | "core" | "plugin"; }; +type ChannelSetupSelectionEntry = { + id: ChannelChoice; + meta: { + id: string; + label: string; + selectionLabel?: string; + exposure?: { setup?: boolean }; + showConfigured?: boolean; + showInSetup?: boolean; + }; +}; + function buildChannelSetupSelectionContribution(params: { channel: ChannelChoice; label: string; @@ -235,33 +247,35 @@ export function resolveChannelSelectionNoteLines(params: { } export function resolveChannelSetupSelectionContributions(params: { - entries: Array<{ - id: ChannelChoice; - meta: { - id: string; - label: string; - selectionLabel?: string; - exposure?: { setup?: boolean }; - showConfigured?: boolean; - showInSetup?: boolean; - }; - }>; + entries: ChannelSetupSelectionEntry[]; statusByChannel: Map; resolveDisabledHint: (channel: ChannelChoice) => string | undefined; }): ChannelSetupSelectionContribution[] { + const bundledChannelIds = new Set(listChatChannels().map((channel) => channel.id)); return params.entries .filter((entry) => shouldShowChannelInSetup(entry.meta)) + .toSorted((left, right) => compareChannelSetupSelectionEntries(left, right)) .map((entry) => { const disabledHint = params.resolveDisabledHint(entry.id); - const hint = - [params.statusByChannel.get(entry.id)?.selectionHint, disabledHint] - .filter(Boolean) - .join(" · ") || undefined; + const statusHint = params.statusByChannel.get(entry.id)?.selectionHint; + const hint = [statusHint, disabledHint].filter(Boolean).join(" · ") || undefined; return buildChannelSetupSelectionContribution({ channel: entry.id, label: entry.meta.selectionLabel ?? entry.meta.label, hint, - source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin", + source: bundledChannelIds.has(entry.id) ? "core" : "plugin", }); }); } + +function compareChannelSetupSelectionEntries( + left: ChannelSetupSelectionEntry, + right: ChannelSetupSelectionEntry, +): number { + const leftLabel = left.meta.selectionLabel ?? left.meta.label; + const rightLabel = right.meta.selectionLabel ?? right.meta.label; + return ( + leftLabel.localeCompare(rightLabel, undefined, { numeric: true, sensitivity: "base" }) || + left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" }) + ); +} diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 5cee897c568..2675394af26 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -12,6 +12,25 @@ const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })), ); +const resolveChannelSetupEntries = vi.hoisted(() => + vi.fn( + ( + _params?: unknown, + ): { + entries: unknown[]; + installedCatalogEntries: unknown[]; + installableCatalogEntries: unknown[]; + installedCatalogById: Map; + installableCatalogById: Map; + } => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }), + ), +); const collectChannelStatus = vi.hoisted(() => vi.fn(async (_params?: unknown) => ({ installedPlugins: [], @@ -42,7 +61,7 @@ vi.mock("../channels/registry.js", () => ({ })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: vi.fn(), + resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params), shouldShowChannelInSetup: () => true, })); @@ -101,6 +120,13 @@ describe("setupChannels workspace shadow exclusion", () => { channels: [], channelSetups: [], }); + resolveChannelSetupEntries.mockReturnValue({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); collectChannelStatus.mockResolvedValue({ installedPlugins: [], catalogEntries: [], @@ -163,4 +189,41 @@ describe("setupChannels workspace shadow exclusion", () => { }), ); }); + + it("defers status and setup-plugin loads until a channel is selected", async () => { + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }, + ], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + const select = vi.fn(async () => "__done__"); + + await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + }, + ); + + expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); + expect(collectChannelStatus).not.toHaveBeenCalled(); + expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled(); + expect(listChannelSetupPlugins).not.toHaveBeenCalled(); + expect(getChannelSetupPlugin).not.toHaveBeenCalled(); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); + }); }); diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index 0ca01cdc69b..6d4943190d0 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -22,6 +22,7 @@ import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setu import type { ChannelSetupConfiguredResult, ChannelSetupResult, + ChannelSetupStatus, ChannelOnboardingPostWriteHook, SetupChannelsOptions, } from "../commands/channel-setup/types.js"; @@ -110,6 +111,7 @@ export async function setupChannels( options?: SetupChannelsOptions, ): Promise { let next = cfg; + const deferStatusUntilSelection = options?.deferStatusUntilSelection === true; const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, @@ -122,12 +124,18 @@ export async function setupChannels( options?.onResolvedPlugin?.(channel, plugin); }; const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined => - scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => { + scopedPluginsById.get(channel) ?? + (deferStatusUntilSelection ? undefined : getChannelSetupPlugin(channel)); + const listVisibleInstalledPlugins = (params?: { + includeRegistry?: boolean; + }): ChannelSetupPlugin[] => { + const includeRegistry = params?.includeRegistry ?? !deferStatusUntilSelection; const merged = new Map(); - for (const plugin of listChannelSetupPlugins()) { - if (shouldShowChannelInSetup(plugin.meta)) { - merged.set(plugin.id, plugin); + if (includeRegistry) { + for (const plugin of listChannelSetupPlugins()) { + if (shouldShowChannelInSetup(plugin.meta)) { + merged.set(plugin.id, plugin); + } } } for (const plugin of scopedPluginsById.values()) { @@ -137,10 +145,10 @@ export async function setupChannels( } return Array.from(merged.values()); }; - const resolveVisibleChannelEntries = () => + const resolveVisibleChannelEntries = (params?: { includeRegistry?: boolean }) => resolveChannelSetupEntries({ cfg: next, - installedPlugins: listVisibleInstalledPlugins(), + installedPlugins: listVisibleInstalledPlugins(params), workspaceDir: resolveWorkspaceDir(), }); const loadScopedChannelPlugin = async ( @@ -172,7 +180,7 @@ export async function setupChannels( if (scopedPlugin) { return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin); } - return resolveChannelSetupWizardAdapterForPlugin(getChannelSetupPlugin(channel)); + return resolveChannelSetupWizardAdapterForPlugin(getVisibleChannelPlugin(channel)); }; const preloadConfiguredExternalPlugins = async () => { // Keep setup memory bounded by snapshot-loading only configured external plugins. @@ -194,15 +202,20 @@ export async function setupChannels( } await Promise.all(preloadTasks); }; - await preloadConfiguredExternalPlugins(); + if (!deferStatusUntilSelection) { + await preloadConfiguredExternalPlugins(); + } - const { statusByChannel, statusLines } = await collectChannelStatus({ - cfg: next, - options, - accountOverrides, - installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleSetupFlowAdapter, - }); + const statusSummary = deferStatusUntilSelection + ? { statusByChannel: new Map(), statusLines: [] } + : await collectChannelStatus({ + cfg: next, + options, + accountOverrides, + installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleSetupFlowAdapter, + }); + const { statusByChannel, statusLines } = statusSummary; if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -217,7 +230,9 @@ export async function setupChannels( return cfg; } - const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({ + const primerChannels = resolveVisibleChannelEntries({ + includeRegistry: !deferStatusUntilSelection, + }).entries.map((entry) => ({ id: entry.id, label: entry.meta.label, blurb: entry.meta.blurb, @@ -225,7 +240,8 @@ export async function setupChannels( await noteChannelPrimer(prompter, primerChannels); const quickstartDefault = - options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel); + options?.initialSelection?.[0] ?? + (deferStatusUntilSelection ? undefined : resolveQuickstartDefault(statusByChannel)); const shouldPromptAccountIds = options?.promptAccountIds === true; const accountIdsByChannel = new Map(); @@ -243,7 +259,7 @@ export async function setupChannels( } }; - const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { + const resolveConfigDisabledHint = (channel: ChannelChoice): string | undefined => { if ( typeof (next.channels as Record | undefined)?.[channel] ?.enabled === "boolean" @@ -252,14 +268,22 @@ export async function setupChannels( ? "disabled" : undefined; } + if (next.plugins?.entries?.[channel]?.enabled === false) { + return "plugin disabled"; + } + if (next.plugins?.enabled === false) { + return "plugins disabled"; + } + return undefined; + }; + + const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { + const configDisabledHint = resolveConfigDisabledHint(channel); + if (configDisabledHint || deferStatusUntilSelection) { + return configDisabledHint; + } const plugin = getVisibleChannelPlugin(channel); if (!plugin) { - if (next.plugins?.entries?.[channel]?.enabled === false) { - return "plugin disabled"; - } - if (next.plugins?.enabled === false) { - return "plugins disabled"; - } return undefined; } const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next }); @@ -274,7 +298,9 @@ export async function setupChannels( }; const getChannelEntries = () => { - const resolved = resolveVisibleChannelEntries(); + const resolved = resolveVisibleChannelEntries({ + includeRegistry: !deferStatusUntilSelection, + }); return { entries: resolved.entries, catalogById: resolved.installableCatalogById, @@ -485,7 +511,7 @@ export async function setupChannels( } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); - } else if (installedCatalogEntry) { + } else if (installedCatalogEntry && installedCatalogEntry.origin !== "bundled") { const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); if (!plugin) { await prompter.note(`${channel} plugin not available.`, "Channel setup"); @@ -581,7 +607,9 @@ export async function setupChannels( const selectedLines = resolveChannelSelectionNoteLines({ cfg: next, - installedPlugins: listVisibleInstalledPlugins(), + installedPlugins: listVisibleInstalledPlugins({ + includeRegistry: !deferStatusUntilSelection, + }), selection, }); if (selectedLines.length > 0) {