@invect/ui 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invect/ui",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Complete React components and routes for Invect workflow management",
5
5
  "keywords": [
6
6
  "components",
@@ -74,12 +74,13 @@
74
74
  "codemirror": "^6.0.2",
75
75
  "nanoid": "^5.1.6",
76
76
  "prettier": "^3.8.1",
77
+ "react-hotkeys-hook": "^5.2.4",
77
78
  "react-markdown": "^10.1.0",
78
79
  "react-resizable-panels": "^3.0.6",
79
80
  "remark-gfm": "^4.0.1",
80
81
  "zustand": "^5.0.9",
81
- "@invect/core": "0.0.1",
82
- "@invect/layouts": "0.0.1"
82
+ "@invect/core": "0.0.2",
83
+ "@invect/layouts": "0.0.2"
83
84
  },
84
85
  "devDependencies": {
85
86
  "@radix-ui/react-dropdown-menu": "^2.1.16",
package/src/Invect.tsx CHANGED
@@ -26,13 +26,23 @@ import { AppSideMenu } from './components/side-menu/side-menu';
26
26
 
27
27
  export interface InvectProps {
28
28
  reactQueryClient?: QueryClient;
29
- apiBaseUrl?: string;
30
- basePath?: string;
29
+ apiPath?: string;
30
+ frontendPath?: string;
31
31
  useMemoryRouter?: boolean; // Use MemoryRouter instead of BrowserRouter (useful for testing)
32
32
  /** Frontend plugins that contribute sidebar items, routes, panel tabs, header actions, etc. */
33
33
  plugins?: InvectFrontendPlugin[];
34
- /** Pre-configured API client instance (e.g. for demo mode). When provided, apiBaseUrl is ignored. */
34
+ /** Pre-configured API client instance (e.g. for demo mode). When provided, apiPath is ignored. */
35
35
  apiClient?: ApiClient;
36
+ /**
37
+ * Invect configuration object (from defineConfig).
38
+ * When provided, `apiPath` and `frontendPath` are read from the config
39
+ * unless explicitly overridden by the individual props.
40
+ */
41
+ config?: {
42
+ apiPath?: string;
43
+ frontendPath?: string;
44
+ plugins?: Array<{ frontend?: InvectFrontendPlugin }>;
45
+ };
36
46
  }
37
47
 
38
48
  // Create a default QueryClient if none is provided
@@ -193,12 +203,20 @@ InvectRoutes.displayName = 'InvectRoutes';
193
203
  export const Invect = React.memo(
194
204
  ({
195
205
  reactQueryClient,
196
- apiBaseUrl = 'http://localhost:3000/invect',
197
- basePath = '/invect',
206
+ apiPath: apiPathProp,
207
+ frontendPath: frontendPathProp,
198
208
  useMemoryRouter = false,
199
209
  plugins,
200
210
  apiClient,
211
+ config,
201
212
  }: InvectProps) => {
213
+ const apiBaseUrl = apiPathProp ?? config?.apiPath ?? 'http://localhost:3000/invect';
214
+ const basePath = frontendPathProp ?? config?.frontendPath ?? '/invect';
215
+ const resolvedPlugins =
216
+ plugins ??
217
+ config?.plugins
218
+ ?.map((p) => p.frontend)
219
+ .filter((p): p is InvectFrontendPlugin => p !== null && p !== undefined);
202
220
  const client = reactQueryClient || createDefaultQueryClient();
203
221
  const hasRouter = useHasRouterContext();
204
222
 
@@ -208,7 +226,7 @@ export const Invect = React.memo(
208
226
  apiBaseUrl={apiBaseUrl}
209
227
  apiClient={apiClient}
210
228
  basePath={basePath}
211
- plugins={plugins}
229
+ plugins={resolvedPlugins}
212
230
  />
213
231
  );
214
232
 
@@ -0,0 +1,136 @@
1
+ import React from 'react';
2
+ import {
3
+ CommandDialog,
4
+ CommandInput,
5
+ CommandList,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandItem,
9
+ CommandShortcut,
10
+ CommandSeparator,
11
+ } from '~/components/ui/command';
12
+ import { CATEGORY_LABELS, type ShortcutCategory } from './keyboard-shortcuts';
13
+ import {
14
+ Save,
15
+ Play,
16
+ Copy,
17
+ Scissors,
18
+ ClipboardPaste,
19
+ CopyPlus,
20
+ Trash2,
21
+ BoxSelect,
22
+ Maximize,
23
+ ZoomIn,
24
+ ZoomOut,
25
+ PanelLeft,
26
+ Moon,
27
+ Keyboard,
28
+ Search,
29
+ MessageSquare,
30
+ } from 'lucide-react';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Command palette for the flow editor (⌘K)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface CommandPaletteAction {
37
+ id: string;
38
+ label: string;
39
+ description?: string;
40
+ category: ShortcutCategory;
41
+ icon?: React.ReactNode;
42
+ shortcutDisplay?: string;
43
+ onSelect: () => void;
44
+ disabled?: boolean;
45
+ }
46
+
47
+ interface FlowCommandPaletteProps {
48
+ open: boolean;
49
+ onOpenChange: (open: boolean) => void;
50
+ actions: CommandPaletteAction[];
51
+ }
52
+
53
+ /** Icons mapped to shortcut IDs for visual consistency */
54
+ const SHORTCUT_ICONS: Record<string, React.ReactNode> = {
55
+ 'command-palette': <Search className="size-4" />,
56
+ save: <Save className="size-4" />,
57
+ 'execute-flow': <Play className="size-4" />,
58
+ 'show-shortcuts': <Keyboard className="size-4" />,
59
+ copy: <Copy className="size-4" />,
60
+ cut: <Scissors className="size-4" />,
61
+ paste: <ClipboardPaste className="size-4" />,
62
+ duplicate: <CopyPlus className="size-4" />,
63
+ 'delete-selection': <Trash2 className="size-4" />,
64
+ 'select-all': <BoxSelect className="size-4" />,
65
+ 'fit-view': <Maximize className="size-4" />,
66
+ 'zoom-in': <ZoomIn className="size-4" />,
67
+ 'zoom-out': <ZoomOut className="size-4" />,
68
+ 'toggle-sidebar': <PanelLeft className="size-4" />,
69
+ 'toggle-theme': <Moon className="size-4" />,
70
+ 'toggle-chat': <MessageSquare className="size-4" />,
71
+ };
72
+
73
+ export function FlowCommandPalette({ open, onOpenChange, actions }: FlowCommandPaletteProps) {
74
+ // Group actions by category
75
+ const grouped = React.useMemo(() => {
76
+ const groups: Record<ShortcutCategory, CommandPaletteAction[]> = {
77
+ general: [],
78
+ editing: [],
79
+ navigation: [],
80
+ view: [],
81
+ };
82
+ for (const action of actions) {
83
+ groups[action.category].push(action);
84
+ }
85
+ return groups;
86
+ }, [actions]);
87
+
88
+ const handleSelect = React.useCallback(
89
+ (action: CommandPaletteAction) => {
90
+ onOpenChange(false);
91
+ // Defer execution to let the dialog close first
92
+ requestAnimationFrame(() => {
93
+ action.onSelect();
94
+ });
95
+ },
96
+ [onOpenChange],
97
+ );
98
+
99
+ const categoryOrder: ShortcutCategory[] = ['general', 'editing', 'navigation', 'view'];
100
+ const nonEmptyCategories = categoryOrder.filter((cat) => grouped[cat].length > 0);
101
+
102
+ return (
103
+ <CommandDialog
104
+ open={open}
105
+ onOpenChange={onOpenChange}
106
+ title="Command Palette"
107
+ description="Search for commands..."
108
+ >
109
+ <CommandInput placeholder="Type a command or search..." />
110
+ <CommandList>
111
+ <CommandEmpty>No commands found.</CommandEmpty>
112
+ {nonEmptyCategories.map((category, index) => (
113
+ <React.Fragment key={category}>
114
+ {index > 0 && <CommandSeparator />}
115
+ <CommandGroup heading={CATEGORY_LABELS[category]}>
116
+ {grouped[category].map((action) => (
117
+ <CommandItem
118
+ key={action.id}
119
+ value={`${action.label} ${action.description ?? ''}`}
120
+ onSelect={() => handleSelect(action)}
121
+ disabled={action.disabled}
122
+ >
123
+ {action.icon ?? SHORTCUT_ICONS[action.id] ?? null}
124
+ <span>{action.label}</span>
125
+ {action.shortcutDisplay && (
126
+ <CommandShortcut>{action.shortcutDisplay}</CommandShortcut>
127
+ )}
128
+ </CommandItem>
129
+ ))}
130
+ </CommandGroup>
131
+ </React.Fragment>
132
+ ))}
133
+ </CommandList>
134
+ </CommandDialog>
135
+ );
136
+ }
@@ -42,6 +42,9 @@ import { useNodeExecutions } from '~/api/executions.api';
42
42
  import { extractOutputValue } from './node-config-panel/utils';
43
43
  import { nanoid } from 'nanoid';
44
44
  import { useCopyPaste } from './use-copy-paste';
45
+ import { useKeyboardShortcuts } from './use-keyboard-shortcuts';
46
+ import { FlowCommandPalette } from './FlowCommandPalette';
47
+ import { ShortcutsHelpDialog } from './ShortcutsHelpDialog';
45
48
 
46
49
  // Stable references for React Flow - defined at module scope to avoid re-renders
47
50
  const EDGE_TYPES: EdgeTypes = {
@@ -251,6 +254,15 @@ export function FlowWorkbenchView({
251
254
  // Copy/paste/cut/duplicate/delete keyboard shortcuts
252
255
  useCopyPaste({ flowId, reactFlowInstance });
253
256
 
257
+ // Command palette, keyboard shortcuts, and help dialog
258
+ const {
259
+ commandPaletteOpen,
260
+ setCommandPaletteOpen,
261
+ shortcutsHelpOpen,
262
+ setShortcutsHelpOpen,
263
+ commandPaletteActions,
264
+ } = useKeyboardShortcuts();
265
+
254
266
  // Use React Flow's built-in hook to detect when nodes have been initialized
255
267
  // This is true when all nodes have been measured and their handles registered
256
268
  const nodesInitializedFromHook = useNodesInitialized();
@@ -997,6 +1009,12 @@ export function FlowWorkbenchView({
997
1009
  availableTools={availableTools}
998
1010
  initialToolInstanceId={configPanelToolInstanceId}
999
1011
  />
1012
+ <FlowCommandPalette
1013
+ open={commandPaletteOpen}
1014
+ onOpenChange={setCommandPaletteOpen}
1015
+ actions={commandPaletteActions}
1016
+ />
1017
+ <ShortcutsHelpDialog open={shortcutsHelpOpen} onOpenChange={setShortcutsHelpOpen} />
1000
1018
  </>
1001
1019
  );
1002
1020
  }
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogDescription,
8
+ } from '~/components/ui/dialog';
9
+ import {
10
+ CATEGORY_LABELS,
11
+ getShortcutDisplay,
12
+ getShortcutsByCategory,
13
+ type ShortcutCategory,
14
+ } from './keyboard-shortcuts';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Keyboard shortcuts help overlay (? key)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface ShortcutsHelpDialogProps {
21
+ open: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ }
24
+
25
+ export function ShortcutsHelpDialog({ open, onOpenChange }: ShortcutsHelpDialogProps) {
26
+ const grouped = React.useMemo(() => getShortcutsByCategory(), []);
27
+ const categoryOrder: ShortcutCategory[] = ['general', 'editing', 'navigation', 'view'];
28
+
29
+ return (
30
+ <Dialog open={open} onOpenChange={onOpenChange}>
31
+ <DialogHeader className="sr-only">
32
+ <DialogTitle>Keyboard Shortcuts</DialogTitle>
33
+ <DialogDescription>A reference of all available keyboard shortcuts.</DialogDescription>
34
+ </DialogHeader>
35
+ <DialogContent className="max-w-lg" showCloseButton>
36
+ <div className="space-y-4">
37
+ <h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
38
+ {categoryOrder.map((category) => {
39
+ const shortcuts = grouped[category];
40
+ if (shortcuts.length === 0) {
41
+ return null;
42
+ }
43
+ return (
44
+ <div key={category}>
45
+ <h3 className="mb-2 text-xs font-medium tracking-wider uppercase text-muted-foreground">
46
+ {CATEGORY_LABELS[category]}
47
+ </h3>
48
+ <div className="space-y-1">
49
+ {shortcuts.map((shortcut) => (
50
+ <div
51
+ key={shortcut.id}
52
+ className="flex items-center justify-between py-1.5 px-2 rounded-md text-sm"
53
+ >
54
+ <span>{shortcut.label}</span>
55
+ <kbd className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono rounded bg-muted text-muted-foreground border border-border">
56
+ {getShortcutDisplay(shortcut)}
57
+ </kbd>
58
+ </div>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ );
63
+ })}
64
+ </div>
65
+ </DialogContent>
66
+ </Dialog>
67
+ );
68
+ }
@@ -0,0 +1,203 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Keyboard shortcut definitions for the flow editor
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface KeyboardShortcut {
6
+ /** Unique identifier */
7
+ id: string;
8
+ /** Human-readable label shown in command palette and help overlay */
9
+ label: string;
10
+ /** Key combo in react-hotkeys-hook format (e.g. "mod+k", "shift+a") */
11
+ keys: string;
12
+ /** Display string for macOS (e.g. "⌘K") */
13
+ macDisplay: string;
14
+ /** Display string for Windows/Linux (e.g. "Ctrl+K") */
15
+ winDisplay: string;
16
+ /** Category for grouping in the command palette */
17
+ category: ShortcutCategory;
18
+ /** Optional description for the command palette */
19
+ description?: string;
20
+ /** If true, this shortcut is also available when editing text inputs */
21
+ enableOnFormTags?: boolean;
22
+ }
23
+
24
+ export type ShortcutCategory = 'general' | 'editing' | 'navigation' | 'view';
25
+
26
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
27
+
28
+ /** Get the platform-appropriate display string for a shortcut */
29
+ export function getShortcutDisplay(shortcut: KeyboardShortcut): string {
30
+ return isMac ? shortcut.macDisplay : shortcut.winDisplay;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Shortcut definitions
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export const SHORTCUTS = {
38
+ // === General ===
39
+ commandPalette: {
40
+ id: 'command-palette',
41
+ label: 'Open Command Palette',
42
+ keys: 'mod+k',
43
+ macDisplay: '⌘K',
44
+ winDisplay: 'Ctrl+K',
45
+ category: 'general',
46
+ enableOnFormTags: true,
47
+ },
48
+ save: {
49
+ id: 'save',
50
+ label: 'Save Flow',
51
+ keys: 'mod+s',
52
+ macDisplay: '⌘S',
53
+ winDisplay: 'Ctrl+S',
54
+ category: 'general',
55
+ enableOnFormTags: true,
56
+ },
57
+ executeFlow: {
58
+ id: 'execute-flow',
59
+ label: 'Run Flow',
60
+ keys: 'mod+enter',
61
+ macDisplay: '⌘↵',
62
+ winDisplay: 'Ctrl+Enter',
63
+ category: 'general',
64
+ enableOnFormTags: true,
65
+ },
66
+ showShortcuts: {
67
+ id: 'show-shortcuts',
68
+ label: 'Show Keyboard Shortcuts',
69
+ keys: 'shift+/',
70
+ macDisplay: '?',
71
+ winDisplay: '?',
72
+ category: 'general',
73
+ },
74
+
75
+ // === Editing ===
76
+ copy: {
77
+ id: 'copy',
78
+ label: 'Copy Selected Nodes',
79
+ keys: 'mod+c',
80
+ macDisplay: '⌘C',
81
+ winDisplay: 'Ctrl+C',
82
+ category: 'editing',
83
+ },
84
+ cut: {
85
+ id: 'cut',
86
+ label: 'Cut Selected Nodes',
87
+ keys: 'mod+x',
88
+ macDisplay: '⌘X',
89
+ winDisplay: 'Ctrl+X',
90
+ category: 'editing',
91
+ },
92
+ paste: {
93
+ id: 'paste',
94
+ label: 'Paste Nodes',
95
+ keys: 'mod+v',
96
+ macDisplay: '⌘V',
97
+ winDisplay: 'Ctrl+V',
98
+ category: 'editing',
99
+ },
100
+ duplicate: {
101
+ id: 'duplicate',
102
+ label: 'Duplicate Selected Nodes',
103
+ keys: 'mod+d',
104
+ macDisplay: '⌘D',
105
+ winDisplay: 'Ctrl+D',
106
+ category: 'editing',
107
+ },
108
+ deleteSelection: {
109
+ id: 'delete-selection',
110
+ label: 'Delete Selected Nodes',
111
+ keys: 'backspace',
112
+ macDisplay: '⌫',
113
+ winDisplay: 'Delete',
114
+ category: 'editing',
115
+ },
116
+ selectAll: {
117
+ id: 'select-all',
118
+ label: 'Select All Nodes',
119
+ keys: 'mod+a',
120
+ macDisplay: '⌘A',
121
+ winDisplay: 'Ctrl+A',
122
+ category: 'editing',
123
+ },
124
+
125
+ // === Navigation ===
126
+ fitView: {
127
+ id: 'fit-view',
128
+ label: 'Fit View',
129
+ keys: 'mod+shift+f',
130
+ macDisplay: '⌘⇧F',
131
+ winDisplay: 'Ctrl+Shift+F',
132
+ category: 'navigation',
133
+ },
134
+ zoomIn: {
135
+ id: 'zoom-in',
136
+ label: 'Zoom In',
137
+ keys: 'mod+=',
138
+ macDisplay: '⌘+',
139
+ winDisplay: 'Ctrl++',
140
+ category: 'navigation',
141
+ },
142
+ zoomOut: {
143
+ id: 'zoom-out',
144
+ label: 'Zoom Out',
145
+ keys: 'mod+-',
146
+ macDisplay: '⌘−',
147
+ winDisplay: 'Ctrl+-',
148
+ category: 'navigation',
149
+ },
150
+
151
+ // === View ===
152
+ toggleSidebar: {
153
+ id: 'toggle-sidebar',
154
+ label: 'Toggle Node Sidebar',
155
+ keys: 'mod+b',
156
+ macDisplay: '⌘B',
157
+ winDisplay: 'Ctrl+B',
158
+ category: 'view',
159
+ },
160
+ toggleTheme: {
161
+ id: 'toggle-theme',
162
+ label: 'Toggle Dark/Light Mode',
163
+ keys: 'mod+shift+l',
164
+ macDisplay: '⌘⇧L',
165
+ winDisplay: 'Ctrl+Shift+L',
166
+ category: 'view',
167
+ },
168
+ toggleChat: {
169
+ id: 'toggle-chat',
170
+ label: 'Toggle AI Chat Assistant',
171
+ keys: 'mod+shift+a',
172
+ macDisplay: '⌘⇧A',
173
+ winDisplay: 'Ctrl+Shift+A',
174
+ category: 'view',
175
+ },
176
+ } as const satisfies Record<string, KeyboardShortcut>;
177
+
178
+ export type ShortcutId = keyof typeof SHORTCUTS;
179
+
180
+ /** All shortcuts as a flat array, for iteration */
181
+ export const ALL_SHORTCUTS: KeyboardShortcut[] = Object.values(SHORTCUTS);
182
+
183
+ /** Shortcuts grouped by category */
184
+ export function getShortcutsByCategory(): Record<ShortcutCategory, KeyboardShortcut[]> {
185
+ const grouped: Record<ShortcutCategory, KeyboardShortcut[]> = {
186
+ general: [],
187
+ editing: [],
188
+ navigation: [],
189
+ view: [],
190
+ };
191
+ for (const shortcut of ALL_SHORTCUTS) {
192
+ grouped[shortcut.category].push(shortcut);
193
+ }
194
+ return grouped;
195
+ }
196
+
197
+ /** Human-readable category labels */
198
+ export const CATEGORY_LABELS: Record<ShortcutCategory, string> = {
199
+ general: 'General',
200
+ editing: 'Editing',
201
+ navigation: 'Navigation',
202
+ view: 'View',
203
+ };
@@ -155,19 +155,19 @@ function serializeEdge(edge: ClipboardEdge, nodeIdToRef: Map<string, string>): s
155
155
  /**
156
156
  * Convert clipboard nodes and edges into SDK source code text.
157
157
  *
158
- * Returns a string like:
158
+ * Returns a string shaped like a `defineFlow()` body:
159
159
  * ```
160
- * // Nodes
161
- * input('query', { variableName: 'query' }),
162
- *
163
- * model('answer', {
164
- * credentialId: 'cred-abc',
165
- * model: 'gpt-4o-mini',
166
- * prompt: '{{ query }}',
167
- * }),
168
- *
169
- * // Edges
170
- * ['query', 'answer'],
160
+ * nodes: [
161
+ * input('query', { variableName: 'query' }),
162
+ * model('answer', {
163
+ * credentialId: 'cred-abc',
164
+ * model: 'gpt-4o-mini',
165
+ * prompt: '{{ query }}',
166
+ * }),
167
+ * ],
168
+ * edges: [
169
+ * ['query', 'answer'],
170
+ * ],
171
171
  * ```
172
172
  */
173
173
  export function serializeToSDK(nodes: ClipboardNode[], edges: ClipboardEdge[]): string {
@@ -179,25 +179,26 @@ export function serializeToSDK(nodes: ClipboardNode[], edges: ClipboardEdge[]):
179
179
 
180
180
  const parts: string[] = [];
181
181
 
182
- // Nodes section
182
+ // Nodes array
183
183
  if (nodes.length > 0) {
184
- parts.push('// Nodes');
184
+ parts.push('nodes: [');
185
185
  for (const n of nodes) {
186
- parts.push(serializeNode(n) + ',');
187
- parts.push('');
186
+ parts.push(' ' + serializeNode(n) + ',');
188
187
  }
188
+ parts.push('],');
189
189
  }
190
190
 
191
- // Edges section
191
+ // Edges array
192
192
  const serializedEdges = edges
193
193
  .map((e) => serializeEdge(e, nodeIdToRef))
194
194
  .filter((e): e is string => e !== null);
195
195
 
196
196
  if (serializedEdges.length > 0) {
197
- parts.push('// Edges');
197
+ parts.push('edges: [');
198
198
  for (const e of serializedEdges) {
199
- parts.push(e + ',');
199
+ parts.push(' ' + e + ',');
200
200
  }
201
+ parts.push('],');
201
202
  }
202
203
 
203
204
  return parts.join('\n').trimEnd();