@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.
- package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js +4 -0
- package/bizar-dash/dist/assets/EnvVarsSection-DhLHZ5ua.js.map +1 -0
- package/bizar-dash/dist/assets/MobileChat-9UpvDP6-.js +1 -0
- package/bizar-dash/dist/assets/{MobileChat-OidrxXR9.js.map → MobileChat-9UpvDP6-.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BFmN9ZWR.js.map +1 -0
- package/bizar-dash/dist/assets/Toast-BlcKl_dN.js +1 -0
- package/bizar-dash/dist/assets/Toast-BlcKl_dN.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bd8piSb2.js → icons-CMPxNV8t.js} +73 -73
- package/bizar-dash/dist/assets/icons-CMPxNV8t.js.map +1 -0
- package/bizar-dash/dist/assets/main-Dhrvp9Gt.js +16 -0
- package/bizar-dash/dist/assets/main-Dhrvp9Gt.js.map +1 -0
- package/bizar-dash/dist/assets/{main-3G6mOhiK.css → main-O2tZVdHm.css} +1 -1
- package/bizar-dash/dist/assets/mobile-DDY1lquG.js +1 -0
- package/bizar-dash/dist/assets/mobile-DDY1lquG.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js +2 -0
- package/bizar-dash/dist/assets/mobile-layout-BjkoHh1W.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-CsZQAswA.css → mobile-layout-CJnZNLy3.css} +1 -1
- package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-eFCiWHVt.js.map +1 -0
- package/bizar-dash/dist/index.html +8 -6
- package/bizar-dash/dist/mobile.html +4 -4
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/web/MobileApp.tsx +18 -20
- package/bizar-dash/src/web/components/MobileBottomNav.tsx +38 -0
- package/bizar-dash/src/web/components/MobileDrawer.tsx +68 -0
- package/bizar-dash/src/web/components/MobileHeader.tsx +50 -0
- package/bizar-dash/src/web/components/MobileLayout.tsx +63 -0
- package/bizar-dash/src/web/components/TaskCard.tsx +69 -0
- package/bizar-dash/src/web/components/TaskCreateSheet.tsx +76 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/mobile/MobileDoctor.tsx +80 -0
- package/bizar-dash/src/web/mobile/MobileEval.tsx +167 -0
- package/bizar-dash/src/web/mobile/MobileMarketplace.tsx +106 -0
- package/bizar-dash/src/web/mobile/MobileMemory.tsx +108 -0
- package/bizar-dash/src/web/mobile/MobileOverview.tsx +213 -0
- package/bizar-dash/src/web/mobile/MobilePlugins.tsx +71 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +235 -180
- package/bizar-dash/src/web/mobile/MobileTasks.tsx +94 -0
- package/bizar-dash/src/web/mobile.tsx +1 -0
- package/bizar-dash/src/web/styles/main.css +388 -0
- package/bizar-dash/src/web/styles/memory.css +45 -0
- package/bizar-dash/src/web/styles/mobile-layout.css +233 -0
- package/bizar-dash/src/web/styles/mobile.css +180 -0
- package/bizar-dash/src/web/styles/settings.css +156 -0
- package/bizar-dash/src/web/styles/tasks.css +305 -0
- package/bizar-dash/tests/mobile-chat.test.tsx +82 -0
- package/bizar-dash/tests/mobile-layout.test.tsx +332 -0
- package/bizar-dash/tests/mobile-misc.test.tsx +321 -0
- package/bizar-dash/tests/mobile-overview.test.tsx +214 -0
- package/bizar-dash/tests/mobile-settings.test.tsx +181 -0
- package/bizar-dash/tests/mobile-tasks.test.tsx +162 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/MobileChat-OidrxXR9.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-B91gVFii.js.map +0 -1
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +0 -1
- package/bizar-dash/dist/assets/main-6YUZY968.js +0 -19
- package/bizar-dash/dist/assets/main-6YUZY968.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CSsp30lr.js +0 -2
- package/bizar-dash/dist/assets/mobile-CSsp30lr.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DtwK7DOV.js +0 -1
- package/bizar-dash/dist/assets/mobile-DtwK7DOV.js.map +0 -1
- package/bizar-dash/dist/assets/useSlashCommands-DEkxEvOO.js +0 -2
- 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
|
+
}
|