@polderlabs/bizar 5.3.1 → 5.4.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 (65) hide show
  1. package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js +4 -0
  2. package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileChat-9UpvDP6-.js +1 -0
  4. package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-9UpvDP6-.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js.map +1 -0
  7. package/bizar-dash/dist/assets/Toast-BlcKl_dN.js +1 -0
  8. package/bizar-dash/dist/assets/Toast-BlcKl_dN.js.map +1 -0
  9. package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-CMPxNV8t.js} +73 -73
  10. package/bizar-dash/dist/assets/icons-CMPxNV8t.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-Dhrvp9Gt.js +16 -0
  12. package/bizar-dash/dist/assets/main-Dhrvp9Gt.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-3G6mOhiK.css → main-O2tZVdHm.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-DDY1lquG.js +1 -0
  15. package/bizar-dash/dist/assets/mobile-DDY1lquG.js.map +1 -0
  16. package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js +2 -0
  17. package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js.map +1 -0
  18. package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-CJnZNLy3.css} +1 -1
  19. package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js +2 -0
  20. package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js.map +1 -0
  21. package/bizar-dash/dist/index.html +8 -6
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/web/MobileApp.tsx +18 -20
  25. package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
  26. package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
  27. package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
  28. package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
  29. package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
  30. package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
  31. package/bizar-dash/src/web/main.tsx +1 -0
  32. package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
  33. package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
  34. package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
  35. package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
  36. package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
  37. package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
  38. package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
  39. package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
  40. package/bizar-dash/src/web/mobile.tsx +1 -0
  41. package/bizar-dash/src/web/styles/main.css +388 -0
  42. package/bizar-dash/src/web/styles/memory.css +45 -0
  43. package/bizar-dash/src/web/styles/mobile-layout.css +233 -0
  44. package/bizar-dash/src/web/styles/mobile.css +180 -0
  45. package/bizar-dash/src/web/styles/settings.css +156 -0
  46. package/bizar-dash/src/web/styles/tasks.css +305 -0
  47. package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
  48. package/bizar-dash/tests/mobile-layout.test.tsx +332 -0
  49. package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
  50. package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
  51. package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
  52. package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
  53. package/package.json +1 -1
  54. package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
  55. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
  56. package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
  57. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
  58. package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
  59. package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
  60. package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
  61. package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
  62. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
  63. package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
  64. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
  65. package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js.map +0 -1
@@ -0,0 +1,106 @@
1
+ // src/web/mobile/MobileMarketplace.tsx — v5.4 mobile plugin marketplace with search.
2
+ import { useEffect, useState } from 'react';
3
+ import { Store, Search } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { MarketplacePluginCard, type MarketplacePlugin } from '../components/MarketplacePluginCard';
6
+
7
+ type RegistryResponse = {
8
+ plugins: MarketplacePlugin[];
9
+ };
10
+
11
+ export function MobileMarketplace() {
12
+ const [registry, setRegistry] = useState<RegistryResponse | null>(null);
13
+ const [search, setSearch] = useState('');
14
+ const [installing, setInstalling] = useState<MarketplacePlugin | null>(null);
15
+ const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
16
+ const [loading, setLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ api.get<RegistryResponse>('/plugins/registry')
20
+ .then((r) => setRegistry(r))
21
+ .catch(() => {/* best-effort */})
22
+ .finally(() => setLoading(false));
23
+ }, []);
24
+
25
+ const install = async (plugin: MarketplacePlugin) => {
26
+ setInstalling(plugin);
27
+ try {
28
+ await api.post('/plugins/install', { pluginId: plugin.id });
29
+ setInstalling(null);
30
+ setSelected(null);
31
+ } catch {
32
+ setInstalling(null);
33
+ }
34
+ };
35
+
36
+ const filtered = (registry?.plugins ?? []).filter(
37
+ (p) => !search || p.name.toLowerCase().includes(search.toLowerCase()),
38
+ );
39
+
40
+ if (loading) {
41
+ return (
42
+ <div className="mobile-loading">
43
+ <p>Loading marketplace…</p>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="mobile-marketplace">
50
+ <div className="mobile-marketplace-search">
51
+ <Search size={16} aria-hidden />
52
+ <input
53
+ type="search"
54
+ value={search}
55
+ onChange={(e) => setSearch(e.target.value)}
56
+ placeholder="Search plugins…"
57
+ aria-label="Search plugins"
58
+ />
59
+ </div>
60
+
61
+ <div className="mobile-marketplace-list">
62
+ {filtered.length === 0 ? (
63
+ <div className="mobile-empty">
64
+ <Store size={32} />
65
+ <p>No plugins{search ? ` match "${search}"` : ' in marketplace'}.</p>
66
+ </div>
67
+ ) : (
68
+ filtered.map((p) => (
69
+ <MarketplacePluginCard
70
+ key={p.id}
71
+ plugin={p}
72
+ onInstall={() => setSelected(p)}
73
+ onView={() => setSelected(p)}
74
+ />
75
+ ))
76
+ )}
77
+ </div>
78
+
79
+ {selected && (
80
+ <div className="mobile-plugin-install-dialog">
81
+ <div className="mobile-plugin-install-dialog-head">
82
+ <h3>{selected.name}</h3>
83
+ <p>{selected.description}</p>
84
+ </div>
85
+ <div className="mobile-plugin-install-dialog-actions">
86
+ <button
87
+ type="button"
88
+ className="mobile-btn mobile-btn-secondary"
89
+ onClick={() => setSelected(null)}
90
+ >
91
+ Cancel
92
+ </button>
93
+ <button
94
+ type="button"
95
+ className="mobile-btn mobile-btn-primary"
96
+ disabled={installing?.id === selected.id}
97
+ onClick={() => install(selected)}
98
+ >
99
+ {installing?.id === selected.id ? 'Installing…' : 'Install'}
100
+ </button>
101
+ </div>
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,108 @@
1
+ // src/web/mobile/MobileMemory.tsx — v5.4 mobile memory view with tabbed sources.
2
+ // Tab bar at top, scrollable source panels below.
3
+ import { useState } from 'react';
4
+ import {
5
+ Brain,
6
+ BarChart,
7
+ FileText,
8
+ GitBranch,
9
+ Search,
10
+ Mic,
11
+ Settings,
12
+ Network,
13
+ Clipboard,
14
+ Scan,
15
+ } from 'lucide-react';
16
+ import { cn } from '../lib/utils';
17
+ import { MemoryOverview } from '../views/memory/MemoryOverview';
18
+ import { LightragPanel } from '../views/memory/LightragPanel';
19
+ import { ObsidianPanel } from '../views/memory/ObsidianPanel';
20
+ import { GitSyncPanel } from '../views/memory/GitSyncPanel';
21
+ import { SemanticSearchPanel } from '../views/memory/SemanticSearchPanel';
22
+ import { ConfigPanel } from '../views/memory/ConfigPanel';
23
+ import { MemoryGraphPanel } from '../views/memory/MemoryGraphPanel';
24
+ import { VaultFromClipboardPanel } from '../views/memory/VaultFromClipboardPanel';
25
+ import { FromScreenshotPanel } from '../views/memory/FromScreenshotPanel';
26
+ import { VoiceNotesPanel } from '../components/VoiceNotesPanel';
27
+
28
+ type SubPanel = 'overview' | 'lightrag' | 'obsidian' | 'git' | 'semantic' | 'config' | 'graph' | 'webclip' | 'screenshot' | 'voice';
29
+
30
+ type Props = {
31
+ snapshot?: unknown;
32
+ settings?: unknown;
33
+ vaultPath?: string;
34
+ };
35
+
36
+ const SOURCES: Array<{ id: SubPanel; label: string; icon: typeof BarChart }> = [
37
+ { id: 'overview', label: 'Overview', icon: BarChart },
38
+ { id: 'lightrag', label: 'LightRAG', icon: Brain },
39
+ { id: 'obsidian', label: 'Obsidian', icon: FileText },
40
+ { id: 'git', label: 'Git Sync', icon: GitBranch },
41
+ { id: 'semantic', label: 'Search', icon: Search },
42
+ { id: 'voice', label: 'Voice', icon: Mic },
43
+ { id: 'config', label: 'Config', icon: Settings },
44
+ { id: 'graph', label: 'Graph', icon: Network },
45
+ { id: 'webclip', label: 'Web Clip', icon: Clipboard },
46
+ { id: 'screenshot', label: 'OCR', icon: Scan },
47
+ ];
48
+
49
+ export function MobileMemory({ vaultPath }: Props) {
50
+ const [activeSource, setActiveSource] = useState<SubPanel>('overview');
51
+ const [refreshKey, setRefreshKey] = useState(0);
52
+
53
+ const refresh = () => setRefreshKey((k) => k + 1);
54
+
55
+ const renderPanel = () => {
56
+ const key = refreshKey;
57
+ switch (activeSource) {
58
+ case 'overview':
59
+ return <MemoryOverview refreshKey={key} onRefresh={refresh} setActiveSubPanel={setActiveSource as (s: string) => void} />;
60
+ case 'lightrag':
61
+ return <LightragPanel refreshKey={key} />;
62
+ case 'obsidian':
63
+ return <ObsidianPanel refreshKey={key} />;
64
+ case 'git':
65
+ return <GitSyncPanel refreshKey={key} />;
66
+ case 'semantic':
67
+ return <SemanticSearchPanel refreshKey={key} />;
68
+ case 'config':
69
+ return <ConfigPanel refreshKey={key} />;
70
+ case 'graph':
71
+ return <MemoryGraphPanel refreshKey={key} />;
72
+ case 'webclip':
73
+ return <VaultFromClipboardPanel refreshKey={key} />;
74
+ case 'screenshot':
75
+ return <FromScreenshotPanel refreshKey={key} />;
76
+ case 'voice':
77
+ return <VoiceNotesPanel refreshKey={key} />;
78
+ default:
79
+ return null;
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className="mobile-memory">
85
+ <div className="mobile-memory-tabs">
86
+ {SOURCES.map((s) => {
87
+ const Icon = s.icon;
88
+ return (
89
+ <button
90
+ key={s.id}
91
+ type="button"
92
+ className={cn('mobile-memory-tab', activeSource === s.id && 'is-active')}
93
+ onClick={() => setActiveSource(s.id)}
94
+ aria-pressed={activeSource === s.id}
95
+ >
96
+ <Icon size={14} aria-hidden />
97
+ {s.label}
98
+ </button>
99
+ );
100
+ })}
101
+ </div>
102
+
103
+ <div className="mobile-memory-content">
104
+ {renderPanel()}
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,213 @@
1
+ // src/mobile/MobileOverview.tsx — mobile-optimized overview with vertical scrolling
2
+ // stat cards, a submit hero, recent activity feed, and quick action shortcuts.
3
+ import { useState } from 'react';
4
+ import {
5
+ CheckSquare,
6
+ Clock,
7
+ Bot,
8
+ Coins,
9
+ Plus,
10
+ MessageSquare,
11
+ Stethoscope,
12
+ Send,
13
+ } from 'lucide-react';
14
+ import { api } from '../lib/api';
15
+ import type { Snapshot } from '../lib/types';
16
+
17
+ type Props = {
18
+ snapshot: Snapshot | null;
19
+ refreshSnapshot: () => Promise<void>;
20
+ };
21
+
22
+ type StatColor = 'blue' | 'purple' | 'green' | 'yellow';
23
+
24
+ export function MobileOverview({ snapshot, refreshSnapshot }: Props) {
25
+ const [submitText, setSubmitText] = useState('');
26
+ const [submitting, setSubmitting] = useState(false);
27
+
28
+ const tasks = snapshot?.tasks || [];
29
+ const schedules = snapshot?.schedules || [];
30
+ const agents = snapshot?.agents || [];
31
+ const activeAgents = agents.filter((a) => a.status === 'running' || a.status === 'active').length;
32
+
33
+ const handleSubmit = async () => {
34
+ const text = submitText.trim();
35
+ if (!text || submitting) return;
36
+ setSubmitting(true);
37
+ try {
38
+ await api.post('/chat', { message: text, agent: 'odin' });
39
+ setSubmitText('');
40
+ await refreshSnapshot().catch(() => undefined);
41
+ } catch {
42
+ // best-effort; the input stays so the user can retry
43
+ } finally {
44
+ setSubmitting(false);
45
+ }
46
+ };
47
+
48
+ const goToTasks = () => {
49
+ window.dispatchEvent(new CustomEvent('mobile-navigate', { detail: { tab: 'tasks' } }));
50
+ };
51
+ const goToChat = () => {
52
+ window.dispatchEvent(new CustomEvent('mobile-navigate', { detail: { tab: 'chat' } }));
53
+ };
54
+ const runDoctor = () => {
55
+ window.dispatchEvent(new CustomEvent('mobile-navigate', { detail: { view: 'doctor' } }));
56
+ };
57
+
58
+ return (
59
+ <div className="mobile-overview mobile-view">
60
+ <HeroCard
61
+ value={submitText}
62
+ onChange={setSubmitText}
63
+ onSubmit={handleSubmit}
64
+ submitting={submitting}
65
+ />
66
+
67
+ <div className="mobile-stats-stack">
68
+ <StatCard
69
+ icon={<CheckSquare size={20} />}
70
+ label="Tasks"
71
+ value={tasks.length}
72
+ subtext={`${tasks.filter((t) => t.status === 'doing').length} active`}
73
+ color="blue"
74
+ />
75
+ <StatCard
76
+ icon={<Clock size={20} />}
77
+ label="Schedules"
78
+ value={schedules.length}
79
+ color="purple"
80
+ />
81
+ <StatCard
82
+ icon={<Bot size={20} />}
83
+ label="Active Agents"
84
+ value={activeAgents}
85
+ color="green"
86
+ />
87
+ <StatCard
88
+ icon={<Coins size={20} />}
89
+ label="API Tokens"
90
+ value={'0'}
91
+ subtext="last 24h"
92
+ color="yellow"
93
+ />
94
+ </div>
95
+
96
+ <RecentActivityCard snapshot={snapshot} />
97
+
98
+ <QuickActionsCard onNewTask={goToTasks} onNewChat={goToChat} onRunDoctor={runDoctor} />
99
+ </div>
100
+ );
101
+ }
102
+
103
+ type HeroCardProps = {
104
+ value: string;
105
+ onChange: (next: string) => void;
106
+ onSubmit: () => void;
107
+ submitting: boolean;
108
+ };
109
+
110
+ function HeroCard({ value, onChange, onSubmit, submitting }: HeroCardProps) {
111
+ return (
112
+ <div className="mobile-hero">
113
+ <h1>What do you want to do?</h1>
114
+ <p>
115
+ Describe what you want — Odin will split it into tasks, create a plan, delegate to
116
+ background agents, and track progress in real time.
117
+ </p>
118
+ <textarea
119
+ placeholder="e.g. Implement user authentication with email + password..."
120
+ className="mobile-hero-input"
121
+ rows={3}
122
+ value={value}
123
+ onChange={(e) => onChange(e.target.value)}
124
+ />
125
+ <button
126
+ type="button"
127
+ className="mobile-hero-submit"
128
+ onClick={onSubmit}
129
+ disabled={!value.trim() || submitting}
130
+ >
131
+ {submitting ? 'Submitting…' : (
132
+ <>
133
+ <Send size={14} /> Submit
134
+ </>
135
+ )}
136
+ </button>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ type StatCardProps = {
142
+ icon: React.ReactNode;
143
+ label: string;
144
+ value: number | string;
145
+ subtext?: string;
146
+ color: StatColor;
147
+ };
148
+
149
+ function StatCard({ icon, label, value, subtext, color }: StatCardProps) {
150
+ return (
151
+ <div className={`mobile-stat-card is-${color}`}>
152
+ <div className="mobile-stat-icon">{icon}</div>
153
+ <div className="mobile-stat-body">
154
+ <div className="mobile-stat-label">{label}</div>
155
+ <div className="mobile-stat-value">{value}</div>
156
+ {subtext && <div className="mobile-stat-subtext">{subtext}</div>}
157
+ </div>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function RecentActivityCard({ snapshot }: { snapshot: Snapshot | null }) {
163
+ const items = (snapshot?.overview?.recentActivity || []).slice(0, 8);
164
+ return (
165
+ <div className="mobile-activity-card">
166
+ <h3>Recent Activity</h3>
167
+ {items.length === 0 ? (
168
+ <p className="mobile-empty">No recent activity</p>
169
+ ) : (
170
+ <ul className="mobile-activity-list">
171
+ {items.map((a, i) => {
172
+ const ts = typeof a.ts === 'string' ? a.ts : '';
173
+ const text = (a as Record<string, unknown>).message
174
+ || (a as Record<string, unknown>).text
175
+ || (a as Record<string, unknown>).title
176
+ || a.kind
177
+ || '';
178
+ return (
179
+ <li key={`${ts}-${i}`} className="mobile-activity-item">
180
+ <span className="mobile-activity-time">
181
+ {ts ? new Date(ts).toLocaleTimeString() : ''}
182
+ </span>
183
+ <span className="mobile-activity-text">{String(text)}</span>
184
+ </li>
185
+ );
186
+ })}
187
+ </ul>
188
+ )}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ type QuickActionsCardProps = {
194
+ onNewTask: () => void;
195
+ onNewChat: () => void;
196
+ onRunDoctor: () => void;
197
+ };
198
+
199
+ function QuickActionsCard({ onNewTask, onNewChat, onRunDoctor }: QuickActionsCardProps) {
200
+ return (
201
+ <div className="mobile-quick-actions">
202
+ <button type="button" className="mobile-quick-action" onClick={onNewTask}>
203
+ <Plus size={16} /> New task
204
+ </button>
205
+ <button type="button" className="mobile-quick-action" onClick={onNewChat}>
206
+ <MessageSquare size={16} /> New chat
207
+ </button>
208
+ <button type="button" className="mobile-quick-action" onClick={onRunDoctor}>
209
+ <Stethoscope size={16} /> Run doctor
210
+ </button>
211
+ </div>
212
+ );
213
+ }
@@ -0,0 +1,71 @@
1
+ // src/web/mobile/MobilePlugins.tsx — v5.4 mobile installed plugins list with search.
2
+ import { useEffect, useState } from 'react';
3
+ import { Puzzle, Search } from 'lucide-react';
4
+ import { api } from '../lib/api';
5
+ import { PluginPermissions } from '../components/PluginPermissions';
6
+ import type { InstalledPlugin } from '../components/PluginCard';
7
+
8
+ export function MobilePlugins() {
9
+ const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
10
+ const [loading, setLoading] = useState(true);
11
+ const [search, setSearch] = useState('');
12
+
13
+ useEffect(() => {
14
+ api.get<{ plugins: InstalledPlugin[] }>('/plugins/installed')
15
+ .then((r) => setPlugins(r.plugins ?? []))
16
+ .catch(() => {/* best-effort */})
17
+ .finally(() => setLoading(false));
18
+ }, []);
19
+
20
+ const filtered = plugins.filter(
21
+ (p) => !search || p.name.toLowerCase().includes(search.toLowerCase()),
22
+ );
23
+
24
+ if (loading) {
25
+ return (
26
+ <div className="mobile-loading">
27
+ <p>Loading plugins…</p>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ return (
33
+ <div className="mobile-plugins">
34
+ <div className="mobile-plugins-search">
35
+ <Search size={16} aria-hidden />
36
+ <input
37
+ type="search"
38
+ value={search}
39
+ onChange={(e) => setSearch(e.target.value)}
40
+ placeholder="Search installed plugins…"
41
+ aria-label="Search plugins"
42
+ />
43
+ </div>
44
+
45
+ <div className="mobile-plugins-list">
46
+ {filtered.length === 0 ? (
47
+ <div className="mobile-empty">
48
+ <Puzzle size={32} />
49
+ <p>{search ? `No plugins match "${search}".` : 'No plugins installed.'}</p>
50
+ </div>
51
+ ) : (
52
+ filtered.map((p) => (
53
+ <div key={p.id} className="mobile-plugin-card">
54
+ <div className="mobile-plugin-card-head">
55
+ <div className="mobile-plugin-name">{p.name}</div>
56
+ <div className="mobile-plugin-version">v{p.version}</div>
57
+ </div>
58
+ {p.description && (
59
+ <p className="mobile-plugin-description">{p.description}</p>
60
+ )}
61
+ <PluginPermissions permissions={p.permissions ?? []} />
62
+ <div className="mobile-plugin-stats">
63
+ {p.invocations ?? 0} invocations
64
+ </div>
65
+ </div>
66
+ ))
67
+ )}
68
+ </div>
69
+ </div>
70
+ );
71
+ }