@peers-app/peers-ui 0.7.39 → 0.8.0

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.
@@ -127,6 +127,10 @@ export function ContactList() {
127
127
  <i className="bi-person-fill-check me-2" />
128
128
  Contacts
129
129
  </h4>
130
+ <a href="#contacts/connect" className="btn btn-primary btn-sm">
131
+ <i className="bi-person-plus me-1" />
132
+ Connect to New User
133
+ </a>
130
134
  </div>
131
135
 
132
136
  <div className="input-group mb-3">
@@ -1,6 +1,8 @@
1
1
  // Import all contact screen components to ensure they register their routes
2
2
  import './contact-list';
3
3
  import './contact-details';
4
+ import './user-connect';
4
5
 
5
6
  export * from "./contact-list";
6
- export * from "./contact-details";
7
+ export * from "./contact-details";
8
+ export * from "./user-connect";
@@ -0,0 +1,452 @@
1
+ import {
2
+ formatConnectionCode,
3
+ generateConnectionCode,
4
+ generateConfirmationHash,
5
+ getMe,
6
+ getUserContext,
7
+ IUser,
8
+ IUserConnectInfo,
9
+ setUserTrustLevel,
10
+ TrustLevel,
11
+ userConnectCodeOffer,
12
+ userConnectCodeAnswer,
13
+ userConnectStatus,
14
+ Users,
15
+ Devices
16
+ } from "@peers-app/peers-sdk";
17
+ import React, { useState, useEffect, useCallback } from 'react';
18
+ import { mainContentPath } from '../../globals';
19
+ import { useObservable } from '../../hooks';
20
+ import { registerInternalPeersUI } from '../../ui-router/ui-loader';
21
+
22
+ type ConnectionMode = 'select' | 'initiate' | 'respond';
23
+ type ConnectionStatus = 'idle' | 'waiting' | 'success' | 'error';
24
+
25
+ interface ConnectionResult {
26
+ remoteUser: IUser;
27
+ confirmationHash: string;
28
+ }
29
+
30
+ export function UserConnect() {
31
+ const [mode, setMode] = useState<ConnectionMode>('select');
32
+ const [status, setStatus] = useState<ConnectionStatus>('idle');
33
+ const [connectionCode, setConnectionCode] = useState<string>('');
34
+ const [inputCode, setInputCode] = useState<string>('');
35
+ const [result, setResult] = useState<ConnectionResult | null>(null);
36
+ const [error, setError] = useState<string>('');
37
+ const [copied, setCopied] = useState(false);
38
+
39
+ // Subscribe to userConnectStatus from the device layer
40
+ const [connectStatus] = useObservable(userConnectStatus);
41
+
42
+ // Also set up a direct subscription after loading is complete
43
+ useEffect(() => {
44
+ let disposed = false;
45
+ let subscription: { dispose: () => void } | undefined;
46
+
47
+ userConnectStatus.loadingPromise.then(() => {
48
+ if (disposed) return;
49
+ subscription = userConnectStatus.subscribe(() => {
50
+ // Force a re-render by checking the current value
51
+ const currentStatus = userConnectStatus();
52
+ if (currentStatus && typeof currentStatus === 'string') {
53
+ handleConnectStatusChange(currentStatus);
54
+ }
55
+ });
56
+ });
57
+
58
+ return () => {
59
+ disposed = true;
60
+ subscription?.dispose();
61
+ };
62
+ }, []);
63
+
64
+ // Handle connect status changes
65
+ const handleConnectStatusChange = useCallback(async (connectStatusValue: string) => {
66
+ if (!connectStatusValue || status === 'success') return;
67
+
68
+ if (connectStatusValue.startsWith('Error:')) {
69
+ // Error status
70
+ setError(connectStatusValue.replace('Error: ', ''));
71
+ setStatus('error');
72
+ } else if (connectStatusValue.length > 0) {
73
+ // Success - connectStatus is the remote userId
74
+ const remoteUserId = connectStatusValue;
75
+
76
+ try {
77
+ const userContext = await getUserContext();
78
+ const me = await getMe();
79
+ const remoteUser = await Users(userContext.userDataContext).get(remoteUserId);
80
+
81
+ if (!remoteUser) {
82
+ setError('Could not find connected user');
83
+ setStatus('error');
84
+ return;
85
+ }
86
+
87
+ const remoteDevice = await Devices(userContext.userDataContext).findOne({ userId: remoteUserId });
88
+ if (!remoteDevice) {
89
+ setError('Could not find connected device but connection was successful');
90
+ setStatus('error');
91
+ return;
92
+ }
93
+
94
+ // Build user connect info for confirmation hash
95
+ const myInfo: IUserConnectInfo = {
96
+ userId: me.userId,
97
+ publicKey: me.publicKey,
98
+ publicBoxKey: me.publicBoxKey,
99
+ deviceId: userContext.deviceId(),
100
+ };
101
+ const remoteInfo: IUserConnectInfo = {
102
+ userId: remoteUser.userId,
103
+ publicKey: remoteUser.publicKey,
104
+ publicBoxKey: remoteUser.publicBoxKey,
105
+ deviceId: remoteDevice.deviceId,
106
+ };
107
+
108
+ const confirmationHash = generateConfirmationHash(myInfo, remoteInfo);
109
+
110
+ setResult({ remoteUser, confirmationHash });
111
+ setStatus('success');
112
+
113
+ // Clear the codes
114
+ userConnectCodeOffer('');
115
+ userConnectCodeAnswer('');
116
+ } catch (err: any) {
117
+ setError(err.message || 'Failed to complete connection');
118
+ setStatus('error');
119
+ }
120
+ }
121
+ }, [status]);
122
+
123
+ // React to userConnectStatus changes from useObservable
124
+ useEffect(() => {
125
+ if (connectStatus && typeof connectStatus === 'string') {
126
+ handleConnectStatusChange(connectStatus);
127
+ }
128
+ }, [connectStatus, handleConnectStatusChange]);
129
+
130
+ // Clean up on unmount
131
+ useEffect(() => {
132
+ return () => {
133
+ if (mode === 'initiate' && status === 'waiting') {
134
+ userConnectCodeOffer('');
135
+ userConnectCodeAnswer('');
136
+ }
137
+ };
138
+ }, [mode, status]);
139
+
140
+ const handleInitiate = useCallback(async () => {
141
+ setMode('initiate');
142
+ setStatus('waiting');
143
+ setError('');
144
+ userConnectStatus(''); // Clear any previous status
145
+
146
+ // Generate the connection code
147
+ const code = generateConnectionCode();
148
+ userConnectCodeOffer(code.code);
149
+ const formattedCode = formatConnectionCode(code.code);
150
+ setConnectionCode(formattedCode);
151
+ }, []);
152
+
153
+ const handleRespond = useCallback(async () => {
154
+ if (inputCode.replace(/[^0-9A-Za-z]/g, '').length !== 12) {
155
+ setError('Please enter a valid 12-character connection code');
156
+ return;
157
+ }
158
+
159
+ setStatus('waiting');
160
+ setError('');
161
+ userConnectStatus(''); // Clear any previous status
162
+ userConnectCodeAnswer(inputCode);
163
+ }, [inputCode]);
164
+
165
+ const handleCancel = useCallback(async () => {
166
+ userConnectCodeOffer('');
167
+ userConnectCodeAnswer('');
168
+ userConnectStatus('');
169
+ setMode('select');
170
+ setStatus('idle');
171
+ setConnectionCode('');
172
+ setError('');
173
+ }, []);
174
+
175
+ const handleReset = useCallback(() => {
176
+ userConnectCodeOffer('');
177
+ userConnectCodeAnswer('');
178
+ userConnectStatus('');
179
+ setMode('select');
180
+ setStatus('idle');
181
+ setConnectionCode('');
182
+ setInputCode('');
183
+ setResult(null);
184
+ setError('');
185
+ setCopied(false);
186
+ }, []);
187
+
188
+ const handleSaveContact = useCallback(async () => {
189
+ if (!result) return;
190
+
191
+ try {
192
+ const userContext = await getUserContext();
193
+
194
+ // Contact is already saved by the device layer, just set trust level
195
+ await setUserTrustLevel(result.remoteUser.userId, TrustLevel.Trusted, userContext.userDataContext);
196
+
197
+ // Navigate to contact details
198
+ mainContentPath(`contacts/${result.remoteUser.userId}`);
199
+ } catch (err: any) {
200
+ setError(err.message || 'Failed to save contact');
201
+ }
202
+ }, [result]);
203
+
204
+ // Copy connection code to clipboard
205
+ const handleCopyCode = useCallback(async () => {
206
+ if (!connectionCode) return;
207
+ try {
208
+ await navigator.clipboard.writeText(connectionCode);
209
+ setCopied(true);
210
+ setTimeout(() => setCopied(false), 2000);
211
+ } catch (err) {
212
+ // Fallback for older browsers
213
+ const textArea = document.createElement('textarea');
214
+ textArea.value = connectionCode;
215
+ document.body.appendChild(textArea);
216
+ textArea.select();
217
+ document.execCommand('copy');
218
+ document.body.removeChild(textArea);
219
+ setCopied(true);
220
+ setTimeout(() => setCopied(false), 2000);
221
+ }
222
+ }, [connectionCode]);
223
+
224
+ // Format input code as user types
225
+ const handleCodeInput = (value: string) => {
226
+ // Remove non-alphanumeric characters
227
+ const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, '');
228
+
229
+ // Format as XXXX-YYYY-ZZZZ
230
+ let formatted = '';
231
+ for (let i = 0; i < cleaned.length && i < 12; i++) {
232
+ if (i === 4 || i === 8) formatted += '-';
233
+ formatted += cleaned[i];
234
+ }
235
+
236
+ setInputCode(formatted);
237
+ };
238
+
239
+ return (
240
+ <div className="container-fluid p-3">
241
+ <div className="d-flex justify-content-between align-items-center mb-4">
242
+ <h4>
243
+ <i className="bi-person-plus-fill me-2" />
244
+ Connect to New User
245
+ </h4>
246
+ {mode !== 'select' && (
247
+ <button className="btn btn-outline-secondary btn-sm" onClick={handleReset}>
248
+ <i className="bi-arrow-left me-1" />
249
+ Back
250
+ </button>
251
+ )}
252
+ </div>
253
+
254
+ {/* Mode Selection */}
255
+ {mode === 'select' && (
256
+ <div className="row g-3">
257
+ <div className="col-md-6">
258
+ <div
259
+ className="card h-100 border-primary"
260
+ style={{ cursor: 'pointer' }}
261
+ onClick={handleInitiate}
262
+ >
263
+ <div className="card-body text-center p-4">
264
+ <i className="bi-qr-code display-4 text-primary mb-3" />
265
+ <h5 className="card-title">Create Connection Code</h5>
266
+ <p className="card-text text-muted">
267
+ Generate a code to share with someone who wants to connect with you
268
+ </p>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ <div className="col-md-6">
273
+ <div
274
+ className="card h-100 border-success"
275
+ style={{ cursor: 'pointer' }}
276
+ onClick={() => setMode('respond')}
277
+ >
278
+ <div className="card-body text-center p-4">
279
+ <i className="bi-keyboard display-4 text-success mb-3" />
280
+ <h5 className="card-title">Enter Connection Code</h5>
281
+ <p className="card-text text-muted">
282
+ Enter a code that someone shared with you to connect
283
+ </p>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ )}
289
+
290
+ {/* Initiate Mode - Show Generated Code */}
291
+ {mode === 'initiate' && status === 'waiting' && (
292
+ <div className="text-center">
293
+ <div className="mb-4">
294
+ <p className="text-muted">Share this code with the person you want to connect with:</p>
295
+ </div>
296
+
297
+ <div className="mb-4" style={{ maxWidth: '400px', margin: '0 auto' }}>
298
+ <div className="input-group">
299
+ <input
300
+ type="text"
301
+ className="form-control form-control-lg text-center font-monospace"
302
+ value={connectionCode || 'XXXX-YYYY-ZZZZ'}
303
+ readOnly
304
+ style={{ letterSpacing: '0.15em', fontSize: '1.5rem' }}
305
+ />
306
+ <button
307
+ className="btn btn-outline-primary"
308
+ onClick={handleCopyCode}
309
+ title="Copy to clipboard"
310
+ >
311
+ <i className={copied ? "bi-check-lg" : "bi-clipboard"} />
312
+ </button>
313
+ </div>
314
+ {copied && (
315
+ <small className="text-success mt-1 d-block">Copied!</small>
316
+ )}
317
+ </div>
318
+
319
+ <div className="mb-4">
320
+ <div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
321
+ <span className="text-muted">Waiting for connection...</span>
322
+ </div>
323
+
324
+ <p className="text-muted small">
325
+ This code will expire in 10 minutes
326
+ </p>
327
+
328
+ <button className="btn btn-outline-secondary" onClick={handleCancel}>
329
+ Cancel
330
+ </button>
331
+ </div>
332
+ )}
333
+
334
+ {/* Respond Mode - Enter Code */}
335
+ {mode === 'respond' && status !== 'success' && (
336
+ <div className="text-center">
337
+ <div className="mb-4">
338
+ <p className="text-muted">Enter the connection code shared with you:</p>
339
+ </div>
340
+
341
+ <div className="mb-4" style={{ maxWidth: '400px', margin: '0 auto' }}>
342
+ <input
343
+ type="text"
344
+ className="form-control form-control-lg text-center font-monospace"
345
+ placeholder="XXXX-YYYY-ZZZZ"
346
+ value={inputCode}
347
+ onChange={(e) => handleCodeInput(e.target.value)}
348
+ maxLength={14}
349
+ style={{ letterSpacing: '0.15em', fontSize: '1.5rem' }}
350
+ autoFocus
351
+ />
352
+ </div>
353
+
354
+ {error && (
355
+ <div className="alert alert-danger" style={{ maxWidth: '400px', margin: '0 auto 1rem' }}>
356
+ {error}
357
+ </div>
358
+ )}
359
+
360
+ <button
361
+ className="btn btn-primary btn-lg"
362
+ onClick={handleRespond}
363
+ disabled={status === 'waiting' || inputCode.replace(/[^0-9A-Z]/gi, '').length !== 12}
364
+ >
365
+ {status === 'waiting' ? (
366
+ <>
367
+ <span className="spinner-border spinner-border-sm me-2" role="status" />
368
+ Connecting...
369
+ </>
370
+ ) : (
371
+ <>
372
+ <i className="bi-link-45deg me-2" />
373
+ Connect
374
+ </>
375
+ )}
376
+ </button>
377
+ </div>
378
+ )}
379
+
380
+ {/* Success State */}
381
+ {status === 'success' && result && (
382
+ <div className="text-center">
383
+ <div className="mb-4">
384
+ <i className="bi-check-circle-fill text-success display-3" />
385
+ </div>
386
+
387
+ <h5 className="mb-4">Connection Successful!</h5>
388
+
389
+ <div className="card mb-4" style={{ maxWidth: '400px', margin: '0 auto' }}>
390
+ <div className="card-body">
391
+ <p className="text-muted mb-2">Verify with the other person that you both see:</p>
392
+ <h3 className="font-monospace text-primary mb-3" style={{ letterSpacing: '0.2em' }}>
393
+ {result.confirmationHash}
394
+ </h3>
395
+
396
+ <hr />
397
+
398
+ <div className="text-start">
399
+ <small className="text-muted">Name:</small>
400
+ <p className="mb-2">{result.remoteUser.name}</p>
401
+
402
+ <small className="text-muted">User ID:</small>
403
+ <p className="font-monospace small mb-0">{result.remoteUser.userId}</p>
404
+ </div>
405
+ </div>
406
+ </div>
407
+
408
+ <div className="d-flex gap-2 justify-content-center">
409
+ <button className="btn btn-primary" onClick={handleSaveContact}>
410
+ <i className="bi-check-lg me-2" />
411
+ Trust & View Contact
412
+ </button>
413
+ <button className="btn btn-outline-secondary" onClick={handleReset}>
414
+ Connect Another
415
+ </button>
416
+ </div>
417
+ </div>
418
+ )}
419
+
420
+ {/* Error State */}
421
+ {status === 'error' && (
422
+ <div className="text-center">
423
+ <div className="mb-4">
424
+ <i className="bi-x-circle-fill text-danger display-3" />
425
+ </div>
426
+
427
+ <h5 className="mb-4">Connection Failed</h5>
428
+
429
+ <div className="alert alert-danger" style={{ maxWidth: '400px', margin: '0 auto 1rem' }}>
430
+ {error}
431
+ </div>
432
+
433
+ <button className="btn btn-primary" onClick={handleReset}>
434
+ Try Again
435
+ </button>
436
+ </div>
437
+ )}
438
+ </div>
439
+ );
440
+ }
441
+
442
+ registerInternalPeersUI({
443
+ peersUIId: '000user00connect0screen01',
444
+ component: UserConnect,
445
+ routes: [
446
+ {
447
+ isMatch: (props, context) => context.path === 'contacts/connect',
448
+ uiCategory: 'screen',
449
+ priority: 3
450
+ }
451
+ ]
452
+ });
@@ -12,7 +12,9 @@ import {
12
12
  valueTypeMentionConfig,
13
13
  IMentionConfig
14
14
  } from '../../mention-configs';
15
- import { IMentionData } from "@peers-app/peers-sdk";
15
+ import { IMentionData, IAppNav } from "@peers-app/peers-sdk";
16
+ import { allPackages } from '../../ui-router/routes-loader';
17
+ import { systemPackage } from '../../system-apps';
16
18
 
17
19
  interface SearchResult {
18
20
  config: IMentionConfig;
@@ -20,10 +22,22 @@ interface SearchResult {
20
22
  category: string;
21
23
  }
22
24
 
25
+ interface AppSearchItem {
26
+ packageId: string;
27
+ packageName: string;
28
+ navItem: IAppNav;
29
+ path: string;
30
+ name: string;
31
+ displayName: string;
32
+ iconClassName: string;
33
+ }
34
+
23
35
  export function GlobalSearch() {
24
36
  const [_colorMode] = useObservable(colorMode);
37
+ const [packages] = useObservable(allPackages);
25
38
  const [searchQuery, setSearchQuery] = useState('');
26
39
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
40
+ const [appResults, setAppResults] = useState<AppSearchItem[]>([]);
27
41
  const [isSearching, setIsSearching] = useState(false);
28
42
  const inputRef = useRef<HTMLInputElement>(null);
29
43
 
@@ -47,10 +61,42 @@ export function GlobalSearch() {
47
61
  { config: userMentionConfig, category: 'Users', navigationPath: 'profile' },
48
62
  ];
49
63
 
64
+ // Get all apps (system and user)
65
+ const getAllApps = (): AppSearchItem[] => {
66
+ const allPackages_ = [...packages, systemPackage];
67
+ return allPackages_
68
+ .filter(p => !p.disabled && p.appNavs && p.appNavs.length > 0)
69
+ .flatMap(pkg =>
70
+ pkg.appNavs!.map(navItem => {
71
+ // Construct path - use direct path for system apps, package-nav for others
72
+ let path: string;
73
+ if (pkg.packageId === 'system-apps') {
74
+ path = navItem.navigationPath ?? navItem.name.replace(/\s/g, '-').toLowerCase();
75
+ } else {
76
+ path = `package-nav/${pkg.packageId}/${(navItem.navigationPath ?? navItem.name).replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
77
+ while (path.includes('//')) {
78
+ path = path.replace('//', '/');
79
+ }
80
+ }
81
+
82
+ return {
83
+ packageId: pkg.packageId,
84
+ packageName: pkg.name,
85
+ navItem,
86
+ path,
87
+ name: navItem.name,
88
+ displayName: navItem.displayName || navItem.name,
89
+ iconClassName: navItem.iconClassName || 'bi-box-seam'
90
+ };
91
+ })
92
+ );
93
+ };
94
+
50
95
  // Debounced search effect
51
96
  useEffect(() => {
52
97
  if (!searchQuery.trim()) {
53
98
  setSearchResults([]);
99
+ setAppResults([]);
54
100
  return;
55
101
  }
56
102
 
@@ -75,13 +121,23 @@ export function GlobalSearch() {
75
121
  const filteredResults = searchResults.filter(Boolean) as SearchResult[];
76
122
 
77
123
  setSearchResults(filteredResults);
124
+
125
+ // Search apps
126
+ const allApps = getAllApps();
127
+ const lowerQuery = searchQuery.toLowerCase();
128
+ const filteredApps = allApps.filter(app =>
129
+ app.name.toLowerCase().includes(lowerQuery) ||
130
+ app.displayName.toLowerCase().includes(lowerQuery) ||
131
+ app.packageName.toLowerCase().includes(lowerQuery)
132
+ );
133
+ setAppResults(filteredApps);
78
134
  } finally {
79
135
  setIsSearching(false);
80
136
  }
81
137
  }, 300); // 300ms debounce
82
138
 
83
139
  return () => clearTimeout(timeoutId);
84
- }, [searchQuery]);
140
+ }, [searchQuery, packages]);
85
141
 
86
142
  const handleItemClick = (result: SearchResult, item: IMentionData) => {
87
143
  // Try using the config's onClick first
@@ -103,7 +159,11 @@ export function GlobalSearch() {
103
159
  }
104
160
  };
105
161
 
106
- const totalResults = searchResults.reduce((sum, result) => sum + result.items.length, 0);
162
+ const handleAppClick = (app: AppSearchItem) => {
163
+ goToTabPath(app.path);
164
+ };
165
+
166
+ const totalResults = searchResults.reduce((sum, result) => sum + result.items.length, 0) + appResults.length;
107
167
 
108
168
  return (
109
169
  <div className="container-fluid h-100 p-4" style={{ maxHeight: '100vh', overflowY: 'auto' }}>
@@ -131,7 +191,7 @@ export function GlobalSearch() {
131
191
  ref={inputRef}
132
192
  type="text"
133
193
  className={`form-control form-control-lg ${isDark ? 'bg-dark text-light border-secondary' : ''}`}
134
- placeholder="Search across tools, assistants, workflows, events, and more..."
194
+ placeholder="Search across apps, tools, assistants, workflows, events, and more..."
135
195
  value={searchQuery}
136
196
  onChange={(e) => setSearchQuery(e.target.value)}
137
197
  style={{
@@ -162,7 +222,7 @@ export function GlobalSearch() {
162
222
  {isSearching ? (
163
223
  'Searching...'
164
224
  ) : totalResults > 0 ? (
165
- `Found ${totalResults} result${totalResults !== 1 ? 's' : ''} across ${searchResults.length} categor${searchResults.length !== 1 ? 'ies' : 'y'}`
225
+ `Found ${totalResults} result${totalResults !== 1 ? 's' : ''}`
166
226
  ) : searchQuery.trim() ? (
167
227
  'No results found'
168
228
  ) : null}
@@ -173,7 +233,7 @@ export function GlobalSearch() {
173
233
  {/* Search Results */}
174
234
  {searchQuery && !isSearching && (
175
235
  <div>
176
- {searchResults.length === 0 ? (
236
+ {searchResults.length === 0 && appResults.length === 0 ? (
177
237
  <div className="text-center py-5">
178
238
  <i className="bi-search mb-3 d-block text-muted" style={{ fontSize: '48px' }} />
179
239
  <h4 className="text-muted">No results found</h4>
@@ -183,6 +243,65 @@ export function GlobalSearch() {
183
243
  </div>
184
244
  ) : (
185
245
  <div>
246
+ {/* Apps Section */}
247
+ {appResults.length > 0 && (
248
+ <div className="mb-5">
249
+ <div className="d-flex align-items-center mb-3">
250
+ <i className="bi-grid-3x3-gap me-3" style={{ fontSize: '20px', color: '#6c757d' }} />
251
+ <h4 className="mb-0 me-3">Apps</h4>
252
+ <span className="badge bg-secondary">{appResults.length}</span>
253
+ </div>
254
+ <div className="row g-3">
255
+ {appResults.map((app) => (
256
+ <div key={`${app.packageId}-${app.path}`} className="col-12 col-md-6 col-lg-4">
257
+ <div
258
+ className={`card h-100 ${isDark ? 'bg-dark border-secondary' : 'bg-light'}`}
259
+ style={{
260
+ cursor: 'pointer',
261
+ transition: 'all 0.15s ease',
262
+ borderRadius: '8px'
263
+ }}
264
+ onClick={() => handleAppClick(app)}
265
+ onMouseEnter={(e) => {
266
+ e.currentTarget.style.transform = 'translateY(-2px)';
267
+ e.currentTarget.style.boxShadow = isDark
268
+ ? '0 4px 12px rgba(0,0,0,0.3)'
269
+ : '0 4px 12px rgba(0,0,0,0.1)';
270
+ }}
271
+ onMouseLeave={(e) => {
272
+ e.currentTarget.style.transform = 'translateY(0)';
273
+ e.currentTarget.style.boxShadow = 'none';
274
+ }}
275
+ >
276
+ <div className="card-body p-3">
277
+ <div className="d-flex align-items-start">
278
+ <i
279
+ className={`${app.iconClassName} me-3 mt-1`}
280
+ style={{
281
+ fontSize: '16px',
282
+ color: isDark ? '#0d6efd' : '#0d6efd',
283
+ minWidth: '16px'
284
+ }}
285
+ />
286
+ <div className="flex-grow-1">
287
+ <h6 className="card-title mb-1 fw-medium">
288
+ {app.displayName}
289
+ </h6>
290
+ <small className="text-muted text-uppercase" style={{ fontSize: '11px', letterSpacing: '0.5px' }}>
291
+ {app.packageId === 'system-apps' ? 'System App' : app.packageName}
292
+ </small>
293
+ </div>
294
+ <i className="bi-arrow-right text-muted" style={{ fontSize: '12px' }} />
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ ))}
300
+ </div>
301
+ </div>
302
+ )}
303
+
304
+ {/* Entity Results */}
186
305
  {searchResults.map((result) => (
187
306
  <div key={result.category} className="mb-5">
188
307
  {/* Category Header */}
@@ -253,7 +372,7 @@ export function GlobalSearch() {
253
372
  <i className="bi-search mb-3 d-block text-muted" style={{ fontSize: '64px' }} />
254
373
  <h3 className="text-muted mb-3">Search across everything</h3>
255
374
  <p className="text-muted mb-4" style={{ maxWidth: '400px', margin: '0 auto' }}>
256
- Find tools, assistants, workflows, events, predicates, types, and users all in one place.
375
+ Find apps, tools, assistants, workflows, events, predicates, types, and users all in one place.
257
376
  </p>
258
377
  <div className="d-flex flex-wrap justify-content-center gap-2">
259
378
  {searchConfigs.map(({ config, category }) => (