mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-18 02:57:47 +00:00
feat: add inline chat experience to new tab page (#418)
* feat: add inline chat experience to new tab page Bring the full sidepanel chat experience to the new tab page. When users select an AI suggestion from the search bar, the page transitions inline to a full chat view instead of opening the sidepanel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unnecessary comments from NewTab.tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review comments - Move NEWTAB_CHAT_STARTED_EVENT tracking to startInlineChat where it actually fires (was dead code in NewTabChat handleSubmit) - Add NEWTAB_CHAT_RESET_EVENT tracking to handleNewConversation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: gate newtab chat behind NEWTAB_CHAT_SUPPORT feature flag When the flag is off (BrowserOS < 0.40.0), falls back to opening the sidepanel via openSidePanelWithSearch (previous behavior). In dev mode all features are enabled, so inline chat works during development. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add newtab origin context to chat system prompt When chatting from the new tab page, the AI is instructed to open content in new tabs rather than navigating the current tab, keeping the user's new tab page accessible. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import {
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
import {
|
||||
NEWTAB_AI_TRIGGERED_EVENT,
|
||||
NEWTAB_APPS_OPENED_EVENT,
|
||||
NEWTAB_CHAT_RESET_EVENT,
|
||||
NEWTAB_CHAT_STARTED_EVENT,
|
||||
NEWTAB_OPENED_EVENT,
|
||||
NEWTAB_SEARCH_EXECUTED_EVENT,
|
||||
NEWTAB_TAB_REMOVED_EVENT,
|
||||
@@ -54,6 +57,7 @@ import {
|
||||
useSuggestions,
|
||||
} from './lib/suggestions/useSuggestions'
|
||||
import { NewTabBranding } from './NewTabBranding'
|
||||
import { NewTabChat } from './NewTabChat'
|
||||
import { NewTabTip } from './NewTabTip'
|
||||
import { ScheduleResults } from './ScheduleResults'
|
||||
import { SearchSuggestions } from './SearchSuggestions'
|
||||
@@ -79,6 +83,7 @@ export const NewTab = () => {
|
||||
const tabsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
|
||||
const [chatActive, setChatActive] = useState(false)
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
filterText: '',
|
||||
@@ -89,6 +94,9 @@ export const NewTab = () => {
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
|
||||
const { messages, sendMessage, setMode, resetConversation } =
|
||||
useChatSessionContext()
|
||||
|
||||
const connectedManagedServers = mcpServers.filter((s) => {
|
||||
if (s.type !== 'managed' || !s.managedServerName) return false
|
||||
return userMCPIntegrations?.integrations?.find(
|
||||
@@ -262,6 +270,21 @@ export const NewTab = () => {
|
||||
runSelectedAction(selectedItem)
|
||||
}
|
||||
|
||||
const startInlineChat = (
|
||||
message: string,
|
||||
mode: 'chat' | 'agent',
|
||||
action?: ReturnType<
|
||||
typeof createBrowserOSAction | typeof createAITabAction
|
||||
>,
|
||||
) => {
|
||||
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
|
||||
setMode(mode)
|
||||
setChatActive(true)
|
||||
sendMessage({ text: message, action })
|
||||
reset()
|
||||
setSelectedTabs([])
|
||||
}
|
||||
|
||||
const runSelectedAction = (item: SuggestionItem | undefined) => {
|
||||
if (!item) return
|
||||
|
||||
@@ -284,11 +307,17 @@ export const NewTab = () => {
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
|
||||
openSidePanelWithSearch('open', {
|
||||
query: searchQuery,
|
||||
mode: 'agent',
|
||||
action,
|
||||
})
|
||||
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
|
||||
startInlineChat(searchQuery, 'agent', action)
|
||||
} else {
|
||||
openSidePanelWithSearch('open', {
|
||||
query: searchQuery,
|
||||
mode: 'agent',
|
||||
action,
|
||||
})
|
||||
reset()
|
||||
setSelectedTabs([])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'browseros': {
|
||||
@@ -301,25 +330,32 @@ export const NewTab = () => {
|
||||
message: item.message,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: item.message,
|
||||
mode: item.mode,
|
||||
action,
|
||||
})
|
||||
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
|
||||
startInlineChat(item.message, item.mode, action)
|
||||
} else {
|
||||
openSidePanelWithSearch('open', {
|
||||
query: item.message,
|
||||
mode: item.mode,
|
||||
action,
|
||||
})
|
||||
reset()
|
||||
setSelectedTabs([])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
reset()
|
||||
setSelectedTabs([])
|
||||
}
|
||||
|
||||
const handleBackToSearch = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
resetConversation()
|
||||
setChatActive(false)
|
||||
}
|
||||
|
||||
const isSuggestionsVisible =
|
||||
!mentionState.isOpen &&
|
||||
// User is typing text into the input
|
||||
((isOpen && inputValue.length) ||
|
||||
// There are sections to display
|
||||
(sections.length > 0 && inputValue.length) ||
|
||||
// User has selected some active tabs
|
||||
(isOpen && selectedTabs.length))
|
||||
|
||||
useEffect(() => {
|
||||
@@ -327,6 +363,10 @@ export const NewTab = () => {
|
||||
track(NEWTAB_OPENED_EVENT)
|
||||
}, [])
|
||||
|
||||
if (chatActive) {
|
||||
return <NewTabChat onBackToSearch={handleBackToSearch} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
{/* Main content */}
|
||||
|
||||
182
apps/agent/entrypoints/newtab/index/NewTabChat.tsx
Normal file
182
apps/agent/entrypoints/newtab/index/NewTabChat.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { ChatEmptyState } from '@/entrypoints/sidepanel/index/ChatEmptyState'
|
||||
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
|
||||
import { ChatFooter } from '@/entrypoints/sidepanel/index/ChatFooter'
|
||||
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { createBrowserOSAction } from '@/lib/chat-actions/types'
|
||||
import {
|
||||
NEWTAB_CHAT_MODE_CHANGED_EVENT,
|
||||
NEWTAB_CHAT_RESET_EVENT,
|
||||
NEWTAB_CHAT_STOPPED_EVENT,
|
||||
NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
|
||||
NEWTAB_TAB_REMOVED_EVENT,
|
||||
NEWTAB_TAB_TOGGLED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { NewTabChatHeader } from './NewTabChatHeader'
|
||||
|
||||
interface NewTabChatProps {
|
||||
onBackToSearch: () => void
|
||||
}
|
||||
|
||||
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
getActionForMessage,
|
||||
liked,
|
||||
onClickLike,
|
||||
disliked,
|
||||
onClickDislike,
|
||||
isRestoringConversation,
|
||||
providers,
|
||||
selectedProvider,
|
||||
handleSelectProvider,
|
||||
resetConversation,
|
||||
} = useChatSessionContext()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll only when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
track(NEWTAB_CHAT_MODE_CHANGED_EVENT, { from: mode, to: newMode })
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
track(NEWTAB_CHAT_STOPPED_EVENT)
|
||||
stop()
|
||||
}
|
||||
|
||||
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
|
||||
setAttachedTabs((prev) => {
|
||||
const isSelected = prev.some((t) => t.id === tab.id)
|
||||
track(NEWTAB_TAB_TOGGLED_EVENT, {
|
||||
action: isSelected ? 'removed' : 'added',
|
||||
})
|
||||
if (isSelected) {
|
||||
return prev.filter((t) => t.id !== tab.id)
|
||||
}
|
||||
return [...prev, tab]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTab = (tabId?: number) => {
|
||||
track(NEWTAB_TAB_REMOVED_EVENT)
|
||||
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
|
||||
}
|
||||
|
||||
const executeMessage = (customMessageText?: string) => {
|
||||
const messageText = customMessageText ? customMessageText : input.trim()
|
||||
if (!messageText) return
|
||||
|
||||
if (attachedTabs.length) {
|
||||
const action = createBrowserOSAction({
|
||||
mode,
|
||||
message: messageText,
|
||||
tabs: attachedTabs,
|
||||
})
|
||||
sendMessage({ text: messageText, action })
|
||||
} else {
|
||||
sendMessage({ text: messageText })
|
||||
}
|
||||
setInput('')
|
||||
setAttachedTabs([])
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
executeMessage()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
|
||||
const handleNewConversation = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
resetConversation()
|
||||
}
|
||||
|
||||
if (!selectedProvider) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-2rem)] flex-col">
|
||||
<NewTabChatHeader
|
||||
selectedProvider={selectedProvider}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewConversation={handleNewConversation}
|
||||
onBackToSearch={onBackToSearch}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
|
||||
{isRestoringConversation ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<ChatEmptyState
|
||||
mode={mode}
|
||||
mounted={mounted}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
status={status}
|
||||
messagesEndRef={messagesEndRef}
|
||||
getActionForMessage={getActionForMessage}
|
||||
liked={liked}
|
||||
onClickLike={onClickLike}
|
||||
disliked={disliked}
|
||||
onClickDislike={onClickDislike}
|
||||
showJtbdPopup={false}
|
||||
showDontShowAgain={false}
|
||||
onTakeSurvey={() => {}}
|
||||
onDismissJtbdPopup={() => {}}
|
||||
/>
|
||||
)}
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
</main>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl px-4">
|
||||
<ChatFooter
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
status={status}
|
||||
onStop={handleStop}
|
||||
attachedTabs={attachedTabs}
|
||||
onToggleTab={toggleTabSelection}
|
||||
onRemoveTab={removeTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
apps/agent/entrypoints/newtab/index/NewTabChatHeader.tsx
Normal file
78
apps/agent/entrypoints/newtab/index/NewTabChatHeader.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
interface NewTabChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewConversation: () => void
|
||||
onBackToSearch: () => void
|
||||
hasMessages: boolean
|
||||
}
|
||||
|
||||
export const NewTabChatHeader: FC<NewTabChatHeaderProps> = ({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelectProvider,
|
||||
onNewConversation,
|
||||
onBackToSearch,
|
||||
hasMessages,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-2.5 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back to search */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToSearch}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Back to search"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Provider selector */}
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={onSelectProvider}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-base">
|
||||
{selectedProvider.name}
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import { Outlet } from 'react-router'
|
||||
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { NewTabFocusGrid } from './NewTabFocusGrid'
|
||||
|
||||
export const NewTabLayout: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<ChatSessionProvider origin="newtab">
|
||||
<NewTabFocusGrid />
|
||||
<Outlet />
|
||||
</>
|
||||
</ChatSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,15 @@ export const getResponseAndQueryFromMessageId = (
|
||||
}
|
||||
}
|
||||
|
||||
export const useChatSession = () => {
|
||||
export type ChatOrigin = 'sidepanel' | 'newtab'
|
||||
|
||||
export interface ChatSessionOptions {
|
||||
origin?: ChatOrigin
|
||||
}
|
||||
|
||||
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
|
||||
|
||||
export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const {
|
||||
selectedLlmProviderRef,
|
||||
enabledMcpServersRef,
|
||||
@@ -294,7 +302,12 @@ export const useChatSession = () => {
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
browserContext,
|
||||
userSystemPrompt: personalizationRef.current,
|
||||
userSystemPrompt:
|
||||
options?.origin === 'newtab'
|
||||
? [personalizationRef.current, NEWTAB_SYSTEM_PROMPT]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
: personalizationRef.current,
|
||||
userWorkingDir: workingDirRef.current,
|
||||
supportsImages: provider?.supportsImages,
|
||||
previousConversation,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { createContext, type FC, type ReactNode, useContext } from 'react'
|
||||
import { useChatSession } from '../index/useChatSession'
|
||||
import {
|
||||
type ChatSessionOptions,
|
||||
useChatSession,
|
||||
} from '../index/useChatSession'
|
||||
|
||||
type ChatSessionContextValue = ReturnType<typeof useChatSession>
|
||||
|
||||
const ChatSessionContext = createContext<ChatSessionContextValue | null>(null)
|
||||
|
||||
export const ChatSessionProvider: FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const session = useChatSession()
|
||||
export const ChatSessionProvider: FC<
|
||||
{ children: ReactNode } & ChatSessionOptions
|
||||
> = ({ children, ...options }) => {
|
||||
const session = useChatSession(options)
|
||||
return (
|
||||
<ChatSessionContext.Provider value={session}>
|
||||
{children}
|
||||
|
||||
@@ -37,6 +37,8 @@ export enum Feature {
|
||||
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
|
||||
// Soul page: agent personality viewer and editor
|
||||
SOUL_SUPPORT = 'SOUL_SUPPORT',
|
||||
// Inline chat in the new tab page
|
||||
NEWTAB_CHAT_SUPPORT = 'NEWTAB_CHAT_SUPPORT',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
|
||||
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
|
||||
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
|
||||
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
|
||||
@@ -98,6 +98,22 @@ export const NEWTAB_APPS_OPENED_EVENT = 'newtab.apps.opened'
|
||||
/** @public */
|
||||
export const NEWTAB_TIP_DISMISSED_EVENT = 'newtab.tip.dismissed'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_STARTED_EVENT = 'newtab.chat.started'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_STOPPED_EVENT = 'newtab.chat.stopped'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_RESET_EVENT = 'newtab.chat.reset'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT =
|
||||
'newtab.chat.suggestion_clicked'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_MODE_CHANGED_EVENT = 'newtab.chat.mode_changed'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user