agent-hustle-demo 1.0.1

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.
Files changed (60) hide show
  1. package/README.md +429 -0
  2. package/dist/HustleChat-BC9wvWVA.d.ts +90 -0
  3. package/dist/HustleChat-BcrKkkyn.d.cts +90 -0
  4. package/dist/browser/hustle-react.js +14854 -0
  5. package/dist/browser/hustle-react.js.map +1 -0
  6. package/dist/components/index.cjs +3141 -0
  7. package/dist/components/index.cjs.map +1 -0
  8. package/dist/components/index.d.cts +20 -0
  9. package/dist/components/index.d.ts +20 -0
  10. package/dist/components/index.js +3112 -0
  11. package/dist/components/index.js.map +1 -0
  12. package/dist/hooks/index.cjs +845 -0
  13. package/dist/hooks/index.cjs.map +1 -0
  14. package/dist/hooks/index.d.cts +6 -0
  15. package/dist/hooks/index.d.ts +6 -0
  16. package/dist/hooks/index.js +838 -0
  17. package/dist/hooks/index.js.map +1 -0
  18. package/dist/hustle-Kj0X8qXC.d.cts +193 -0
  19. package/dist/hustle-Kj0X8qXC.d.ts +193 -0
  20. package/dist/index-ChUsRBwL.d.ts +152 -0
  21. package/dist/index-DE1N7C3W.d.cts +152 -0
  22. package/dist/index-DuPFrMZy.d.cts +214 -0
  23. package/dist/index-kFIdHjNw.d.ts +214 -0
  24. package/dist/index.cjs +3746 -0
  25. package/dist/index.cjs.map +1 -0
  26. package/dist/index.d.cts +271 -0
  27. package/dist/index.d.ts +271 -0
  28. package/dist/index.js +3697 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/providers/index.cjs +844 -0
  31. package/dist/providers/index.cjs.map +1 -0
  32. package/dist/providers/index.d.cts +5 -0
  33. package/dist/providers/index.d.ts +5 -0
  34. package/dist/providers/index.js +838 -0
  35. package/dist/providers/index.js.map +1 -0
  36. package/package.json +80 -0
  37. package/src/components/AuthStatus.tsx +352 -0
  38. package/src/components/ConnectButton.tsx +421 -0
  39. package/src/components/HustleChat.tsx +1273 -0
  40. package/src/components/MarkdownContent.tsx +431 -0
  41. package/src/components/index.ts +15 -0
  42. package/src/hooks/index.ts +40 -0
  43. package/src/hooks/useEmblemAuth.ts +27 -0
  44. package/src/hooks/useHustle.ts +36 -0
  45. package/src/hooks/usePlugins.ts +135 -0
  46. package/src/index.ts +142 -0
  47. package/src/plugins/index.ts +48 -0
  48. package/src/plugins/migrateFun.ts +211 -0
  49. package/src/plugins/predictionMarket.ts +411 -0
  50. package/src/providers/EmblemAuthProvider.tsx +319 -0
  51. package/src/providers/HustleProvider.tsx +540 -0
  52. package/src/providers/index.ts +6 -0
  53. package/src/styles/index.ts +2 -0
  54. package/src/styles/tokens.ts +447 -0
  55. package/src/types/auth.ts +85 -0
  56. package/src/types/hustle.ts +217 -0
  57. package/src/types/index.ts +49 -0
  58. package/src/types/plugin.ts +180 -0
  59. package/src/utils/index.ts +122 -0
  60. package/src/utils/pluginRegistry.ts +375 -0
@@ -0,0 +1,1273 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
4
+ import { useHustle } from '../providers/HustleProvider';
5
+ import { useEmblemAuth } from '../providers/EmblemAuthProvider';
6
+ import { usePlugins } from '../hooks/usePlugins';
7
+ import { availablePlugins } from '../plugins';
8
+ import { tokens, presets, animations } from '../styles';
9
+ import { MarkdownContent } from './MarkdownContent';
10
+ import type { ChatMessage, StreamChunk, ToolCall, Attachment } from '../types';
11
+
12
+ // ============================================================================
13
+ // Styles using design tokens
14
+ // ============================================================================
15
+ const styles = {
16
+ // Container
17
+ container: {
18
+ display: 'flex',
19
+ flexDirection: 'column' as const,
20
+ height: '100%',
21
+ background: tokens.colors.bgSecondary,
22
+ borderRadius: tokens.radius.xl,
23
+ border: `1px solid ${tokens.colors.borderPrimary}`,
24
+ fontFamily: tokens.typography.fontFamily,
25
+ color: tokens.colors.textPrimary,
26
+ },
27
+
28
+ // Not ready / auth required states
29
+ placeholder: {
30
+ display: 'flex',
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ padding: tokens.spacing.xxl,
34
+ background: tokens.colors.bgSecondary,
35
+ borderRadius: tokens.radius.xl,
36
+ border: `1px solid ${tokens.colors.borderPrimary}`,
37
+ },
38
+ placeholderContent: {
39
+ textAlign: 'center' as const,
40
+ color: tokens.colors.textSecondary,
41
+ },
42
+ placeholderTitle: {
43
+ fontSize: tokens.typography.fontSizeLg,
44
+ fontWeight: tokens.typography.fontWeightMedium,
45
+ marginBottom: tokens.spacing.xs,
46
+ },
47
+ placeholderText: {
48
+ fontSize: tokens.typography.fontSizeSm,
49
+ color: tokens.colors.textTertiary,
50
+ },
51
+ loadingSpinner: {
52
+ display: 'inline-block',
53
+ width: '24px',
54
+ height: '24px',
55
+ border: `2px solid ${tokens.colors.textTertiary}`,
56
+ borderTopColor: 'transparent',
57
+ borderRadius: tokens.radius.full,
58
+ animation: 'hustle-spin 0.8s linear infinite',
59
+ marginBottom: tokens.spacing.sm,
60
+ },
61
+
62
+ // Header - darker shade
63
+ header: {
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'space-between',
67
+ padding: `${tokens.spacing.md} ${tokens.spacing.lg}`,
68
+ background: tokens.colors.bgPrimary,
69
+ borderBottom: `1px solid ${tokens.colors.borderPrimary}`,
70
+ borderRadius: `${tokens.radius.xl} ${tokens.radius.xl} 0 0`,
71
+ },
72
+ headerTitle: {
73
+ fontWeight: tokens.typography.fontWeightSemibold,
74
+ color: tokens.colors.textPrimary,
75
+ fontSize: tokens.typography.fontSizeMd,
76
+ },
77
+ headerActions: {
78
+ display: 'flex',
79
+ alignItems: 'center',
80
+ gap: tokens.spacing.sm,
81
+ },
82
+
83
+ // Model selector
84
+ select: {
85
+ fontSize: tokens.typography.fontSizeSm,
86
+ padding: `${tokens.spacing.xs} ${tokens.spacing.sm}`,
87
+ border: `1px solid ${tokens.colors.borderSecondary}`,
88
+ borderRadius: tokens.radius.md,
89
+ background: tokens.colors.bgTertiary,
90
+ color: tokens.colors.textPrimary,
91
+ outline: 'none',
92
+ },
93
+
94
+ // Settings button
95
+ settingsBtn: {
96
+ ...presets.buttonIcon,
97
+ borderRadius: tokens.radius.md,
98
+ } as React.CSSProperties,
99
+ settingsBtnActive: {
100
+ background: tokens.colors.accentPrimaryBg,
101
+ color: tokens.colors.accentPrimary,
102
+ },
103
+ settingsBtnInactive: {
104
+ color: tokens.colors.textSecondary,
105
+ },
106
+
107
+ // Settings Modal
108
+ modalOverlay: {
109
+ position: 'fixed' as const,
110
+ top: 0,
111
+ left: 0,
112
+ right: 0,
113
+ bottom: 0,
114
+ background: tokens.colors.bgOverlay,
115
+ display: 'flex',
116
+ alignItems: 'center',
117
+ justifyContent: 'center',
118
+ zIndex: tokens.zIndex.modal,
119
+ },
120
+ modal: {
121
+ background: tokens.colors.bgSecondary,
122
+ borderRadius: tokens.radius.xl,
123
+ border: `1px solid ${tokens.colors.borderPrimary}`,
124
+ width: '100%',
125
+ maxWidth: '440px',
126
+ maxHeight: '90vh',
127
+ overflow: 'auto',
128
+ boxShadow: tokens.shadows.xl,
129
+ },
130
+ modalHeader: {
131
+ display: 'flex',
132
+ alignItems: 'center',
133
+ justifyContent: 'space-between',
134
+ padding: `${tokens.spacing.lg} ${tokens.spacing.xl}`,
135
+ borderBottom: `1px solid ${tokens.colors.borderPrimary}`,
136
+ },
137
+ modalTitle: {
138
+ fontSize: tokens.typography.fontSizeLg,
139
+ fontWeight: tokens.typography.fontWeightSemibold,
140
+ color: tokens.colors.textPrimary,
141
+ },
142
+ modalClose: {
143
+ background: 'transparent',
144
+ border: 'none',
145
+ color: tokens.colors.textTertiary,
146
+ fontSize: '20px',
147
+ cursor: 'pointer',
148
+ padding: tokens.spacing.xs,
149
+ lineHeight: 1,
150
+ transition: `color ${tokens.transitions.fast}`,
151
+ },
152
+ modalBody: {
153
+ padding: tokens.spacing.xl,
154
+ },
155
+
156
+ // Settings sections
157
+ settingGroup: {
158
+ marginBottom: tokens.spacing.xl,
159
+ },
160
+ settingLabel: {
161
+ display: 'block',
162
+ fontSize: tokens.typography.fontSizeMd,
163
+ fontWeight: tokens.typography.fontWeightMedium,
164
+ color: tokens.colors.textPrimary,
165
+ marginBottom: tokens.spacing.xs,
166
+ },
167
+ settingDescription: {
168
+ fontSize: tokens.typography.fontSizeSm,
169
+ color: tokens.colors.textTertiary,
170
+ marginBottom: tokens.spacing.md,
171
+ },
172
+ settingSelect: {
173
+ width: '100%',
174
+ padding: `${tokens.spacing.md} ${tokens.spacing.lg}`,
175
+ fontSize: tokens.typography.fontSizeMd,
176
+ background: tokens.colors.bgTertiary,
177
+ border: `1px solid ${tokens.colors.borderSecondary}`,
178
+ borderRadius: tokens.radius.lg,
179
+ color: tokens.colors.textPrimary,
180
+ outline: 'none',
181
+ cursor: 'pointer',
182
+ appearance: 'none' as const,
183
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238892a4' d='M6 8L1 3h10z'/%3E%3C/svg%3E")`,
184
+ backgroundRepeat: 'no-repeat',
185
+ backgroundPosition: 'right 12px center',
186
+ paddingRight: '36px',
187
+ },
188
+ modelInfo: {
189
+ fontSize: tokens.typography.fontSizeXs,
190
+ color: tokens.colors.textTertiary,
191
+ marginTop: tokens.spacing.sm,
192
+ },
193
+
194
+ // Toggle switch row
195
+ toggleRow: {
196
+ display: 'flex',
197
+ alignItems: 'center',
198
+ justifyContent: 'space-between',
199
+ padding: `${tokens.spacing.md} ${tokens.spacing.lg}`,
200
+ background: tokens.colors.bgTertiary,
201
+ borderRadius: tokens.radius.lg,
202
+ marginBottom: tokens.spacing.sm,
203
+ },
204
+ toggleLabel: {
205
+ fontSize: tokens.typography.fontSizeMd,
206
+ color: tokens.colors.textPrimary,
207
+ },
208
+ toggleSwitch: {
209
+ position: 'relative' as const,
210
+ width: '44px',
211
+ height: '24px',
212
+ background: tokens.colors.borderSecondary,
213
+ borderRadius: '12px',
214
+ cursor: 'pointer',
215
+ transition: `background ${tokens.transitions.fast}`,
216
+ },
217
+ toggleSwitchActive: {
218
+ background: tokens.colors.accentPrimary,
219
+ },
220
+ toggleKnob: {
221
+ position: 'absolute' as const,
222
+ top: '2px',
223
+ left: '2px',
224
+ width: '20px',
225
+ height: '20px',
226
+ background: tokens.colors.textPrimary,
227
+ borderRadius: tokens.radius.full,
228
+ transition: `transform ${tokens.transitions.fast}`,
229
+ },
230
+ toggleKnobActive: {
231
+ transform: 'translateX(20px)',
232
+ },
233
+
234
+ // Settings textarea
235
+ settingTextarea: {
236
+ width: '100%',
237
+ minHeight: '100px',
238
+ padding: tokens.spacing.lg,
239
+ fontSize: tokens.typography.fontSizeMd,
240
+ background: tokens.colors.bgTertiary,
241
+ border: `1px solid ${tokens.colors.borderSecondary}`,
242
+ borderRadius: tokens.radius.lg,
243
+ color: tokens.colors.textPrimary,
244
+ outline: 'none',
245
+ resize: 'vertical' as const,
246
+ fontFamily: tokens.typography.fontFamily,
247
+ },
248
+
249
+ // Messages area
250
+ messagesArea: {
251
+ flex: 1,
252
+ overflowY: 'auto' as const,
253
+ padding: tokens.spacing.lg,
254
+ background: tokens.colors.bgSecondary,
255
+ },
256
+ messagesEmpty: {
257
+ textAlign: 'center' as const,
258
+ color: tokens.colors.textTertiary,
259
+ padding: tokens.spacing.xxl,
260
+ },
261
+ messagesContainer: {
262
+ display: 'flex',
263
+ flexDirection: 'column' as const,
264
+ gap: tokens.spacing.lg,
265
+ },
266
+
267
+ // Tool calls indicator
268
+ toolCallsIndicator: {
269
+ display: 'flex',
270
+ flexWrap: 'wrap' as const,
271
+ gap: tokens.spacing.sm,
272
+ padding: `0 ${tokens.spacing.lg}`,
273
+ },
274
+ toolCallBadge: {
275
+ display: 'inline-flex',
276
+ alignItems: 'center',
277
+ gap: tokens.spacing.xs,
278
+ padding: `${tokens.spacing.xs} ${tokens.spacing.sm}`,
279
+ fontSize: tokens.typography.fontSizeXs,
280
+ background: tokens.colors.accentWarningBg,
281
+ color: tokens.colors.accentWarning,
282
+ borderRadius: tokens.radius.pill,
283
+ },
284
+ toolCallDot: {
285
+ width: '8px',
286
+ height: '8px',
287
+ background: tokens.colors.accentWarning,
288
+ borderRadius: tokens.radius.full,
289
+ animation: 'hustle-pulse 1s ease-in-out infinite',
290
+ },
291
+
292
+ // Attachments preview
293
+ attachmentsPreview: {
294
+ padding: `${tokens.spacing.sm} ${tokens.spacing.lg}`,
295
+ borderTop: `1px solid ${tokens.colors.borderPrimary}`,
296
+ display: 'flex',
297
+ flexWrap: 'wrap' as const,
298
+ gap: tokens.spacing.sm,
299
+ },
300
+ attachmentItem: {
301
+ display: 'inline-flex',
302
+ alignItems: 'center',
303
+ gap: tokens.spacing.xs,
304
+ padding: `${tokens.spacing.xs} ${tokens.spacing.sm}`,
305
+ background: tokens.colors.bgTertiary,
306
+ borderRadius: tokens.radius.md,
307
+ fontSize: tokens.typography.fontSizeSm,
308
+ },
309
+ attachmentName: {
310
+ maxWidth: '100px',
311
+ overflow: 'hidden',
312
+ textOverflow: 'ellipsis',
313
+ whiteSpace: 'nowrap' as const,
314
+ },
315
+ attachmentRemove: {
316
+ background: 'none',
317
+ border: 'none',
318
+ color: tokens.colors.textTertiary,
319
+ cursor: 'pointer',
320
+ fontSize: '14px',
321
+ padding: 0,
322
+ lineHeight: 1,
323
+ },
324
+
325
+ // Input area - slightly darker than messages
326
+ inputArea: {
327
+ padding: tokens.spacing.lg,
328
+ background: tokens.colors.bgPrimary,
329
+ borderTop: `1px solid ${tokens.colors.borderPrimary}`,
330
+ borderRadius: `0 0 ${tokens.radius.xl} ${tokens.radius.xl}`,
331
+ },
332
+ inputRow: {
333
+ display: 'flex',
334
+ alignItems: 'center',
335
+ gap: tokens.spacing.sm,
336
+ },
337
+ inputContainer: {
338
+ flex: 1,
339
+ display: 'flex',
340
+ alignItems: 'center',
341
+ background: tokens.colors.bgTertiary,
342
+ border: `1px solid ${tokens.colors.borderSecondary}`,
343
+ borderRadius: tokens.radius.lg,
344
+ overflow: 'hidden',
345
+ },
346
+ attachBtn: {
347
+ width: '40px',
348
+ height: '40px',
349
+ padding: 0,
350
+ background: 'transparent',
351
+ border: 'none',
352
+ borderRadius: 0,
353
+ color: tokens.colors.textTertiary,
354
+ flexShrink: 0,
355
+ } as React.CSSProperties,
356
+ inputWrapper: {
357
+ flex: 1,
358
+ },
359
+ input: {
360
+ width: '100%',
361
+ padding: `${tokens.spacing.md} ${tokens.spacing.sm}`,
362
+ background: 'transparent',
363
+ border: 'none',
364
+ color: tokens.colors.textPrimary,
365
+ fontSize: tokens.typography.fontSizeMd,
366
+ outline: 'none',
367
+ resize: 'none' as const,
368
+ } as React.CSSProperties,
369
+ inputDisabled: {
370
+ background: tokens.colors.bgTertiary,
371
+ cursor: 'not-allowed',
372
+ },
373
+ sendBtn: {
374
+ // Inherits global button styles from CSS
375
+ height: '40px',
376
+ padding: `0 ${tokens.spacing.lg}`,
377
+ fontWeight: tokens.typography.fontWeightMedium,
378
+ } as React.CSSProperties,
379
+ sendBtnDisabled: {
380
+ opacity: 0.5,
381
+ cursor: 'not-allowed',
382
+ },
383
+ sendSpinner: {
384
+ display: 'inline-block',
385
+ width: '16px',
386
+ height: '16px',
387
+ border: '2px solid currentColor',
388
+ borderTopColor: 'transparent',
389
+ borderRadius: tokens.radius.full,
390
+ animation: 'hustle-spin 0.8s linear infinite',
391
+ },
392
+
393
+ // Error display
394
+ errorBox: {
395
+ marginTop: tokens.spacing.sm,
396
+ padding: `${tokens.spacing.sm} ${tokens.spacing.md}`,
397
+ background: tokens.colors.accentErrorBg,
398
+ color: tokens.colors.accentError,
399
+ fontSize: tokens.typography.fontSizeSm,
400
+ borderRadius: tokens.radius.md,
401
+ },
402
+
403
+ // Message bubbles
404
+ messageBubbleContainer: {
405
+ display: 'flex',
406
+ },
407
+ messageBubbleUser: {
408
+ justifyContent: 'flex-end',
409
+ },
410
+ messageBubbleAssistant: {
411
+ justifyContent: 'flex-start',
412
+ },
413
+ messageBubble: {
414
+ maxWidth: '80%',
415
+ padding: `${tokens.spacing.sm} ${tokens.spacing.lg}`,
416
+ borderRadius: tokens.radius.lg,
417
+ },
418
+ messageBubbleUserStyle: {
419
+ background: tokens.colors.msgUser,
420
+ color: tokens.colors.textPrimary,
421
+ },
422
+ messageBubbleAssistantStyle: {
423
+ background: tokens.colors.msgAssistant,
424
+ color: tokens.colors.textPrimary,
425
+ },
426
+ messageBubbleSystemStyle: {
427
+ background: tokens.colors.bgTertiary,
428
+ color: tokens.colors.textSecondary,
429
+ fontSize: tokens.typography.fontSizeSm,
430
+ fontStyle: 'italic' as const,
431
+ },
432
+ messageContent: {
433
+ whiteSpace: 'pre-wrap' as const,
434
+ wordBreak: 'break-word' as const,
435
+ lineHeight: tokens.typography.lineHeightRelaxed,
436
+ },
437
+ streamingCursor: {
438
+ display: 'inline-block',
439
+ width: '2px',
440
+ height: '16px',
441
+ marginLeft: tokens.spacing.xs,
442
+ background: 'currentColor',
443
+ animation: 'hustle-pulse 0.8s ease-in-out infinite',
444
+ },
445
+
446
+ // Tool calls debug
447
+ toolCallsDebug: {
448
+ marginTop: tokens.spacing.sm,
449
+ paddingTop: tokens.spacing.sm,
450
+ borderTop: `1px solid ${tokens.colors.borderSecondary}`,
451
+ fontSize: tokens.typography.fontSizeXs,
452
+ },
453
+ toolCallsDebugTitle: {
454
+ fontWeight: tokens.typography.fontWeightMedium,
455
+ color: tokens.colors.textSecondary,
456
+ marginBottom: tokens.spacing.xs,
457
+ },
458
+ toolCallDebugItem: {
459
+ background: 'rgba(255,255,255,0.05)',
460
+ borderRadius: tokens.radius.sm,
461
+ padding: tokens.spacing.xs,
462
+ marginTop: tokens.spacing.xs,
463
+ },
464
+ toolCallDebugName: {
465
+ ...presets.mono,
466
+ } as React.CSSProperties,
467
+ toolCallDebugArgs: {
468
+ ...presets.mono,
469
+ marginTop: tokens.spacing.xs,
470
+ fontSize: '10px',
471
+ overflow: 'auto',
472
+ } as React.CSSProperties,
473
+
474
+ // Plugin management styles
475
+ settingDivider: {
476
+ height: '1px',
477
+ background: tokens.colors.borderPrimary,
478
+ margin: `${tokens.spacing.xl} 0`,
479
+ },
480
+
481
+ pluginList: {
482
+ display: 'flex',
483
+ flexDirection: 'column' as const,
484
+ gap: tokens.spacing.sm,
485
+ },
486
+
487
+ pluginRow: {
488
+ display: 'flex',
489
+ alignItems: 'center',
490
+ justifyContent: 'space-between',
491
+ padding: `${tokens.spacing.md} ${tokens.spacing.lg}`,
492
+ background: tokens.colors.bgTertiary,
493
+ borderRadius: tokens.radius.lg,
494
+ },
495
+
496
+ pluginInfo: {
497
+ display: 'flex',
498
+ alignItems: 'center',
499
+ gap: tokens.spacing.md,
500
+ flex: 1,
501
+ minWidth: 0,
502
+ },
503
+
504
+ pluginIcon: {
505
+ fontSize: '20px',
506
+ flexShrink: 0,
507
+ },
508
+
509
+ pluginDetails: {
510
+ flex: 1,
511
+ minWidth: 0,
512
+ },
513
+
514
+ pluginName: {
515
+ display: 'block',
516
+ fontWeight: tokens.typography.fontWeightMedium,
517
+ color: tokens.colors.textPrimary,
518
+ whiteSpace: 'nowrap' as const,
519
+ overflow: 'hidden',
520
+ textOverflow: 'ellipsis',
521
+ },
522
+
523
+ pluginMeta: {
524
+ display: 'block',
525
+ fontSize: tokens.typography.fontSizeXs,
526
+ color: tokens.colors.textTertiary,
527
+ },
528
+
529
+ pluginEmpty: {
530
+ padding: tokens.spacing.lg,
531
+ textAlign: 'center' as const,
532
+ color: tokens.colors.textTertiary,
533
+ fontSize: tokens.typography.fontSizeSm,
534
+ },
535
+
536
+ availablePluginsHeader: {
537
+ fontSize: tokens.typography.fontSizeXs,
538
+ fontWeight: tokens.typography.fontWeightSemibold,
539
+ color: tokens.colors.textSecondary,
540
+ textTransform: 'uppercase' as const,
541
+ letterSpacing: '0.5px',
542
+ marginTop: tokens.spacing.lg,
543
+ marginBottom: tokens.spacing.sm,
544
+ },
545
+
546
+ installBtn: {
547
+ padding: `${tokens.spacing.xs} ${tokens.spacing.md}`,
548
+ fontSize: tokens.typography.fontSizeSm,
549
+ background: 'transparent',
550
+ border: `1px solid ${tokens.colors.accentPrimary}`,
551
+ borderRadius: tokens.radius.md,
552
+ color: tokens.colors.accentPrimary,
553
+ cursor: 'pointer',
554
+ transition: `all ${tokens.transitions.fast}`,
555
+ whiteSpace: 'nowrap' as const,
556
+ } as React.CSSProperties,
557
+
558
+ uninstallBtn: {
559
+ padding: `${tokens.spacing.xs} ${tokens.spacing.md}`,
560
+ fontSize: tokens.typography.fontSizeSm,
561
+ background: 'transparent',
562
+ border: `1px solid ${tokens.colors.accentError}`,
563
+ borderRadius: tokens.radius.md,
564
+ color: tokens.colors.accentError,
565
+ cursor: 'pointer',
566
+ transition: `all ${tokens.transitions.fast}`,
567
+ whiteSpace: 'nowrap' as const,
568
+ } as React.CSSProperties,
569
+ };
570
+
571
+ /**
572
+ * Props for HustleChat component
573
+ */
574
+ export interface HustleChatProps {
575
+ /** Additional CSS classes */
576
+ className?: string;
577
+ /** Placeholder text for input */
578
+ placeholder?: string;
579
+ /** Show settings button (opens modal with model selector, prompts, etc.) */
580
+ showSettings?: boolean;
581
+ /** Show debug info */
582
+ showDebug?: boolean;
583
+ /** Initial system prompt */
584
+ initialSystemPrompt?: string;
585
+ /** Callback when message is sent */
586
+ onMessage?: (message: ChatMessage) => void;
587
+ /** Callback when tool is called */
588
+ onToolCall?: (toolCall: ToolCall) => void;
589
+ /** Callback when response is received */
590
+ onResponse?: (content: string) => void;
591
+ }
592
+
593
+ /**
594
+ * Internal message type for display
595
+ */
596
+ interface DisplayMessage extends ChatMessage {
597
+ id: string;
598
+ isStreaming?: boolean;
599
+ toolCalls?: ToolCall[];
600
+ }
601
+
602
+ /**
603
+ * Generate unique ID
604
+ */
605
+ function generateId(): string {
606
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
607
+ }
608
+
609
+ /**
610
+ * HustleChat - Complete streaming chat interface
611
+ *
612
+ * @example Basic usage
613
+ * ```tsx
614
+ * <HustleChat />
615
+ * ```
616
+ *
617
+ * @example With settings modal
618
+ * ```tsx
619
+ * <HustleChat
620
+ * showSettings
621
+ * placeholder="Ask me anything..."
622
+ * onMessage={(msg) => console.log('Sent:', msg)}
623
+ * />
624
+ * ```
625
+ */
626
+ export function HustleChat({
627
+ className = '',
628
+ placeholder = 'Type a message...',
629
+ showSettings = false,
630
+ showDebug = false,
631
+ initialSystemPrompt = '',
632
+ onMessage,
633
+ onToolCall,
634
+ onResponse,
635
+ }: HustleChatProps) {
636
+ const { isAuthenticated } = useEmblemAuth();
637
+ const {
638
+ instanceId,
639
+ isReady,
640
+ isLoading,
641
+ error,
642
+ models,
643
+ chatStream,
644
+ uploadFile,
645
+ selectedModel,
646
+ setSelectedModel,
647
+ systemPrompt,
648
+ setSystemPrompt,
649
+ skipServerPrompt,
650
+ setSkipServerPrompt,
651
+ } = useHustle();
652
+ const {
653
+ plugins,
654
+ registerPlugin,
655
+ unregisterPlugin,
656
+ enablePlugin,
657
+ disablePlugin,
658
+ } = usePlugins(instanceId);
659
+
660
+ // Local state
661
+ const [messages, setMessages] = useState<DisplayMessage[]>([]);
662
+ const [inputValue, setInputValue] = useState('');
663
+ const [isStreaming, setIsStreaming] = useState(false);
664
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
665
+ const [currentToolCalls, setCurrentToolCalls] = useState<ToolCall[]>([]);
666
+ const [showSettingsPanel, setShowSettingsPanel] = useState(false);
667
+
668
+ // Refs
669
+ const messagesEndRef = useRef<HTMLDivElement>(null);
670
+ const fileInputRef = useRef<HTMLInputElement>(null);
671
+
672
+ // Set initial system prompt
673
+ useEffect(() => {
674
+ if (initialSystemPrompt && !systemPrompt) {
675
+ setSystemPrompt(initialSystemPrompt);
676
+ }
677
+ }, [initialSystemPrompt, systemPrompt, setSystemPrompt]);
678
+
679
+ // Scroll to bottom when messages change
680
+ useEffect(() => {
681
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
682
+ }, [messages]);
683
+
684
+ /**
685
+ * Handle file upload
686
+ */
687
+ const handleFileSelect = useCallback(
688
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
689
+ const files = e.target.files;
690
+ if (!files || files.length === 0) return;
691
+
692
+ for (const file of Array.from(files)) {
693
+ try {
694
+ const attachment = await uploadFile(file);
695
+ setAttachments(prev => [...prev, attachment]);
696
+ } catch (err) {
697
+ console.error('Upload failed:', err);
698
+ }
699
+ }
700
+
701
+ // Reset file input
702
+ if (fileInputRef.current) {
703
+ fileInputRef.current.value = '';
704
+ }
705
+ },
706
+ [uploadFile]
707
+ );
708
+
709
+ /**
710
+ * Remove an attachment
711
+ */
712
+ const removeAttachment = useCallback((index: number) => {
713
+ setAttachments(prev => prev.filter((_, i) => i !== index));
714
+ }, []);
715
+
716
+ /**
717
+ * Send a message
718
+ */
719
+ const sendMessage = useCallback(async () => {
720
+ const content = inputValue.trim();
721
+ if (!content || isStreaming || !isReady) return;
722
+
723
+ // Create user message
724
+ const userMessage: DisplayMessage = {
725
+ id: generateId(),
726
+ role: 'user',
727
+ content,
728
+ };
729
+
730
+ // Add user message to display
731
+ setMessages(prev => [...prev, userMessage]);
732
+ setInputValue('');
733
+ onMessage?.(userMessage);
734
+
735
+ // Create assistant message placeholder
736
+ const assistantMessage: DisplayMessage = {
737
+ id: generateId(),
738
+ role: 'assistant',
739
+ content: '',
740
+ isStreaming: true,
741
+ toolCalls: [],
742
+ };
743
+
744
+ setMessages(prev => [...prev, assistantMessage]);
745
+ setIsStreaming(true);
746
+ setCurrentToolCalls([]);
747
+
748
+ try {
749
+ // Build messages array
750
+ const chatMessages: ChatMessage[] = messages
751
+ .filter(m => !m.isStreaming)
752
+ .map(m => ({ role: m.role, content: m.content }));
753
+
754
+ // Add current user message
755
+ chatMessages.push({ role: 'user', content });
756
+
757
+ // Stream the response
758
+ const stream = chatStream({
759
+ messages: chatMessages,
760
+ attachments: attachments.length > 0 ? attachments : undefined,
761
+ processChunks: true,
762
+ });
763
+
764
+ // Clear attachments after sending
765
+ setAttachments([]);
766
+
767
+ let fullContent = '';
768
+ const toolCallsAccumulated: ToolCall[] = [];
769
+
770
+ for await (const chunk of stream) {
771
+ if (chunk.type === 'text') {
772
+ fullContent += chunk.value;
773
+ setMessages(prev =>
774
+ prev.map(m =>
775
+ m.id === assistantMessage.id
776
+ ? { ...m, content: fullContent }
777
+ : m
778
+ )
779
+ );
780
+ } else if (chunk.type === 'tool_call') {
781
+ const toolCall = chunk.value;
782
+ toolCallsAccumulated.push(toolCall);
783
+ setCurrentToolCalls([...toolCallsAccumulated]);
784
+ setMessages(prev =>
785
+ prev.map(m =>
786
+ m.id === assistantMessage.id
787
+ ? { ...m, toolCalls: [...toolCallsAccumulated] }
788
+ : m
789
+ )
790
+ );
791
+ onToolCall?.(toolCall);
792
+ } else if (chunk.type === 'error') {
793
+ console.error('Stream error:', chunk.value);
794
+ }
795
+ }
796
+
797
+ // Finalize the message
798
+ setMessages(prev =>
799
+ prev.map(m =>
800
+ m.id === assistantMessage.id
801
+ ? { ...m, isStreaming: false, content: fullContent || '(No response)' }
802
+ : m
803
+ )
804
+ );
805
+
806
+ onResponse?.(fullContent);
807
+ } catch (err) {
808
+ console.error('Chat error:', err);
809
+ setMessages(prev =>
810
+ prev.map(m =>
811
+ m.id === assistantMessage.id
812
+ ? { ...m, isStreaming: false, content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}` }
813
+ : m
814
+ )
815
+ );
816
+ } finally {
817
+ setIsStreaming(false);
818
+ setCurrentToolCalls([]);
819
+ }
820
+ }, [inputValue, isStreaming, isReady, messages, chatStream, attachments, onMessage, onToolCall, onResponse]);
821
+
822
+ /**
823
+ * Handle key press
824
+ */
825
+ const handleKeyPress = useCallback(
826
+ (e: React.KeyboardEvent) => {
827
+ if (e.key === 'Enter' && !e.shiftKey) {
828
+ e.preventDefault();
829
+ sendMessage();
830
+ }
831
+ },
832
+ [sendMessage]
833
+ );
834
+
835
+ // Determine placeholder message for messages area
836
+ const getPlaceholderMessage = () => {
837
+ if (!isAuthenticated) {
838
+ return 'Connect to start chatting...';
839
+ }
840
+ if (!isReady) {
841
+ return 'Initializing...';
842
+ }
843
+ return 'Start a conversation...';
844
+ };
845
+
846
+ // Can interact with chat?
847
+ const canChat = isAuthenticated && isReady;
848
+
849
+ return (
850
+ <>
851
+ <style>{animations}</style>
852
+ <div className={className} style={styles.container}>
853
+ {/* Header */}
854
+ <div style={styles.header}>
855
+ <h2 style={styles.headerTitle}>Chat</h2>
856
+ <div style={styles.headerActions}>
857
+ {/* Selected model label */}
858
+ {selectedModel && (
859
+ <span style={{ fontSize: tokens.typography.fontSizeSm, color: tokens.colors.textSecondary }}>
860
+ {selectedModel.split('/').pop()}
861
+ </span>
862
+ )}
863
+
864
+ {/* Settings toggle */}
865
+ {showSettings && (
866
+ <button
867
+ type="button"
868
+ onClick={() => setShowSettingsPanel(!showSettingsPanel)}
869
+ style={{
870
+ ...styles.settingsBtn,
871
+ ...(showSettingsPanel ? styles.settingsBtnActive : styles.settingsBtnInactive),
872
+ }}
873
+ title="Settings"
874
+ >
875
+ <SettingsIcon />
876
+ </button>
877
+ )}
878
+ </div>
879
+ </div>
880
+
881
+ {/* Settings Modal */}
882
+ {showSettings && showSettingsPanel && (
883
+ <div style={styles.modalOverlay} onClick={() => setShowSettingsPanel(false)}>
884
+ <div style={styles.modal} onClick={e => e.stopPropagation()}>
885
+ {/* Modal Header */}
886
+ <div style={styles.modalHeader}>
887
+ <span style={styles.modalTitle}>Settings</span>
888
+ <button
889
+ type="button"
890
+ style={styles.modalClose}
891
+ onClick={() => setShowSettingsPanel(false)}
892
+ >
893
+ ×
894
+ </button>
895
+ </div>
896
+
897
+ {/* Modal Body */}
898
+ <div style={styles.modalBody}>
899
+ {/* Model Selection */}
900
+ <div style={styles.settingGroup}>
901
+ <label style={styles.settingLabel}>Model</label>
902
+ <p style={styles.settingDescription}>Select the AI model to use for chat responses</p>
903
+ <select
904
+ value={selectedModel}
905
+ onChange={e => setSelectedModel(e.target.value)}
906
+ style={styles.settingSelect}
907
+ >
908
+ <option value="">Default (server decides)</option>
909
+ {(() => {
910
+ // Group models by provider
911
+ const grouped: Record<string, typeof models> = {};
912
+ models.forEach(model => {
913
+ const [provider] = model.id.split('/');
914
+ if (!grouped[provider]) grouped[provider] = [];
915
+ grouped[provider].push(model);
916
+ });
917
+ return Object.entries(grouped).map(([provider, providerModels]) => (
918
+ <optgroup key={provider} label={provider.charAt(0).toUpperCase() + provider.slice(1)}>
919
+ {providerModels.map(model => (
920
+ <option key={model.id} value={model.id}>
921
+ {model.name}
922
+ </option>
923
+ ))}
924
+ </optgroup>
925
+ ));
926
+ })()}
927
+ </select>
928
+ {selectedModel && (() => {
929
+ const model = models.find(m => m.id === selectedModel);
930
+ if (!model) return null;
931
+ const contextK = Math.round(model.context_length / 1000);
932
+ const promptCost = parseFloat(model.pricing?.prompt || '0') * 1000000;
933
+ const completionCost = parseFloat(model.pricing?.completion || '0') * 1000000;
934
+ return (
935
+ <div style={styles.modelInfo}>
936
+ Context: {contextK}K tokens | Cost: ${promptCost.toFixed(2)}/${completionCost.toFixed(2)} per 1M tokens
937
+ </div>
938
+ );
939
+ })()}
940
+ </div>
941
+
942
+ {/* Server System Prompt */}
943
+ <div style={styles.settingGroup}>
944
+ <label style={styles.settingLabel}>Server System Prompt</label>
945
+ <div
946
+ style={styles.toggleRow}
947
+ onClick={() => setSkipServerPrompt(!skipServerPrompt)}
948
+ >
949
+ <span style={styles.toggleLabel}>Skip server-provided system prompt</span>
950
+ <div style={{
951
+ ...styles.toggleSwitch,
952
+ ...(skipServerPrompt ? styles.toggleSwitchActive : {}),
953
+ }}>
954
+ <div style={{
955
+ ...styles.toggleKnob,
956
+ ...(skipServerPrompt ? styles.toggleKnobActive : {}),
957
+ }} />
958
+ </div>
959
+ </div>
960
+ <p style={styles.settingDescription}>
961
+ When enabled, the server's default system prompt will not be used
962
+ </p>
963
+ </div>
964
+
965
+ {/* Custom System Prompt */}
966
+ <div style={styles.settingGroup}>
967
+ <label style={styles.settingLabel}>Custom System Prompt</label>
968
+ <p style={styles.settingDescription}>Provide instructions for how the AI should behave</p>
969
+ <textarea
970
+ value={systemPrompt}
971
+ onChange={e => setSystemPrompt(e.target.value)}
972
+ placeholder="You are a helpful assistant..."
973
+ style={styles.settingTextarea}
974
+ />
975
+ </div>
976
+
977
+ {/* Divider */}
978
+ <div style={styles.settingDivider} />
979
+
980
+ {/* Plugins Section */}
981
+ <div style={{ ...styles.settingGroup, marginBottom: 0 }}>
982
+ <label style={styles.settingLabel}>Plugins</label>
983
+ <p style={styles.settingDescription}>Extend the AI with custom tools</p>
984
+
985
+ {/* Installed plugins */}
986
+ {plugins.length > 0 ? (
987
+ <div style={styles.pluginList}>
988
+ {plugins.map(plugin => (
989
+ <div key={plugin.name} style={styles.pluginRow}>
990
+ <div style={styles.pluginInfo}>
991
+ <span style={styles.pluginIcon}>{plugin.enabled ? '🔌' : '⚪'}</span>
992
+ <div style={styles.pluginDetails}>
993
+ <span style={styles.pluginName}>{plugin.name}</span>
994
+ <span style={styles.pluginMeta}>
995
+ v{plugin.version} • {plugin.tools?.length || 0} tools
996
+ </span>
997
+ </div>
998
+ </div>
999
+ <div style={{ display: 'flex', gap: tokens.spacing.sm }}>
1000
+ <div
1001
+ style={{
1002
+ ...styles.toggleSwitch,
1003
+ ...(plugin.enabled ? styles.toggleSwitchActive : {}),
1004
+ }}
1005
+ onClick={() => plugin.enabled
1006
+ ? disablePlugin(plugin.name)
1007
+ : enablePlugin(plugin.name)
1008
+ }
1009
+ >
1010
+ <div style={{
1011
+ ...styles.toggleKnob,
1012
+ ...(plugin.enabled ? styles.toggleKnobActive : {}),
1013
+ }} />
1014
+ </div>
1015
+ <button
1016
+ type="button"
1017
+ style={styles.uninstallBtn}
1018
+ onClick={() => unregisterPlugin(plugin.name)}
1019
+ >
1020
+ Remove
1021
+ </button>
1022
+ </div>
1023
+ </div>
1024
+ ))}
1025
+ </div>
1026
+ ) : (
1027
+ <div style={styles.pluginEmpty}>
1028
+ No plugins installed
1029
+ </div>
1030
+ )}
1031
+
1032
+ {/* Available plugins */}
1033
+ {availablePlugins.filter(p => !plugins.some(installed => installed.name === p.name)).length > 0 && (
1034
+ <>
1035
+ <div style={styles.availablePluginsHeader}>Available</div>
1036
+ <div style={styles.pluginList}>
1037
+ {availablePlugins
1038
+ .filter(p => !plugins.some(installed => installed.name === p.name))
1039
+ .map(plugin => (
1040
+ <div key={plugin.name} style={styles.pluginRow}>
1041
+ <div style={styles.pluginInfo}>
1042
+ <span style={styles.pluginIcon}>📦</span>
1043
+ <div style={styles.pluginDetails}>
1044
+ <span style={styles.pluginName}>{plugin.name}</span>
1045
+ <span style={styles.pluginMeta}>{plugin.description}</span>
1046
+ </div>
1047
+ </div>
1048
+ <button
1049
+ type="button"
1050
+ style={styles.installBtn}
1051
+ onClick={() => registerPlugin(plugin)}
1052
+ >
1053
+ + Install
1054
+ </button>
1055
+ </div>
1056
+ ))}
1057
+ </div>
1058
+ </>
1059
+ )}
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+ )}
1065
+
1066
+ {/* Messages area */}
1067
+ <div style={styles.messagesArea}>
1068
+ {messages.length === 0 && (
1069
+ <div style={styles.messagesEmpty}>
1070
+ <p>{getPlaceholderMessage()}</p>
1071
+ </div>
1072
+ )}
1073
+
1074
+ <div style={styles.messagesContainer}>
1075
+ {messages.map(message => (
1076
+ <MessageBubble
1077
+ key={message.id}
1078
+ message={message}
1079
+ showDebug={showDebug}
1080
+ />
1081
+ ))}
1082
+ </div>
1083
+
1084
+ {/* Current tool calls indicator */}
1085
+ {currentToolCalls.length > 0 && (
1086
+ <div style={styles.toolCallsIndicator}>
1087
+ {currentToolCalls.map(tool => (
1088
+ <span key={tool.toolCallId} style={styles.toolCallBadge}>
1089
+ <span style={styles.toolCallDot} />
1090
+ {tool.toolName}
1091
+ </span>
1092
+ ))}
1093
+ </div>
1094
+ )}
1095
+
1096
+ <div ref={messagesEndRef} />
1097
+ </div>
1098
+
1099
+ {/* Attachments preview */}
1100
+ {attachments.length > 0 && (
1101
+ <div style={styles.attachmentsPreview}>
1102
+ {attachments.map((att, index) => (
1103
+ <div key={index} style={styles.attachmentItem}>
1104
+ <span style={styles.attachmentName}>{att.name}</span>
1105
+ <button
1106
+ type="button"
1107
+ onClick={() => removeAttachment(index)}
1108
+ style={styles.attachmentRemove}
1109
+ >
1110
+ ×
1111
+ </button>
1112
+ </div>
1113
+ ))}
1114
+ </div>
1115
+ )}
1116
+
1117
+ {/* Input area */}
1118
+ <div style={styles.inputArea}>
1119
+ <div style={styles.inputRow}>
1120
+ {/* Input container with attached file button */}
1121
+ <div style={styles.inputContainer}>
1122
+ <button
1123
+ type="button"
1124
+ onClick={() => fileInputRef.current?.click()}
1125
+ style={styles.attachBtn}
1126
+ title="Attach file"
1127
+ >
1128
+ <AttachIcon />
1129
+ </button>
1130
+ <input
1131
+ ref={fileInputRef}
1132
+ type="file"
1133
+ accept="image/*"
1134
+ multiple
1135
+ onChange={handleFileSelect}
1136
+ style={{ display: 'none' }}
1137
+ />
1138
+ <div style={styles.inputWrapper}>
1139
+ <textarea
1140
+ value={inputValue}
1141
+ onChange={e => setInputValue(e.target.value)}
1142
+ onKeyPress={handleKeyPress}
1143
+ placeholder={placeholder}
1144
+ disabled={!canChat || isStreaming || isLoading}
1145
+ rows={1}
1146
+ style={{
1147
+ ...styles.input,
1148
+ ...(!canChat || isStreaming || isLoading ? styles.inputDisabled : {}),
1149
+ }}
1150
+ />
1151
+ </div>
1152
+ </div>
1153
+
1154
+ {/* Send button */}
1155
+ <button
1156
+ type="button"
1157
+ onClick={sendMessage}
1158
+ disabled={!canChat || !inputValue.trim() || isStreaming || isLoading}
1159
+ style={{
1160
+ ...styles.sendBtn,
1161
+ ...(!canChat || !inputValue.trim() || isStreaming || isLoading
1162
+ ? styles.sendBtnDisabled
1163
+ : {}),
1164
+ }}
1165
+ >
1166
+ {isStreaming ? (
1167
+ <span style={styles.sendSpinner} />
1168
+ ) : (
1169
+ 'Send'
1170
+ )}
1171
+ </button>
1172
+ </div>
1173
+
1174
+ {/* Error display */}
1175
+ {error && (
1176
+ <div style={styles.errorBox}>
1177
+ {error.message}
1178
+ </div>
1179
+ )}
1180
+ </div>
1181
+ </div>
1182
+ </>
1183
+ );
1184
+ }
1185
+
1186
+ /**
1187
+ * Message bubble component
1188
+ */
1189
+ interface MessageBubbleProps {
1190
+ message: DisplayMessage;
1191
+ showDebug?: boolean;
1192
+ }
1193
+
1194
+ function MessageBubble({ message, showDebug }: MessageBubbleProps) {
1195
+ const isUser = message.role === 'user';
1196
+ const isSystem = message.role === 'system';
1197
+
1198
+ const containerStyle = {
1199
+ ...styles.messageBubbleContainer,
1200
+ ...(isUser ? styles.messageBubbleUser : styles.messageBubbleAssistant),
1201
+ };
1202
+
1203
+ const bubbleStyle = {
1204
+ ...styles.messageBubble,
1205
+ ...(isUser
1206
+ ? styles.messageBubbleUserStyle
1207
+ : isSystem
1208
+ ? styles.messageBubbleSystemStyle
1209
+ : styles.messageBubbleAssistantStyle),
1210
+ };
1211
+
1212
+ return (
1213
+ <div style={containerStyle}>
1214
+ <div style={bubbleStyle}>
1215
+ {/* Message content */}
1216
+ <div style={styles.messageContent}>
1217
+ {isUser || isSystem ? (
1218
+ // User and system messages: plain text
1219
+ message.content
1220
+ ) : (
1221
+ // Assistant messages: render markdown
1222
+ <MarkdownContent content={message.content} />
1223
+ )}
1224
+ {message.isStreaming && (
1225
+ <span style={styles.streamingCursor} />
1226
+ )}
1227
+ </div>
1228
+
1229
+ {/* Tool calls (debug mode) */}
1230
+ {showDebug && message.toolCalls && message.toolCalls.length > 0 && (
1231
+ <div style={styles.toolCallsDebug}>
1232
+ <div style={styles.toolCallsDebugTitle}>Tool calls:</div>
1233
+ {message.toolCalls.map(tool => (
1234
+ <div key={tool.toolCallId} style={styles.toolCallDebugItem}>
1235
+ <span style={styles.toolCallDebugName}>{tool.toolName}</span>
1236
+ {tool.args && (
1237
+ <pre style={styles.toolCallDebugArgs}>
1238
+ {JSON.stringify(tool.args, null, 2)}
1239
+ </pre>
1240
+ )}
1241
+ </div>
1242
+ ))}
1243
+ </div>
1244
+ )}
1245
+ </div>
1246
+ </div>
1247
+ );
1248
+ }
1249
+
1250
+ /**
1251
+ * Settings icon
1252
+ */
1253
+ function SettingsIcon() {
1254
+ return (
1255
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1256
+ <circle cx="12" cy="12" r="3" />
1257
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
1258
+ </svg>
1259
+ );
1260
+ }
1261
+
1262
+ /**
1263
+ * Attach icon
1264
+ */
1265
+ function AttachIcon() {
1266
+ return (
1267
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1268
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
1269
+ </svg>
1270
+ );
1271
+ }
1272
+
1273
+ export default HustleChat;