Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('GlobalCommands')

/**
* Detects if the current platform is macOS.
*
* @returns True if running on macOS, false otherwise
*/
function isMacPlatform(): boolean {
if (typeof window === 'undefined') return false
return (
Expand All @@ -27,18 +22,6 @@ function isMacPlatform(): boolean {
)
}

/**
* Represents a parsed keyboard shortcut.
*
* We support the following modifiers:
* - Mod: maps to Meta on macOS, Ctrl on other platforms
* - Ctrl, Meta, Shift, Alt
*
* Examples:
* - "Mod+A"
* - "Mod+Shift+T"
* - "Meta+K"
*/
export interface ParsedShortcut {
key: string
mod?: boolean
Expand All @@ -48,24 +31,10 @@ export interface ParsedShortcut {
alt?: boolean
}

/**
* Declarative command registration.
*/
export interface GlobalCommand {
/** Unique id for the command. If omitted, one is generated. */
id?: string
/** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
shortcut: string
/**
* Whether to allow the command to run inside editable elements like inputs,
* textareas or contenteditable. Defaults to true to ensure browser defaults
* are overridden when desired.
*/
allowInEditable?: boolean
/**
* Handler invoked when the shortcut is matched. Use this to trigger actions
* like navigation or dispatching application events.
*/
handler: (event: KeyboardEvent) => void
}

Expand All @@ -80,16 +49,13 @@ interface GlobalCommandsContextValue {

const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)

/**
* Parses a human-readable shortcut into a structured representation.
*/
function parseShortcut(shortcut: string): ParsedShortcut {
const parts = shortcut.split('+').map((p) => p.trim())
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
const last = parts[parts.length - 1]

return {
key: last.length === 1 ? last.toLowerCase() : last, // keep non-letter keys verbatim
key: last.length === 1 ? last.toLowerCase() : last,
mod: modifiers.has('mod'),
ctrl: modifiers.has('ctrl'),
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
Expand All @@ -98,16 +64,10 @@ function parseShortcut(shortcut: string): ParsedShortcut {
}
}

/**
* Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
* interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
*/
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)

// Normalize key for comparison: for letters compare lowercase
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key

return (
Expand All @@ -119,10 +79,6 @@ function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
)
}

/**
* Provider that captures global keyboard shortcuts and routes them to
* registered commands. Commands can be registered from any descendant component.
*/
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const isMac = useMemo(() => isMacPlatform(), [])
Expand All @@ -140,13 +96,11 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
allowInEditable: cmd.allowInEditable ?? true,
})
createdIds.push(id)
logger.info('Registered global command', { id, shortcut: cmd.shortcut })
}

return () => {
for (const id of createdIds) {
registryRef.current.delete(id)
logger.info('Unregistered global command', { id })
}
}
}, [])
Expand All @@ -155,8 +109,6 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return

// Evaluate matches in registration order (latest registration wins naturally
// due to replacement on same id). Break on first match.
for (const [, cmd] of registryRef.current) {
if (!cmd.allowInEditable) {
const ae = document.activeElement
Expand All @@ -168,16 +120,8 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
}

if (matchesShortcut(e, cmd.parsed)) {
// Always override default browser behavior for matched commands.
e.preventDefault()
e.stopPropagation()
logger.info('Executing global command', {
id: cmd.id,
shortcut: cmd.shortcut,
key: e.key,
isMac,
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
})
try {
cmd.handler(e)
} catch (err) {
Expand All @@ -197,22 +141,28 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}

/**
* Registers a set of global commands for the lifetime of the component.
*
* Returns nothing; cleanup is automatic on unmount.
*/
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
}

const commandsRef = useRef<GlobalCommand[]>([])
const list = typeof commands === 'function' ? commands() : commands
commandsRef.current = list

useEffect(() => {
const list = typeof commands === 'function' ? commands() : commands
const unregister = ctx.register(list)
const wrappedCommands = commandsRef.current.map((cmd) => ({
...cmd,
handler: (event: KeyboardEvent) => {
const currentCmd = commandsRef.current.find((c) => c.id === cmd.id)
if (currentCmd) {
currentCmd.handler(event)
}
},
}))
const unregister = ctx.register(wrappedCommands)
return unregister
// We intentionally want to register once for the given commands
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,7 @@ export function Chat() {
{isStreaming ? (
<Button
onClick={handleStopStreaming}
className='h-[22px] w-[22px] rounded-full p-0 transition-colors !bg-[var(--c-C0C0C0)] hover:!bg-[var(--c-D0D0D0)]'
className='!bg-[var(--c-C0C0C0)] hover:!bg-[var(--c-D0D0D0)] h-[22px] w-[22px] rounded-full p-0 transition-colors'
>
<Square className='h-2.5 w-2.5 fill-black text-black' />
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ export function Panel() {
}
}

/**
* Cancels the currently executing workflow
*/
const cancelWorkflow = useCallback(async () => {
await handleCancelExecution()
}, [handleCancelExecution])

/**
* Runs the workflow with usage limit check
*/
Expand All @@ -144,13 +151,6 @@ export function Panel() {
await handleRunWorkflow()
}, [usageExceeded, handleRunWorkflow])

/**
* Cancels the currently executing workflow
*/
const cancelWorkflow = useCallback(async () => {
await handleCancelExecution()
}, [handleCancelExecution])

// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore()
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
Expand Down Expand Up @@ -300,7 +300,6 @@ export function Panel() {
{
id: 'run-workflow',
handler: () => {
// Do exactly what the Run button does
if (isExecuting) {
void cancelWorkflow()
} else {
Expand Down
Loading