free-coding-models 0.5.29 → 0.5.30

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 (48) hide show
  1. package/README.md +1 -1
  2. package/changelog/v0.5.30.md +15 -0
  3. package/package.json +1 -1
  4. package/src/core/playground.js +6 -4
  5. package/src/core/router-daemon.js +25 -2
  6. package/src/core/sync-set.js +5 -3
  7. package/web/dist/assets/index-CsFt3qt5.css +1 -0
  8. package/web/dist/assets/index-I6N91rH7.js +40 -0
  9. package/web/dist/favicon.ico +0 -0
  10. package/web/dist/favicons/apple-touch-icon.png +0 -0
  11. package/web/dist/favicons/favicon-16x16.png +0 -0
  12. package/web/dist/favicons/favicon-192x192.png +0 -0
  13. package/web/dist/favicons/favicon-32x32.png +0 -0
  14. package/web/dist/favicons/favicon-48x48.png +0 -0
  15. package/web/dist/favicons/favicon-512x512.png +0 -0
  16. package/web/dist/favicons/favicon-96x96.png +0 -0
  17. package/web/dist/favicons/favicon.ico +0 -0
  18. package/web/dist/favicons/mstile-150x150.png +0 -0
  19. package/web/dist/favicons/mstile-310x150.png +0 -0
  20. package/web/dist/favicons/mstile-310x310.png +0 -0
  21. package/web/dist/favicons/mstile-512x512.png +0 -0
  22. package/web/dist/favicons/mstile-70x70.png +0 -0
  23. package/web/dist/index.html +2 -2
  24. package/web/public/favicon.ico +0 -0
  25. package/web/public/favicons/apple-touch-icon.png +0 -0
  26. package/web/public/favicons/favicon-16x16.png +0 -0
  27. package/web/public/favicons/favicon-192x192.png +0 -0
  28. package/web/public/favicons/favicon-32x32.png +0 -0
  29. package/web/public/favicons/favicon-48x48.png +0 -0
  30. package/web/public/favicons/favicon-512x512.png +0 -0
  31. package/web/public/favicons/favicon-96x96.png +0 -0
  32. package/web/public/favicons/favicon.ico +0 -0
  33. package/web/public/favicons/mstile-150x150.png +0 -0
  34. package/web/public/favicons/mstile-310x150.png +0 -0
  35. package/web/public/favicons/mstile-310x310.png +0 -0
  36. package/web/public/favicons/mstile-512x512.png +0 -0
  37. package/web/public/favicons/mstile-70x70.png +0 -0
  38. package/web/server.js +34 -4
  39. package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
  40. package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
  41. package/web/src/components/playground/PlaygroundChat.jsx +527 -0
  42. package/web/src/components/playground/PlaygroundChat.module.css +382 -0
  43. package/web/src/components/playground/PlaygroundView.jsx +104 -442
  44. package/web/src/components/playground/PlaygroundView.module.css +10 -0
  45. package/web/src/components/router/RouterView.jsx +144 -0
  46. package/web/src/components/router/RouterView.module.css +281 -0
  47. package/web/dist/assets/index-BMU58Jju.js +0 -41
  48. package/web/dist/assets/index-Dd1jOGEn.css +0 -1
@@ -166,6 +166,16 @@
166
166
  min-height: 320px;
167
167
  }
168
168
 
169
+ /* 📖 Flex container that fills the modal body and hosts either the router
170
+ daemon start/starting panels or the shared <PlaygroundChat> (which itself is
171
+ a column flex that scrolls internally and pins the input at the bottom). */
172
+ .chatBody {
173
+ flex: 1;
174
+ min-height: 320px;
175
+ display: flex;
176
+ flex-direction: column;
177
+ }
178
+
169
179
  .empty {
170
180
  flex: 1;
171
181
  display: flex;
@@ -10,8 +10,10 @@ import {
10
10
  IconCopy, IconCheck, IconChevronDown, IconChevronUp,
11
11
  IconActivity, IconServer, IconPlus, IconX, IconGripVertical,
12
12
  IconArrowRight, IconArrowUp, IconArrowDown, IconTrash, IconList, IconWand,
13
+ IconSend, IconBolt,
13
14
  } from '@tabler/icons-react'
14
15
  import styles from './RouterView.module.css'
16
+ import PlaygroundChat from '../playground/PlaygroundChat.jsx'
15
17
 
16
18
  function formatUptime(seconds) {
17
19
  if (!seconds || seconds <= 0) return '—'
@@ -74,6 +76,12 @@ export default function RouterView({ onClose, onToast }) {
74
76
  const saveTimerRef = useRef(null)
75
77
  const hasAutoExpandedLog = useRef(false)
76
78
 
79
+ // 📖 Probe state — AI Latency benchmarks launched on the active set. Progress
80
+ // 📖 comes from /stats.globalBenchmark (polled), results land on
81
+ // 📖 /stats.models[].benchmark. `probePolling` speeds up polling while running
82
+ // 📖 so the progress bar + per-model latencies update live.
83
+ const [probePolling, setProbePolling] = useState(false)
84
+
77
85
  const fetchStatus = useCallback(async () => {
78
86
  try {
79
87
  const resp = await fetch('/api/router/status')
@@ -115,6 +123,23 @@ export default function RouterView({ onClose, onToast }) {
115
123
  return () => clearInterval(interval)
116
124
  }, [fetchStatus, fetchSets, fetchCatalog])
117
125
 
126
+ // 📖 Fast-poll /stats while a probe is running so the progress bar and
127
+ // 📖 per-model AI latencies stream in live (every 1.2s instead of 5s).
128
+ // 📖 Stops the moment the daemon reports the global benchmark as done.
129
+ useEffect(() => {
130
+ if (!probePolling) return undefined
131
+ const interval = setInterval(() => { void fetchStatus() }, 1200)
132
+ return () => clearInterval(interval)
133
+ }, [probePolling, fetchStatus])
134
+
135
+ // 📖 Keep probePolling in sync with the daemon's real state: if it says the
136
+ // 📖 global benchmark stopped, drop our fast-poll flag so we go back to 5s.
137
+ useEffect(() => {
138
+ if (probePolling && stats?.globalBenchmark && !stats.globalBenchmark.running) {
139
+ setProbePolling(false)
140
+ }
141
+ }, [stats?.globalBenchmark, probePolling])
142
+
118
143
  // 📖 Cleanup the "saved" indicator so it fades back to idle after 1.5s.
119
144
  useEffect(() => {
120
145
  if (saveStatus.kind !== 'saved') return undefined
@@ -307,6 +332,44 @@ export default function RouterView({ onClose, onToast }) {
307
332
  }
308
333
  }, [activeSetName, fetchSets, onToast])
309
334
 
335
+ // 📖 handleProbeAll — launch AI Latency benchmarks on every model in the
336
+ // 📖 active set, inside the DAEMON. Progress + results stream back through
337
+ // 📖 /stats (globalBenchmark + per-model benchmark), which the fast-poll
338
+ // 📖 effect above picks up every 1.2s. Disabled while already running.
339
+ const handleProbeAll = async () => {
340
+ if (!activeSetName || probePolling) return
341
+ if (localModels.length === 0) {
342
+ onToast?.('Add models to the set first, then probe.', 'info')
343
+ return
344
+ }
345
+ try {
346
+ const probeModels = localModels.map((m) => ({ providerKey: m.provider, modelId: m.model }))
347
+ const resp = await fetch('/api/router/probe-all', {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ models: probeModels }),
351
+ })
352
+ const data = await resp.json().catch(() => null)
353
+ if (!resp.ok || !resp.status || resp.status === 409) {
354
+ throw new Error(data?.error || 'Probe already running')
355
+ }
356
+ setProbePolling(true)
357
+ const total = data?.total ?? localModels.length
358
+ onToast?.(`Probing ${total} model${total === 1 ? '' : 's'} for AI Latency…`, 'info')
359
+ await fetchStatus()
360
+ } catch (err) {
361
+ onToast?.(`Probe failed: ${err.message}`, 'error')
362
+ setProbePolling(false)
363
+ }
364
+ }
365
+
366
+ // 📖 The "Test Router" mini playground is now rendered by the shared
367
+ // 📖 <PlaygroundChat> component (model="fcm"), which streams the reply
368
+ // 📖 through /api/playground/chat and shows the harmonized metadata row
369
+ // 📖 (served model · ms · tok · t/s) under every reply. The router's
370
+ // 📖 Primary pick is passed as `targetLabel` so the user sees which model
371
+ // 📖 the priority-first router will try first under their user bubble.
372
+
310
373
  // ── Drag and drop state ───────────────────────────────────────────────
311
374
  // We keep a local copy of `models` so the drag UX is instant — the
312
375
  // server is updated only when the user actually drops the row.
@@ -433,6 +496,15 @@ export default function RouterView({ onClose, onToast }) {
433
496
  const routingOrder = stats?.routingOrder || []
434
497
  const nextToServeKey = routingOrder.length > 0 ? routingOrder[0].key : null
435
498
 
499
+ // 📖 Benchmark results keyed by "provider/model" so each set row can show
500
+ // 📖 its live AI Latency + TPS after a probe. Built from /stats.models.
501
+ const benchmarkByKey = new Map()
502
+ for (const m of (stats?.models || [])) {
503
+ if (m && typeof m.key === 'string') benchmarkByKey.set(m.key, m)
504
+ }
505
+ const globalBenchmark = stats?.globalBenchmark || null
506
+ const probeActive = Boolean(globalBenchmark?.running) || probePolling
507
+
436
508
  // 📖 Auto-expand the request log the first time requests appear.
437
509
  useEffect(() => {
438
510
  if (requestLog.length > 0 && !hasAutoExpandedLog.current) {
@@ -603,6 +675,17 @@ export default function RouterView({ onClose, onToast }) {
603
675
  <IconWand size={11} />
604
676
  Sync best
605
677
  </button>
678
+ {/* 📖 Probe all — run AI Latency benchmarks on every model in the
679
+ set. Results stream into the rows below (AI Lat column). */}
680
+ <button
681
+ className={`${styles.smallBtn} ${probeActive ? styles.probeBtnActive : ''}`}
682
+ onClick={handleProbeAll}
683
+ disabled={probeActive || saveStatus.kind === 'saving' || localModels.length === 0}
684
+ title="Benchmark AI Latency + TPS on every model in this set"
685
+ >
686
+ <IconBolt size={11} />
687
+ {probeActive ? 'Probing…' : 'Probe all'}
688
+ </button>
606
689
  <button
607
690
  className={styles.primaryBtn}
608
691
  onClick={() => setPickerOpen((v) => !v)}
@@ -614,6 +697,26 @@ export default function RouterView({ onClose, onToast }) {
614
697
  </div>
615
698
  </div>
616
699
 
700
+ {/* 📖 Probe progress bar — shown while AI Latency benchmarks run
701
+ across the set. Reads /stats.globalBenchmark (fast-polled). */}
702
+ {probeActive && globalBenchmark && (
703
+ <div className={styles.probeProgress}>
704
+ <div className={styles.probeProgressLabel}>
705
+ <IconBolt size={11} />
706
+ AI Latency probe
707
+ <span className={styles.probeProgressCount}>
708
+ {globalBenchmark.completed} / {globalBenchmark.total || localModels.length}
709
+ </span>
710
+ </div>
711
+ <div className={styles.probeProgressBar}>
712
+ <div
713
+ className={styles.probeProgressFill}
714
+ style={{ width: `${Math.min(100, ((globalBenchmark.completed || 0) / Math.max(1, globalBenchmark.total || localModels.length)) * 100)}%` }}
715
+ />
716
+ </div>
717
+ </div>
718
+ )}
719
+
617
720
  {localModels.length === 0 ? (
618
721
  <div className={styles.setEmpty}>
619
722
  <div className={styles.setEmptyTitle}>No models in the active set</div>
@@ -639,6 +742,11 @@ export default function RouterView({ onClose, onToast }) {
639
742
  {localModels.map((m, idx) => {
640
743
  const key = `${m.provider}/${m.model}`
641
744
  const cb = circuitBreakers[key] || {}
745
+ // 📖 Per-model AI Latency from the latest probe. `bm` is the
746
+ // 📖 raw benchmark result; `bmLoading` is true while it runs.
747
+ const bmEntry = benchmarkByKey.get(key)
748
+ const bm = bmEntry?.benchmark || null
749
+ const bmLoading = Boolean(bmEntry?.isBenchmarking)
642
750
  const isDragging = draggingKey === key
643
751
  const dropAbove = dropPosition?.key === key && dropPosition.side === 'above'
644
752
  const dropBelow = dropPosition?.key === key && dropPosition.side === 'below'
@@ -671,6 +779,18 @@ export default function RouterView({ onClose, onToast }) {
671
779
  <span className={styles.setKey}>{key}</span>
672
780
  {m.tier && <span className={styles.setTier}>{m.tier}</span>}
673
781
  <CircuitBadge state={cb.state || m.state} />
782
+ {/* 📖 AI Latency — populated by "Probe all". Shows a spinner
783
+ while benchmarking, the latency+TPS once done, or a dim
784
+ placeholder before the first probe. */}
785
+ <span className={styles.aiLatencyCell} title={bm ? `AI Latency: ${Math.round(bm.totalMs)}ms · TPS: ${(bm.tokensPerSecond ?? 0).toFixed(1)}` : 'Run Probe all to measure AI Latency'}>
786
+ {bmLoading
787
+ ? <span className={styles.aiLatencySpin}>···</span>
788
+ : bm?.ok
789
+ ? <><span className={styles.aiLatencyMs}>{Math.round(bm.totalMs)}ms</span>{bm.tokensPerSecond != null && bm.tokensPerSecond > 0 && <span className={styles.aiLatencyTps}>{bm.tokensPerSecond.toFixed(1)} t/s</span>}</>
790
+ : bm && !bm.ok
791
+ ? <span className={styles.aiLatencyErr}>fail</span>
792
+ : <span className={styles.aiLatencyNone}>—</span>}
793
+ </span>
674
794
  <div className={styles.setRowBtns}>
675
795
  <button
676
796
  className={styles.iconBtn}
@@ -818,6 +938,30 @@ export default function RouterView({ onClose, onToast }) {
818
938
  </div>
819
939
  )}
820
940
 
941
+ {/* 📖 Test Router — the shared PlaygroundChat core, routed through
942
+ `fcm` so the user can sanity-check the priority-first router right
943
+ from this view. The router's Primary pick is passed as targetLabel
944
+ so it appears under the user bubble; the served model (+ ms / tok /
945
+ t/s) appears under the reply and reveals any failover. Only shown
946
+ when running (needs the daemon). */}
947
+ {running && (
948
+ <div className={styles.section}>
949
+ <h3 className={styles.sectionTitle}>
950
+ <IconSend size={14} />
951
+ Test Router
952
+ <span className={styles.miniPgHint}>routes through <code>fcm</code> — try the fallback chain</span>
953
+ </h3>
954
+ <PlaygroundChat
955
+ model="fcm"
956
+ variant="mini"
957
+ disabled={!running}
958
+ targetLabel={nextToServeKey || 'fcm'}
959
+ placeholder="Test the router… (e.g. write a haiku about TypeScript)"
960
+ emptyHint="Send a message to see which model the router picks, with latency + TPS."
961
+ />
962
+ </div>
963
+ )}
964
+
821
965
  {/* Server health (small chip at the bottom for visibility) */}
822
966
  <div className={styles.section} style={{ marginBottom: 0, marginTop: 16 }}>
823
967
  <span className={styles.saveStatus}>
@@ -844,3 +844,284 @@
844
844
  flex-shrink: 0;
845
845
  }
846
846
 
847
+ /* ════════════════════════════════════════════════════════════════════════
848
+ 📖 Probe-all button + progress bar (AI Latency benchmarks on the set).
849
+ ════════════════════════════════════════════════════════════════════════ */
850
+
851
+ /* 📖 Active state for the Probe button while benchmarks run. */
852
+ .probeBtnActive {
853
+ border-color: var(--accent, #00c864);
854
+ color: var(--accent, #00c864);
855
+ cursor: progress;
856
+ }
857
+
858
+ /* 📖 Progress card shown below the set actions while probing. */
859
+ .probeProgress {
860
+ display: flex;
861
+ flex-direction: column;
862
+ gap: 6px;
863
+ padding: 8px 10px;
864
+ margin-bottom: 8px;
865
+ background: var(--bg-secondary, #14141f);
866
+ border: 1px solid rgba(0, 200, 100, 0.2);
867
+ border-radius: 6px;
868
+ }
869
+
870
+ .probeProgressLabel {
871
+ display: flex;
872
+ align-items: center;
873
+ gap: 5px;
874
+ font-size: 11px;
875
+ color: var(--accent, #00c864);
876
+ font-weight: 600;
877
+ }
878
+
879
+ .probeProgressCount {
880
+ margin-left: auto;
881
+ color: var(--text-secondary, #aaa);
882
+ font-weight: 500;
883
+ font-family: 'SF Mono', monospace;
884
+ }
885
+
886
+ .probeProgressBar {
887
+ height: 4px;
888
+ background: var(--bg-primary, #0f0f17);
889
+ border-radius: 2px;
890
+ overflow: hidden;
891
+ }
892
+
893
+ .probeProgressFill {
894
+ height: 100%;
895
+ background: linear-gradient(90deg, var(--accent, #00c864), rgba(0, 200, 100, 0.6));
896
+ border-radius: 2px;
897
+ transition: width 0.4s ease;
898
+ }
899
+
900
+ /* ════════════════════════════════════════════════════════════════════════
901
+ 📖 AI Latency cell in each set row — populated by Probe all.
902
+ ════════════════════════════════════════════════════════════════════════ */
903
+
904
+ .aiLatencyCell {
905
+ display: inline-flex;
906
+ align-items: center;
907
+ gap: 4px;
908
+ min-width: 78px;
909
+ font-size: 10px;
910
+ font-family: 'SF Mono', monospace;
911
+ justify-content: flex-end;
912
+ }
913
+
914
+ .aiLatencyMs {
915
+ font-weight: 700;
916
+ color: #b400ff;
917
+ }
918
+
919
+ .aiLatencyTps {
920
+ color: var(--text-muted, #888);
921
+ font-size: 9px;
922
+ }
923
+
924
+ .aiLatencySpin {
925
+ color: var(--accent, #00c864);
926
+ letter-spacing: 2px;
927
+ animation: probeSpin 0.8s steps(3, end) infinite;
928
+ }
929
+
930
+ @keyframes probeSpin {
931
+ 0% { opacity: 0.3; }
932
+ 50% { opacity: 1; }
933
+ 100% { opacity: 0.3; }
934
+ }
935
+
936
+ .aiLatencyErr {
937
+ color: var(--error, #ff5a5a);
938
+ font-size: 10px;
939
+ }
940
+
941
+ .aiLatencyNone {
942
+ color: var(--text-muted, #888);
943
+ opacity: 0.4;
944
+ }
945
+
946
+ /* ════════════════════════════════════════════════════════════════════════
947
+ 📖 Mini Playground — live router test inside the Router view.
948
+ ════════════════════════════════════════════════════════════════════════ */
949
+
950
+ .miniPgHint {
951
+ margin-left: auto;
952
+ font-size: 10px;
953
+ font-weight: 400;
954
+ color: var(--text-muted, #888);
955
+ }
956
+
957
+ .miniPgHint code {
958
+ color: var(--accent, #00c864);
959
+ font-family: 'SF Mono', monospace;
960
+ }
961
+
962
+ .miniPg {
963
+ max-height: 220px;
964
+ overflow-y: auto;
965
+ padding: 8px;
966
+ margin-bottom: 8px;
967
+ background: var(--bg-primary, #0f0f17);
968
+ border: 1px solid var(--border, #1e1e2e);
969
+ border-radius: 8px;
970
+ display: flex;
971
+ flex-direction: column;
972
+ gap: 8px;
973
+ scroll-behavior: smooth;
974
+ }
975
+
976
+ .miniPgEmpty {
977
+ text-align: center;
978
+ color: var(--text-muted, #888);
979
+ font-size: 11px;
980
+ padding: 18px 8px;
981
+ }
982
+
983
+ .miniPgMsg {
984
+ display: flex;
985
+ flex-direction: column;
986
+ gap: 3px;
987
+ max-width: 88%;
988
+ }
989
+
990
+ .miniPgUser {
991
+ align-self: flex-end;
992
+ align-items: flex-end;
993
+ }
994
+
995
+ .miniPgAssistant {
996
+ align-self: flex-start;
997
+ align-items: flex-start;
998
+ }
999
+
1000
+ .miniPgBubble {
1001
+ padding: 7px 11px;
1002
+ border-radius: 12px;
1003
+ font-size: 12px;
1004
+ line-height: 1.5;
1005
+ white-space: pre-wrap;
1006
+ word-break: break-word;
1007
+ color: var(--text-primary, #e0e0e0);
1008
+ }
1009
+
1010
+ .miniPgUser .miniPgBubble {
1011
+ background: rgba(0, 200, 100, 0.12);
1012
+ border: 1px solid rgba(0, 200, 100, 0.2);
1013
+ border-bottom-right-radius: 4px;
1014
+ }
1015
+
1016
+ .miniPgAssistant .miniPgBubble {
1017
+ background: var(--bg-secondary, #14141f);
1018
+ border: 1px solid var(--border, #1e1e2e);
1019
+ border-bottom-left-radius: 4px;
1020
+ }
1021
+
1022
+ .miniPgTyping {
1023
+ color: var(--accent, #00c864);
1024
+ letter-spacing: 3px;
1025
+ animation: probeSpin 0.9s steps(3, end) infinite;
1026
+ }
1027
+
1028
+ .miniPgError {
1029
+ display: block;
1030
+ margin-top: 4px;
1031
+ color: var(--error, #ff5a5a);
1032
+ font-size: 11px;
1033
+ }
1034
+
1035
+ .miniPgAborted {
1036
+ display: block;
1037
+ margin-top: 4px;
1038
+ color: var(--text-muted, #888);
1039
+ font-size: 10px;
1040
+ font-style: italic;
1041
+ }
1042
+
1043
+ /* 📖 Target badge under each user bubble — shows the Primary model the
1044
+ router will try first for this message (priority-first routing, #120).
1045
+ If failover kicks in, the reply's .miniPgRouted badge shows a different
1046
+ model, making the fallback chain visible at a glance. */
1047
+ .miniPgTarget {
1048
+ font-size: 10px;
1049
+ color: var(--text-muted, #888);
1050
+ font-family: 'SF Mono', monospace;
1051
+ padding: 0 4px;
1052
+ display: inline-flex;
1053
+ align-items: center;
1054
+ gap: 3px;
1055
+ }
1056
+
1057
+ .miniPgTarget svg {
1058
+ color: var(--accent, #00c864);
1059
+ }
1060
+
1061
+ /* 📖 Routed-via badge under each assistant reply — shows WHICH model the
1062
+ priority-first router actually picked for this message (issue #120). */
1063
+ .miniPgRouted {
1064
+ font-size: 10px;
1065
+ color: var(--text-muted, #888);
1066
+ font-family: 'SF Mono', monospace;
1067
+ padding: 0 4px;
1068
+ }
1069
+
1070
+ .miniPgRouted span {
1071
+ color: var(--accent, #00c864);
1072
+ }
1073
+
1074
+ .miniPgInputRow {
1075
+ display: flex;
1076
+ gap: 6px;
1077
+ align-items: stretch;
1078
+ }
1079
+
1080
+ .miniPgInput {
1081
+ flex: 1;
1082
+ padding: 8px 12px;
1083
+ background: var(--bg-primary, #0f0f17);
1084
+ border: 1px solid var(--border, #1e1e2e);
1085
+ border-radius: 8px;
1086
+ color: var(--text-primary, #e0e0e0);
1087
+ font-size: 12px;
1088
+ font-family: inherit;
1089
+ outline: none;
1090
+ transition: border-color 0.15s;
1091
+ }
1092
+
1093
+ .miniPgInput:focus {
1094
+ border-color: var(--accent, #00c864);
1095
+ }
1096
+
1097
+ .miniPgInput::placeholder {
1098
+ color: var(--text-muted, #888);
1099
+ }
1100
+
1101
+ .miniPgSendBtn,
1102
+ .miniPgStopBtn {
1103
+ display: flex;
1104
+ align-items: center;
1105
+ justify-content: center;
1106
+ width: 38px;
1107
+ border: none;
1108
+ border-radius: 8px;
1109
+ cursor: pointer;
1110
+ transition: opacity 0.15s, background 0.15s;
1111
+ flex-shrink: 0;
1112
+ }
1113
+
1114
+ .miniPgSendBtn {
1115
+ background: var(--accent, #00c864);
1116
+ color: #000;
1117
+ }
1118
+
1119
+ .miniPgSendBtn:disabled {
1120
+ opacity: 0.4;
1121
+ cursor: not-allowed;
1122
+ }
1123
+
1124
+ .miniPgStopBtn {
1125
+ background: var(--error, #ff5a5a);
1126
+ color: #fff;
1127
+ }