Skip to content

Commit ea3461e

Browse files
ryan-williamsclaude
andcommitted
Add omnibar (⌘K) and sequence completion modal
Features: - Command palette (⌘K) with fuzzy search across actions - Sequence completion modal shows available actions while typing sequences - Keywords/synonyms for better search (e.g., "co2" matches "CO₂", "back" matches "Prev") - Search matches group names (e.g., "hum right" finds Humidity in Right Y-Axis) Fixes: - Gate scroll zoom behind meta+scroll to prevent accidental zooms - Increase sequence timeout to 2000ms for more comfortable input - Show unassigned actions in shortcuts modal (actions without keybindings) - Fix remove button being occluded by adjacent hotkeys (z-index) - Add info icon to "None" explaining it only applies to right Y-axis - Close omnibar on Escape/blur properly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6fc2ab8 commit ea3461e

File tree

9 files changed

+807
-163
lines changed

9 files changed

+807
-163
lines changed

www/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"@floating-ui/react": "^0.27.13",
2727
"@fortawesome/fontawesome-free": "^6.7.2",
2828
"@observablehq/plot": "^0.6.17",
29-
"@rdub/use-hotkeys": "github:runsascoded/use-hotkeys#c62903e7efc6aa123a9da6b18e429ca32da31c43",
29+
"@rdub/use-hotkeys": "github:runsascoded/use-hotkeys#4b1828f197bef38c2df2481f7f0af20b4e72f72d",
3030
"@rdub/use-url-params": "^0.1.2",
3131
"@tanstack/react-query": "^5.83.0",
3232
"d3": "^7.9.0",

www/pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

www/src/App.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { KeyboardShortcutsProvider, ShortcutsModal, useKeyboardShortcutsContext } from '@rdub/use-hotkeys'
22
import { useUrlParam } from '@rdub/use-url-params'
33
import { QueryClientProvider } from '@tanstack/react-query'
4-
import { useCallback, useEffect, useMemo, useState } from 'react'
4+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
55
import { AwairChart } from './components/AwairChart'
66
import { DevicePoller, type DeviceDataResult } from './components/DevicePoller'
7+
import { Omnibar } from './components/Omnibar'
78
import { HOTKEY_DESCRIPTIONS, HOTKEY_GROUPS, ShortcutsModalContent } from './components/ShortcutsModalContent'
89
import { ThemeToggle } from './components/ThemeToggle'
910
import { ThemeProvider } from './contexts/ThemeContext'
@@ -16,8 +17,14 @@ import './App.scss'
1617
function AppContent() {
1718
const [isOgMode] = useUrlParam('og', boolParam)
1819
const [shortcutsOpen, setShortcutsOpen] = useState(false)
20+
const [omnibarOpen, setOmnibarOpen] = useState(false)
1921
const openShortcuts = useCallback(() => setShortcutsOpen(true), [])
2022
const closeShortcuts = useCallback(() => setShortcutsOpen(false), [])
23+
const toggleOmnibar = useCallback(() => setOmnibarOpen(prev => !prev), [])
24+
const closeOmnibar = useCallback(() => setOmnibarOpen(false), [])
25+
26+
// Ref for executing actions from omnibar
27+
const handlersRef = useRef<Record<string, () => void>>({})
2128

2229
// Access keyboard shortcuts from context (state lives in KeyboardShortcutsProvider)
2330
const shortcutsState = useKeyboardShortcutsContext()
@@ -179,6 +186,8 @@ function AppContent() {
179186
setTimeRange={setTimeRange}
180187
isOgMode={isOgMode}
181188
onOpenShortcuts={openShortcuts}
189+
onOpenOmnibar={toggleOmnibar}
190+
handlersRef={handlersRef}
182191
/>
183192
)}
184193
</main>
@@ -201,6 +210,17 @@ function AppContent() {
201210
)}
202211
</ShortcutsModal>
203212
)}
213+
{/* Omnibar (command palette) */}
214+
{!isOgMode && (
215+
<Omnibar
216+
isOpen={omnibarOpen}
217+
onClose={closeOmnibar}
218+
onExecute={(actionId) => {
219+
const handler = handlersRef.current[actionId]
220+
if (handler) handler()
221+
}}
222+
/>
223+
)}
204224
</div>
205225
)
206226
}

www/src/components/AwairChart.tsx

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useUrlParam } from '@rdub/use-url-params'
2-
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'
2+
import React, { useState, useMemo, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
33
import Plot from 'react-plotly.js'
44
import { ChartControls, metricConfig, getRangeFloor } from './ChartControls'
55
import { CustomLegend } from './CustomLegend'
66
import { DataTable } from './DataTable'
7+
import { SequenceModal } from './SequenceModal'
78
import { TIME_WINDOWS, getWindowForDuration } from '../hooks/useDataAggregation'
89
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
910
import { useLatestMode } from '../hooks/useLatestMode'
@@ -47,9 +48,11 @@ interface Props {
4748
setTimeRange: (range: { timestamp: Date | null; duration: number }) => void
4849
isOgMode?: boolean
4950
onOpenShortcuts?: () => void
51+
onOpenOmnibar?: () => void
52+
handlersRef?: MutableRefObject<Record<string, () => void>>
5053
}
5154

52-
export const AwairChart = React.memo(function AwairChart({ deviceDataResults, summary, devices, selectedDeviceIds, onDeviceSelectionChange, timeRange: timeRangeFromProps, setTimeRange: setTimeRangeFromProps, isOgMode = false, onOpenShortcuts }: Props) {
55+
export const AwairChart = React.memo(function AwairChart({ deviceDataResults, summary, devices, selectedDeviceIds, onDeviceSelectionChange, timeRange: timeRangeFromProps, setTimeRange: setTimeRangeFromProps, isOgMode = false, onOpenShortcuts, onOpenOmnibar, handlersRef }: Props) {
5356

5457
// Combine data from all devices for time range calculations and bounds checking
5558
// Sorted newest-first for efficient latest record access
@@ -96,6 +99,7 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
9699

97100
// Refs for handling programmatic updates
98101
const ignoreNextRelayoutRef = useRef(false)
102+
const plotContainerRef = useRef<HTMLDivElement>(null)
99103

100104
// Compute window size for buffer calculation (before useTimeRangeParam)
101105
const windowMinutes = useMemo(() => {
@@ -284,7 +288,7 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
284288
}, [setXAxisRange, getAllDeviceBounds, formatForPlotly])
285289

286290
// Register keyboard shortcuts (keymap managed by context, handlers defined here)
287-
const { pendingKeys, isAwaitingSequence, timeoutStartedAt, sequenceTimeout } = useKeyboardShortcuts({
291+
const { pendingKeys, isAwaitingSequence, timeoutStartedAt, sequenceTimeout, handlers } = useKeyboardShortcuts({
288292
metrics,
289293
xAxisRange,
290294
setXAxisRange,
@@ -296,6 +300,7 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
296300
handleAllClick,
297301
setIgnoreNextPanCheck,
298302
openShortcutsModal: onOpenShortcuts || noop,
303+
openOmnibar: onOpenOmnibar || noop,
299304
devices,
300305
selectedDeviceIds,
301306
setSelectedDeviceIds: onDeviceSelectionChange,
@@ -307,6 +312,13 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
307312
tableLastPage: tableNavHandlers?.lastPage,
308313
})
309314

315+
// Update handlersRef for omnibar to use
316+
useEffect(() => {
317+
if (handlersRef) {
318+
handlersRef.current = handlers
319+
}
320+
}, [handlersRef, handlers])
321+
310322
// Handle responsive plot height and viewport width using matchMedia
311323
useEffect(() => {
312324
const mobileQuery = window.matchMedia('(max-width: 767px) or (max-height: 599px)')
@@ -330,6 +342,23 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
330342
}
331343
}, [])
332344

345+
// Gate scroll zoom behind meta+scroll to avoid accidental zooms while scrolling page
346+
useEffect(() => {
347+
const container = plotContainerRef.current
348+
if (!container) return
349+
350+
const handleWheel = (e: WheelEvent) => {
351+
// Only allow scroll zoom if meta key is pressed
352+
if (!e.metaKey && !e.ctrlKey) {
353+
e.stopPropagation()
354+
}
355+
}
356+
357+
// Use capture phase to intercept before plotly sees it
358+
container.addEventListener('wheel', handleWheel, { capture: true })
359+
return () => container.removeEventListener('wheel', handleWheel, { capture: true })
360+
}, [])
361+
333362
// Theme-aware plot colors
334363
const computePlotColors = useCallback(() => {
335364
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
@@ -685,36 +714,15 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
685714
}
686715
}, [selectedDeviceIdForTable, selectedWindow, deviceDataResults])
687716

688-
// Format pending keys for display
689-
const formatPendingKeys = () => {
690-
return pendingKeys.map(combo => {
691-
let key = combo.key.toUpperCase()
692-
if (combo.modifiers.shift) key = `⇧${key}`
693-
if (combo.modifiers.ctrl) key = `⌃${key}`
694-
if (combo.modifiers.alt) key = `⌥${key}`
695-
if (combo.modifiers.meta) key = `⌘${key}`
696-
return key
697-
}).join(' ')
698-
}
699-
700717
return (
701718
<div className={`awair-chart${isOgMode ? ' og-mode' : ''}`}>
702-
{/* Sequence indicator */}
703-
{isAwaitingSequence && pendingKeys.length > 0 && (
704-
<div className="sequence-indicator">
705-
<kbd>{formatPendingKeys()}</kbd>
706-
<span className="sequence-waiting"></span>
707-
{timeoutStartedAt && (
708-
<div
709-
className="sequence-timeout-bar"
710-
style={{
711-
animationDuration: `${sequenceTimeout}ms`,
712-
}}
713-
key={timeoutStartedAt}
714-
/>
715-
)}
716-
</div>
717-
)}
719+
{/* Sequence modal (omnibar-style with completions) */}
720+
<SequenceModal
721+
pendingKeys={pendingKeys}
722+
isAwaitingSequence={isAwaitingSequence}
723+
timeoutStartedAt={timeoutStartedAt}
724+
sequenceTimeout={sequenceTimeout}
725+
/>
718726
{/* OG mode title overlay */}
719727
{isOgMode && (
720728
<div className="og-title" style={{ color: plotColors.textColor }}>
@@ -723,6 +731,7 @@ export const AwairChart = React.memo(function AwairChart({ deviceDataResults, su
723731
</div>
724732
)}
725733
<div
734+
ref={plotContainerRef}
726735
className="plot-container"
727736
onMouseEnter={() => setHoverState(null)}
728737
>

0 commit comments

Comments
 (0)