feat(M6): Weitere Hosts — MiniApps in Fenstern + AI-Agenten-Ausgabe-Bloecke (#364)

- Windows-Host: openMiniAppWindow-Helper + MiniAppWindowContent (windowStore);
  Oeffnen-Buttons im Chat-Block (MiniAppBlock) und Dashboard-Widget
- AI-Agenten-Host: Core-Tool send_miniapp (app/ai/miniapp_tools.py) —
  miniapp-Block in Agent-Chat (approval_request-Praezedenz), Permission
  fail-closed gegen aufrufenden User pro App; Registrierung im lifespan
- agent_loop: tool_context + agent_name (Raum-Aufloesung)
- Fix: MiniAppBlock nutzt useMiniapps (component-Feld) statt Legacy /comm/miniapps
- Tests: M6 7/7 (TDD rot->gruen), Backend-Regression 57/57, Vitest 26/26
  (4 neue Window-Tests), tsc clean, build OK
This commit is contained in:
Agent Zero
2026-08-31 01:04:33 +02:00
parent 63aa0cf788
commit 335762dd3d
11 changed files with 538 additions and 29 deletions
@@ -0,0 +1,103 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent';
import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow';
import { useWindowStore } from '@/store/windowStore';
/**
* M6 — Windows host tests: MiniApps open in floating windows via the
* existing window manager (openMiniAppWindow + MiniAppWindowContent).
*/
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('@/api/miniapps', () => ({
useMiniapps: () => ({
data: {
items: [
{
app_id: 'recent_contacts', name: 'Recent Contacts', icon: 'Users', description: 'desc',
plugin_name: 'contacts', render_schema: {}, permission: '', settings_schema: {},
col_span: 2, row_span: 1, hosts: ['chat', 'dashboard', 'window'],
component: '@/components/dashboard/RecentContactsWidget', order: 10, builtin: true,
},
],
total: 1,
},
isLoading: false,
}),
}));
vi.mock('@/components/dashboard/MiniAppHost', () => ({
MiniAppHost: ({ appId }: { appId: string }) => (
<div data-testid={`miniapp-${appId}`}>host:{appId}</div>
),
}));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderContent() {
return render(
<QueryClientProvider client={queryClient}>
<MiniAppWindowContent appId="recent_contacts" settings={{ limit: 5 }} />
</QueryClientProvider>
);
}
describe('MiniAppWindowContent', () => {
it('renders the MiniApp host with the given app id', () => {
renderContent();
expect(screen.getByTestId('miniapp-window-recent_contacts')).toBeInTheDocument();
expect(screen.getByTestId('miniapp-recent_contacts')).toBeInTheDocument();
});
it('shows the app description when present', () => {
renderContent();
expect(screen.getByText('desc')).toBeInTheDocument();
});
});
describe('openMiniAppWindow', () => {
it('opens a window with miniapp type and props', () => {
const spy = vi.fn();
// spy on the store's openWindow implementation
const state = useWindowStore.getState();
const origOpen = state.openWindow;
useWindowStore.setState({ openWindow: spy as never });
openMiniAppWindow({
appId: 'recent_contacts',
settings: { limit: 3 },
component: MiniAppWindowContent as never,
});
expect(spy).toHaveBeenCalledTimes(1);
const config = spy.mock.calls[0][0];
expect(config.type).toBe('miniapp-recent_contacts');
expect(config.title).toBe('recent_contacts');
expect(config.componentProps).toEqual({ appId: 'recent_contacts', settings: { limit: 3 } });
// restore
useWindowStore.setState({ openWindow: origOpen as never, windows: [] });
});
it('uses the def name as window title when provided', () => {
const spy = vi.fn();
const state = useWindowStore.getState();
const origOpen = state.openWindow;
useWindowStore.setState({ openWindow: spy as never });
openMiniAppWindow({
appId: 'x',
def: { name: 'Mein Widget' } as never,
component: MiniAppWindowContent as never,
});
expect(spy.mock.calls[0][0].title).toBe('Mein Widget');
useWindowStore.setState({ openWindow: origOpen as never, windows: [] });
});
});
@@ -1,42 +1,36 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageBlock } from '@/store/commStore';
import { AppWindow, Loader2 } from 'lucide-react';
import { apiClient } from '@/api/client';
import { AppWindow, ExternalLink, Loader2 } from 'lucide-react';
import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent';
import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow';
interface MiniAppBlockProps {
block: MessageBlock;
}
interface MiniAppDef {
app_id: string;
name: string;
icon: string;
description: string;
plugin_name: string;
render_schema: Record<string, any>;
}
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
const { t } = useTranslation();
const { app_id, config } = block.block_data;
const [appDef, setAppDef] = useState<MiniAppDef | null>(null);
const [loading, setLoading] = useState(false);
// M6: universal registry endpoint (carries component/permission fields;
// the legacy /comm/miniapps list never did, hiding the window button)
const { data: miniappsData, isLoading: loading } = useMiniapps();
const appDef: MiniAppDef | null =
miniappsData?.items.find((a) => a.app_id === app_id) ?? null;
useEffect(() => {
if (!app_id) return;
setLoading(true);
apiClient.get('/comm/miniapps')
.then((res) => {
const apps: MiniAppDef[] = res.data || [];
const found = apps.find((a) => a.app_id === app_id);
setAppDef(found || null);
})
.catch(() => setAppDef(null))
.finally(() => setLoading(false));
}, [app_id]);
const canOpenInWindow = Boolean(appDef?.component);
const openInWindow = () =>
openMiniAppWindow({
appId: app_id || '',
def: appDef ?? undefined,
settings: (config as Record<string, unknown>) ?? {},
component: MiniAppWindowContent,
});
const appId: string = app_id || 'Unbekannt';
const hasConfig = config && typeof config === 'object' && Object.keys(config).length > 0;
const schema = appDef?.render_schema;
const schema = appDef?.render_schema as { fields?: { name: string; label?: string }[] } | undefined;
const hasSchema = schema && Object.keys(schema).length > 0;
if (loading) {
@@ -60,6 +54,18 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
{appDef?.plugin_name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary-100 text-secondary-600">{appDef.plugin_name}</span>
)}
{canOpenInWindow && (
<button
type="button"
onClick={openInWindow}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-400 hover:text-primary-600"
aria-label={t('dashboard.openInWindow')}
title={t('dashboard.openInWindow')}
data-testid={`miniapp-open-window-${app_id}`}
>
<ExternalLink className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>
{hasSchema && schema.fields && (
@@ -28,6 +28,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
ExternalLink,
LayoutDashboard,
Pencil,
Check,
@@ -56,6 +57,8 @@ import {
} from '@/api/dashboards';
import { useMiniapps, renderableDashboardApps, type MiniAppDef } from '@/api/miniapps';
import { MiniAppHost } from './MiniAppHost';
import { MiniAppWindowContent } from './MiniAppWindowContent';
import { openMiniAppWindow } from './openMiniAppWindow';
import { WidgetSettingsForm } from './WidgetSettingsForm';
let widgetIdCounter = 0;
@@ -166,6 +169,22 @@ function SortableWidget({ widget, def, editMode, onRemove, onResize, onOpenSetti
>
<Plus className="w-3.5 h-3.5 rotate-90" aria-hidden="true" />
</button>
<button
type="button"
onClick={() =>
openMiniAppWindow({
appId: widget.app_id,
def,
settings: widget.settings,
component: MiniAppWindowContent,
})
}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.openInWindow')}
data-testid="dashboard-widget-open-window"
>
<ExternalLink className="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={onOpenSettings}
@@ -0,0 +1,38 @@
/**
* MiniAppWindowContent — window host for a MiniApp (Phase M6).
*
* Rendered inside the window manager's floating windows: resolves the
* MiniApp definition (name, settings_schema) and renders the shared
* MiniAppHost as the window body.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useMiniapps } from '@/api/miniapps';
import { MiniAppHost } from '@/components/dashboard/MiniAppHost';
export interface MiniAppWindowContentProps {
appId: string;
settings?: Record<string, unknown>;
}
export function MiniAppWindowContent({ appId, settings }: MiniAppWindowContentProps) {
const { t } = useTranslation();
const { data, isLoading } = useMiniapps();
const def = data?.items.find((a) => a.app_id === appId);
return (
<div className="p-4 h-full overflow-auto" data-testid={`miniapp-window-${appId}`}>
{isLoading ? (
<p className="text-sm text-secondary-500">{t('common.loading', 'Lädt…')}</p>
) : (
<>
{def?.description && (
<p className="text-xs text-secondary-400 mb-3">{def.description}</p>
)}
<MiniAppHost appId={appId} settings={settings ?? {}} def={def} />
</>
)}
</div>
);
}
@@ -0,0 +1,37 @@
/**
* openMiniAppWindow — open a MiniApp in a floating window (Phase M6).
*
* Windows host: any inline MiniApp (chat block, dashboard widget) can be
* popped into a draggable window via the existing window manager.
*/
import type { ComponentType } from 'react';
import { useWindowStore } from '@/store/windowStore';
import type { MiniAppDef } from '@/api/miniapps';
export interface OpenMiniAppWindowOptions {
appId: string;
title?: string;
settings?: Record<string, unknown>;
def?: MiniAppDef;
component: ComponentType<{ appId: string; settings?: Record<string, unknown> }>;
width?: number;
height?: number;
}
export function openMiniAppWindow(opts: OpenMiniAppWindowOptions): string {
const store = useWindowStore.getState();
const title = opts.title ?? opts.def?.name ?? opts.appId;
const id = store.openWindow({
title,
type: `miniapp-${opts.appId}`,
component: opts.component,
componentProps: {
appId: opts.appId,
settings: opts.settings ?? {},
},
});
// openWindow applies the default size; MiniApps get a compact default
store.updateWindowSize(id, { width: opts.width ?? 520, height: opts.height ?? 480 });
return id;
}
+2 -1
View File
@@ -163,7 +163,8 @@
"taller": "Höher",
"shorter": "Niedriger"
},
"systemMetricsNoAccess": "Systemmetriken erfordern Admin-Rechte."
"systemMetricsNoAccess": "Systemmetriken erfordern Admin-Rechte.",
"openInWindow": "In Fenster öffnen"
},
"companies": {
"title": "Firmen",
+2 -1
View File
@@ -163,7 +163,8 @@
"taller": "Taller",
"shorter": "Shorter"
},
"systemMetricsNoAccess": "System metrics require admin rights."
"systemMetricsNoAccess": "System metrics require admin rights.",
"openInWindow": "Open in window"
},
"companies": {
"title": "Companies",