@nextclaw/ui 0.12.35-beta.5 → 0.12.35-beta.7
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/CHANGELOG.md +126 -0
- package/dist/assets/api-DruAcori.js +15 -0
- package/dist/assets/{book-open-qc0UVn6m.js → book-open-cPX0Za7Z.js} +1 -1
- package/dist/assets/channels-list-page-D-tv11dc.js +8 -0
- package/dist/assets/chat-page-MyXEZI8R.js +105 -0
- package/dist/assets/{config-split-page-DW8tP9Bc.js → config-split-page-BFh_Qixx.js} +1 -1
- package/dist/assets/confirm-dialog-B0TP-vPd.js +5 -0
- package/dist/assets/{createLucideIcon-CA0sgoMJ.js → createLucideIcon-Br1EFMEr.js} +1 -1
- package/dist/assets/{desktop-update-config-DARSwQG8.js → desktop-update-config-oZpIKKtS.js} +1 -1
- package/dist/assets/{dist-D5zsy7pX.js → dist-CpwDZDtv.js} +1 -1
- package/dist/assets/{dist-DNxIa3pe.js → dist-rzakR_bu.js} +1 -1
- package/dist/assets/doc-browser-B2OFjScU.js +3 -0
- package/dist/assets/doc-browser-BrUQEZ1j.js +1 -0
- package/dist/assets/doc-browser-context-DB6TTHeE.js +1 -0
- package/dist/assets/doc-browser-route-registry.utils-x5ijvk0u.js +1 -0
- package/dist/assets/{external-link-C9fbyi6k.js → external-link-C3usm3se.js} +1 -1
- package/dist/assets/index-D2H2CKYR.js +101 -0
- package/dist/assets/index-v0fAGXQW.css +1 -0
- package/dist/assets/{mcp-marketplace-page-BiExW5wq.js → mcp-marketplace-page-CegP14Hl.js} +2 -2
- package/dist/assets/mcp-marketplace-page-MjpFEEkY.js +1 -0
- package/dist/assets/model-config-fIE_Asps.js +1 -0
- package/dist/assets/play-CqNi0u02.js +1 -0
- package/dist/assets/plus-BcIC8dG1.js +1 -0
- package/dist/assets/{provider-scoped-model-input-Bk8jlpRm.js → provider-scoped-model-input-D4zRsGZ6.js} +1 -1
- package/dist/assets/providers-list-CqsOk2hr.js +1 -0
- package/dist/assets/react-B2X_ph0a.js +1 -0
- package/dist/assets/remote-BMWQUPDW.js +1 -0
- package/dist/assets/runtime-config-page-BJ86cC8a.js +1 -0
- package/dist/assets/{save-CG4Hb1u8.js → save-D10M5vQg.js} +1 -1
- package/dist/assets/{search-lWpOIHEn.js → search-CdhgFNwf.js} +1 -1
- package/dist/assets/search-config-TbCPlRE7.js +1 -0
- package/dist/assets/{secrets-config-ZbLzjIRx.js → secrets-config-DBIEKP_V.js} +1 -1
- package/dist/assets/{setting-row-CNfTKDjc.js → setting-row-8VkukJoH.js} +1 -1
- package/dist/assets/{tabs-custom-BbZoysAl.js → tabs-custom-BBXu1OGs.js} +1 -1
- package/dist/assets/{tag-chip-Dl8JbhC7.js → tag-chip-ix15P-r4.js} +1 -1
- package/dist/assets/wrench-qVIwShTF.js +1 -0
- package/dist/assets/x-Chb07O-Y.js +1 -0
- package/dist/index.html +17 -15
- package/package.json +8 -8
- package/src/features/panel-apps/components/panel-app-list-item.test.tsx +60 -0
- package/src/features/panel-apps/components/panel-app-list-item.tsx +120 -30
- package/src/features/panel-apps/components/panel-apps-list.tsx +8 -1
- package/src/features/panel-apps/hooks/use-panel-apps.ts +10 -0
- package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +85 -1
- package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +123 -6
- package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +7 -3
- package/src/features/panel-apps/utils/panel-app-view.utils.test.ts +66 -0
- package/src/features/panel-apps/utils/panel-app-view.utils.ts +13 -2
- package/src/index.css +23 -0
- package/src/shared/components/doc-browser/doc-browser-context.test.tsx +170 -0
- package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
- package/src/shared/components/doc-browser/doc-browser-home-page.tsx +95 -0
- package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +0 -90
- package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +117 -44
- package/src/shared/components/doc-browser/doc-browser.test.tsx +159 -1
- package/src/shared/components/doc-browser/doc-browser.tsx +42 -23
- package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +183 -71
- package/src/shared/components/doc-browser/stores/doc-browser.store.ts +52 -1
- package/src/shared/components/doc-browser/types/doc-browser.types.ts +9 -0
- package/src/shared/components/doc-browser/utils/doc-browser-route-registry.utils.ts +232 -0
- package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +11 -0
- package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +2 -0
- package/src/shared/lib/api/types.ts +1 -0
- package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +10 -0
- package/dist/assets/api-BTZui7nj.js +0 -15
- package/dist/assets/channels-list-page-DBtU_M1Y.js +0 -8
- package/dist/assets/chat-page-BcoJYo91.js +0 -105
- package/dist/assets/doc-browser-B9skxzVo.js +0 -1
- package/dist/assets/doc-browser-CwyITRXx.js +0 -1
- package/dist/assets/doc-browser-context-Do2qSrB8.js +0 -1
- package/dist/assets/index-BYRP_BMj.css +0 -1
- package/dist/assets/index-DckPGTdM.js +0 -103
- package/dist/assets/mcp-marketplace-page-Cj6MDSkE.js +0 -1
- package/dist/assets/model-config-DbdY7fF1.js +0 -1
- package/dist/assets/notice-card-CyiVcu_Z.js +0 -5
- package/dist/assets/play-D-s0C4MN.js +0 -1
- package/dist/assets/plus-DOwjM_kF.js +0 -1
- package/dist/assets/providers-list-CJjNStDV.js +0 -1
- package/dist/assets/react-BaRotFd6.js +0 -1
- package/dist/assets/remote-gl8QckNc.js +0 -1
- package/dist/assets/runtime-config-page-BMFc1yzf.js +0 -1
- package/dist/assets/search-config-DUOR26if.js +0 -1
- package/dist/assets/x-D6GwT_8k.js +0 -1
- /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-CY1lg9IE.js} +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { PanelAppListItem } from './panel-app-list-item';
|
|
5
|
+
|
|
6
|
+
const baseEntry = {
|
|
7
|
+
id: 'demo',
|
|
8
|
+
fileName: 'demo.panel.html',
|
|
9
|
+
title: 'Demo Panel',
|
|
10
|
+
description: 'A compact panel app with a longer description that should stay on one line.',
|
|
11
|
+
contentPath: '/api/panel-apps/demo/content',
|
|
12
|
+
createdAt: '2026-05-28T08:00:00.000Z',
|
|
13
|
+
updatedAt: '2026-05-28T09:00:00.000Z',
|
|
14
|
+
sizeBytes: 12,
|
|
15
|
+
favorite: false,
|
|
16
|
+
openCount: 0,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('PanelAppListItem', () => {
|
|
20
|
+
it('keeps panel app metadata compact below the icon-title row', () => {
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<PanelAppListItem
|
|
23
|
+
deletePending={false}
|
|
24
|
+
entry={baseEntry}
|
|
25
|
+
favoritePending={false}
|
|
26
|
+
onDelete={vi.fn()}
|
|
27
|
+
onOpen={vi.fn()}
|
|
28
|
+
onToggleFavorite={vi.fn()}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByText(baseEntry.description).className).toContain('truncate');
|
|
33
|
+
expect(container.querySelector('.line-clamp-2')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('opens delete from the more-actions menu only after confirmation', async () => {
|
|
37
|
+
const user = userEvent.setup();
|
|
38
|
+
const onDelete = vi.fn();
|
|
39
|
+
render(
|
|
40
|
+
<PanelAppListItem
|
|
41
|
+
deletePending={false}
|
|
42
|
+
entry={baseEntry}
|
|
43
|
+
favoritePending={false}
|
|
44
|
+
onDelete={onDelete}
|
|
45
|
+
onOpen={vi.fn()}
|
|
46
|
+
onToggleFavorite={vi.fn()}
|
|
47
|
+
/>,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
|
|
51
|
+
await user.click(screen.getByRole('button', { name: 'Delete panel app' }));
|
|
52
|
+
|
|
53
|
+
expect(onDelete).not.toHaveBeenCalled();
|
|
54
|
+
expect(screen.getByText('Delete panel app?')).toBeTruthy();
|
|
55
|
+
|
|
56
|
+
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
|
57
|
+
|
|
58
|
+
expect(onDelete).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1,60 +1,150 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { AppWindow, Star } from 'lucide-react';
|
|
1
|
+
import { useState, type MouseEvent } from 'react';
|
|
2
|
+
import { AppWindow, MoreVertical, Star, Trash2, type LucideIcon } from 'lucide-react';
|
|
3
3
|
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
4
|
-
import {
|
|
4
|
+
import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
|
|
6
|
+
import { getLanguage, getLocale, t } from '@/shared/lib/i18n';
|
|
7
|
+
import { cn } from '@/shared/lib/utils';
|
|
5
8
|
|
|
6
9
|
export function PanelAppListItem({
|
|
10
|
+
deletePending,
|
|
7
11
|
entry,
|
|
8
12
|
favoritePending,
|
|
13
|
+
onDelete,
|
|
9
14
|
onOpen,
|
|
10
15
|
onToggleFavorite,
|
|
11
16
|
}: {
|
|
17
|
+
deletePending: boolean;
|
|
12
18
|
entry: PanelAppEntryView;
|
|
13
19
|
favoritePending: boolean;
|
|
20
|
+
onDelete: () => void;
|
|
14
21
|
onOpen: () => void;
|
|
15
22
|
onToggleFavorite: () => void;
|
|
16
23
|
}) {
|
|
24
|
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
25
|
+
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
17
26
|
const favoriteLabel = entry.favorite ? t('panelAppsUnfavorite') : t('panelAppsFavorite');
|
|
18
27
|
const secondary = entry.lastOpenedAt
|
|
19
|
-
? `${t('panelAppsLastOpened')} ${
|
|
20
|
-
: `${t('panelAppsUpdated')} ${
|
|
28
|
+
? `${t('panelAppsLastOpened')} ${formatPanelAppTime(entry.lastOpenedAt)}`
|
|
29
|
+
: `${t('panelAppsUpdated')} ${formatPanelAppTime(entry.updatedAt)}`;
|
|
30
|
+
|
|
21
31
|
const handleFavorite = (event: MouseEvent<HTMLButtonElement>) => {
|
|
22
32
|
event.stopPropagation();
|
|
23
33
|
onToggleFavorite();
|
|
24
34
|
};
|
|
35
|
+
const openDeleteDialog = () => {
|
|
36
|
+
setIsMenuOpen(false);
|
|
37
|
+
setIsDeleteDialogOpen(true);
|
|
38
|
+
};
|
|
25
39
|
|
|
26
40
|
return (
|
|
27
|
-
<div className="group
|
|
28
|
-
<
|
|
29
|
-
type="button"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<span className="min-w-0 flex-1">
|
|
37
|
-
<span className="block truncate text-sm font-medium text-gray-900">{entry.title}</span>
|
|
41
|
+
<div className="group w-full min-w-0 rounded-lg border border-gray-200 bg-white px-2.5 py-2.5 transition-colors hover:border-amber-200 hover:bg-amber-50/50">
|
|
42
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
43
|
+
<button type="button" onClick={onOpen} className="min-w-0 flex-1 text-left">
|
|
44
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
45
|
+
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-amber-50 text-base text-amber-700">
|
|
46
|
+
<PanelAppIcon icon={entry.icon} title={entry.title} />
|
|
47
|
+
</span>
|
|
48
|
+
<span className="block min-w-0 flex-1 truncate text-sm font-medium text-gray-900">{entry.title}</span>
|
|
49
|
+
</span>
|
|
38
50
|
{entry.description ? (
|
|
39
|
-
<span className="mt-
|
|
51
|
+
<span className="mt-1.5 block truncate text-xs leading-5 text-gray-600">{entry.description}</span>
|
|
40
52
|
) : null}
|
|
41
|
-
<span className="mt-
|
|
42
|
-
</
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
<span className="mt-0.5 block truncate text-[11px] leading-4 text-gray-400">{secondary}</span>
|
|
54
|
+
</button>
|
|
55
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={handleFavorite}
|
|
59
|
+
disabled={favoritePending}
|
|
60
|
+
className="rounded-md p-1.5 text-gray-400 transition-colors hover:bg-white hover:text-amber-500 disabled:opacity-50"
|
|
61
|
+
title={favoriteLabel}
|
|
62
|
+
aria-label={favoriteLabel}
|
|
63
|
+
>
|
|
64
|
+
<Star className={entry.favorite ? 'h-4 w-4 fill-amber-400 text-amber-500' : 'h-4 w-4'} />
|
|
65
|
+
</button>
|
|
66
|
+
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
|
67
|
+
<PopoverTrigger asChild>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className="rounded-md p-1.5 text-gray-400 transition-colors hover:bg-white hover:text-gray-700"
|
|
71
|
+
aria-label={t('panelAppsMoreActions')}
|
|
72
|
+
disabled={deletePending}
|
|
73
|
+
>
|
|
74
|
+
<MoreVertical className="h-4 w-4" />
|
|
75
|
+
</button>
|
|
76
|
+
</PopoverTrigger>
|
|
77
|
+
<PopoverContent align="end" className="w-48 rounded-xl p-1.5">
|
|
78
|
+
<PanelAppMenuItem
|
|
79
|
+
destructive
|
|
80
|
+
disabled={deletePending}
|
|
81
|
+
icon={Trash2}
|
|
82
|
+
label={t('panelAppsDelete')}
|
|
83
|
+
onClick={openDeleteDialog}
|
|
84
|
+
/>
|
|
85
|
+
</PopoverContent>
|
|
86
|
+
</Popover>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<ConfirmDialog
|
|
90
|
+
open={isDeleteDialogOpen}
|
|
91
|
+
onOpenChange={setIsDeleteDialogOpen}
|
|
92
|
+
title={t('panelAppsDeleteConfirmTitle')}
|
|
93
|
+
description={`${t('panelAppsDeleteConfirmDescription')} ${entry.fileName}`}
|
|
94
|
+
confirmLabel={t('delete')}
|
|
95
|
+
variant="destructive"
|
|
96
|
+
onConfirm={onDelete}
|
|
97
|
+
onCancel={() => undefined}
|
|
98
|
+
/>
|
|
54
99
|
</div>
|
|
55
100
|
);
|
|
56
101
|
}
|
|
57
102
|
|
|
103
|
+
function PanelAppMenuItem({
|
|
104
|
+
destructive = false,
|
|
105
|
+
disabled = false,
|
|
106
|
+
icon: Icon,
|
|
107
|
+
label,
|
|
108
|
+
onClick,
|
|
109
|
+
}: {
|
|
110
|
+
destructive?: boolean;
|
|
111
|
+
disabled?: boolean;
|
|
112
|
+
icon: LucideIcon;
|
|
113
|
+
label: string;
|
|
114
|
+
onClick: () => void;
|
|
115
|
+
}) {
|
|
116
|
+
return (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className={cn(
|
|
120
|
+
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
121
|
+
destructive ? 'text-destructive hover:bg-destructive/10' : 'text-gray-700 hover:bg-gray-100',
|
|
122
|
+
)}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
onClick={onClick}
|
|
125
|
+
>
|
|
126
|
+
<Icon className="h-4 w-4 shrink-0" />
|
|
127
|
+
<span>{label}</span>
|
|
128
|
+
</button>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatPanelAppTime(value: string): string {
|
|
133
|
+
const date = new Date(value);
|
|
134
|
+
if (Number.isNaN(date.getTime())) {
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
const locale = getLocale(getLanguage());
|
|
138
|
+
const now = new Date();
|
|
139
|
+
if (date.toDateString() === now.toDateString()) {
|
|
140
|
+
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date);
|
|
141
|
+
}
|
|
142
|
+
if (date.getFullYear() === now.getFullYear()) {
|
|
143
|
+
return new Intl.DateTimeFormat(locale, { month: 'numeric', day: 'numeric' }).format(date);
|
|
144
|
+
}
|
|
145
|
+
return new Intl.DateTimeFormat(locale, { year: '2-digit', month: 'numeric', day: 'numeric' }).format(date);
|
|
146
|
+
}
|
|
147
|
+
|
|
58
148
|
function PanelAppIcon({ icon, title }: { icon?: string; title: string }) {
|
|
59
149
|
if (!icon) {
|
|
60
150
|
return <AppWindow className="h-4 w-4" />;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
import { HelpCircle, RefreshCw } from 'lucide-react';
|
|
3
3
|
import { PanelAppListItem } from '@/features/panel-apps/components/panel-app-list-item';
|
|
4
|
-
import { usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
|
|
4
|
+
import { useDeletePanelApp, usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
|
|
5
5
|
import { getPanelAppViewEntries } from '@/features/panel-apps/utils/panel-app-view.utils';
|
|
6
6
|
import type { PanelAppViewMode } from '@/features/panel-apps/utils/panel-app-view.utils';
|
|
7
7
|
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
@@ -17,6 +17,7 @@ export function PanelAppsList({
|
|
|
17
17
|
onOpenPanelApp: (entry: PanelAppEntryView) => void;
|
|
18
18
|
}) {
|
|
19
19
|
const panelApps = usePanelApps();
|
|
20
|
+
const deletePanelApp = useDeletePanelApp();
|
|
20
21
|
const updatePreferences = useUpdatePanelAppPreferences();
|
|
21
22
|
const recordOpened = useRecordPanelAppOpened();
|
|
22
23
|
const [viewMode, setViewMode] = useState<PanelAppViewMode>('smart');
|
|
@@ -40,6 +41,10 @@ export function PanelAppsList({
|
|
|
40
41
|
});
|
|
41
42
|
};
|
|
42
43
|
|
|
44
|
+
const deleteEntry = (entry: PanelAppEntryView) => {
|
|
45
|
+
deletePanelApp.mutate(entry.id);
|
|
46
|
+
};
|
|
47
|
+
|
|
43
48
|
if (panelApps.isLoading) {
|
|
44
49
|
return (
|
|
45
50
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">{t('panelAppsLoading')}</div>
|
|
@@ -100,7 +105,9 @@ export function PanelAppsList({
|
|
|
100
105
|
<PanelAppListItem
|
|
101
106
|
key={entry.id}
|
|
102
107
|
entry={entry}
|
|
108
|
+
deletePending={deletePanelApp.isPending}
|
|
103
109
|
favoritePending={updatePreferences.isPending}
|
|
110
|
+
onDelete={() => deleteEntry(entry)}
|
|
104
111
|
onOpen={() => void openPanelApp(entry)}
|
|
105
112
|
onToggleFavorite={() => toggleFavorite(entry)}
|
|
106
113
|
/>
|
|
@@ -37,3 +37,13 @@ export function useRecordPanelAppOpened() {
|
|
|
37
37
|
},
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
export function useDeletePanelApp() {
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
return useMutation({
|
|
44
|
+
mutationFn: (id: string) => nextclawClient.panelApps.deletePanelApp(id),
|
|
45
|
+
onSuccess: () => {
|
|
46
|
+
void queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { NextClawClientError } from '@nextclaw/client-sdk';
|
|
2
2
|
import { waitFor } from '@testing-library/react';
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { PanelAppBridgeManager } from '@/features/panel-apps/managers/panel-app-bridge.manager';
|
|
5
5
|
import type { ServiceActionAuthorizationManager } from '@/features/service-apps';
|
|
6
6
|
|
|
7
7
|
const mocks = vi.hoisted(() => ({
|
|
8
8
|
createBridgeSession: vi.fn(),
|
|
9
|
+
generateAgentObject: vi.fn(),
|
|
10
|
+
grantAgentCapability: vi.fn(),
|
|
9
11
|
grantServiceAction: vi.fn(),
|
|
10
12
|
invokeServiceAction: vi.fn(),
|
|
11
13
|
listServiceActions: vi.fn(),
|
|
14
|
+
sendAgentMessage: vi.fn(),
|
|
12
15
|
requestAuthorization: vi.fn(),
|
|
13
16
|
}));
|
|
14
17
|
|
|
@@ -16,6 +19,9 @@ vi.mock('@/shared/lib/api', () => ({
|
|
|
16
19
|
nextclawClient: {
|
|
17
20
|
panelApps: {
|
|
18
21
|
createBridgeSession: mocks.createBridgeSession,
|
|
22
|
+
generateAgentObject: mocks.generateAgentObject,
|
|
23
|
+
grantAgentCapability: mocks.grantAgentCapability,
|
|
24
|
+
sendAgentMessage: mocks.sendAgentMessage,
|
|
19
25
|
},
|
|
20
26
|
serviceApps: {
|
|
21
27
|
grantServiceAction: mocks.grantServiceAction,
|
|
@@ -26,6 +32,10 @@ vi.mock('@/shared/lib/api', () => ({
|
|
|
26
32
|
},
|
|
27
33
|
}));
|
|
28
34
|
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
29
39
|
describe('PanelAppBridgeManager', () => {
|
|
30
40
|
it('opens the authorization flow before retrying a protected service action', async () => {
|
|
31
41
|
const manager = new PanelAppBridgeManager({
|
|
@@ -108,4 +118,78 @@ describe('PanelAppBridgeManager', () => {
|
|
|
108
118
|
type: 'nextclaw:panel-app-service-actions:response',
|
|
109
119
|
}, '*');
|
|
110
120
|
});
|
|
121
|
+
|
|
122
|
+
it('opens the authorization flow before retrying a protected generateObject call', async () => {
|
|
123
|
+
const manager = new PanelAppBridgeManager({
|
|
124
|
+
requestAuthorization: mocks.requestAuthorization,
|
|
125
|
+
} as unknown as ServiceActionAuthorizationManager);
|
|
126
|
+
const postMessage = vi.fn();
|
|
127
|
+
const contentWindow = { postMessage } as unknown as Window;
|
|
128
|
+
const iframe = { contentWindow } as HTMLIFrameElement;
|
|
129
|
+
mocks.createBridgeSession.mockResolvedValue({
|
|
130
|
+
expiresAt: '2026-05-28T00:00:00.000Z',
|
|
131
|
+
id: 'session-1',
|
|
132
|
+
panelAppId: 'mood-calendar',
|
|
133
|
+
tabId: 'tab-1',
|
|
134
|
+
token: 'token-1',
|
|
135
|
+
});
|
|
136
|
+
mocks.generateAgentObject
|
|
137
|
+
.mockRejectedValueOnce(new NextClawClientError({
|
|
138
|
+
code: 'AUTHORIZATION_REQUIRED',
|
|
139
|
+
message: 'authorization required',
|
|
140
|
+
}))
|
|
141
|
+
.mockResolvedValueOnce({ result: { summary: 'sunny' } });
|
|
142
|
+
mocks.requestAuthorization.mockResolvedValue(true);
|
|
143
|
+
mocks.grantAgentCapability.mockResolvedValue({
|
|
144
|
+
caller: { surface: 'panel-app', appId: 'mood-calendar' },
|
|
145
|
+
capability: 'agent:generateObject',
|
|
146
|
+
grantedAt: '2026-05-28T00:00:00.000Z',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
manager.handleIframeMessage({
|
|
150
|
+
event: {
|
|
151
|
+
data: {
|
|
152
|
+
method: 'agent.generateObject',
|
|
153
|
+
payload: {
|
|
154
|
+
input: {
|
|
155
|
+
peerId: 'mood-summary',
|
|
156
|
+
prompt: 'summarize',
|
|
157
|
+
schema: { type: 'object' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
requestId: 'request-2',
|
|
161
|
+
type: 'nextclaw:panel-app-service-actions:request',
|
|
162
|
+
},
|
|
163
|
+
source: contentWindow,
|
|
164
|
+
} as MessageEvent,
|
|
165
|
+
iframe,
|
|
166
|
+
tab: {
|
|
167
|
+
currentUrl: '/api/panel-apps/mood-calendar/content',
|
|
168
|
+
history: [],
|
|
169
|
+
historyIndex: 0,
|
|
170
|
+
id: 'tab-1',
|
|
171
|
+
kind: 'content',
|
|
172
|
+
navVersion: 0,
|
|
173
|
+
title: 'Mood Calendar',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(postMessage).toHaveBeenCalled());
|
|
178
|
+
|
|
179
|
+
expect(mocks.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
|
|
180
|
+
actionId: 'agent:generateObject',
|
|
181
|
+
panelAppId: 'mood-calendar',
|
|
182
|
+
risk: 'write',
|
|
183
|
+
}));
|
|
184
|
+
expect(mocks.grantAgentCapability).toHaveBeenCalledWith('agent:generateObject', {
|
|
185
|
+
bridgeSessionToken: 'token-1',
|
|
186
|
+
});
|
|
187
|
+
expect(mocks.generateAgentObject).toHaveBeenCalledTimes(2);
|
|
188
|
+
expect(postMessage).toHaveBeenCalledWith({
|
|
189
|
+
data: { result: { summary: 'sunny' } },
|
|
190
|
+
ok: true,
|
|
191
|
+
requestId: 'request-2',
|
|
192
|
+
type: 'nextclaw:panel-app-service-actions:response',
|
|
193
|
+
}, '*');
|
|
194
|
+
});
|
|
111
195
|
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { NextClawClientError } from '@nextclaw/client-sdk';
|
|
2
2
|
import type {
|
|
3
|
+
PanelAppAgentCapabilityView,
|
|
4
|
+
PanelAppAgentGenerateObjectRequestView,
|
|
5
|
+
PanelAppAgentGenerateObjectResultView,
|
|
6
|
+
PanelAppAgentSendRequestView,
|
|
7
|
+
PanelAppAgentSendResultView,
|
|
3
8
|
PanelAppBridgeSessionView,
|
|
9
|
+
PanelAppCapabilityGrantView,
|
|
4
10
|
ServiceActionGrantView,
|
|
5
11
|
ServiceActionInvokeResultView,
|
|
6
12
|
ServiceActionListView,
|
|
@@ -12,10 +18,17 @@ import { nextclawClient } from '@/shared/lib/api';
|
|
|
12
18
|
type PanelAppBridgeRequest = {
|
|
13
19
|
type: 'nextclaw:panel-app-service-actions:request';
|
|
14
20
|
requestId: string;
|
|
15
|
-
method:
|
|
21
|
+
method:
|
|
22
|
+
| 'agent.generateObject'
|
|
23
|
+
| 'agent.send'
|
|
24
|
+
| 'invoke'
|
|
25
|
+
| 'list'
|
|
26
|
+
| 'requestGrant'
|
|
27
|
+
| 'revokeGrant';
|
|
16
28
|
payload?: {
|
|
17
29
|
actionId?: string;
|
|
18
|
-
input?:
|
|
30
|
+
input?: unknown;
|
|
31
|
+
request?: unknown;
|
|
19
32
|
};
|
|
20
33
|
};
|
|
21
34
|
|
|
@@ -79,6 +92,9 @@ export class PanelAppBridgeManager {
|
|
|
79
92
|
session: PanelAppBridgeSessionView,
|
|
80
93
|
request: PanelAppBridgeRequest,
|
|
81
94
|
): Promise<
|
|
95
|
+
| PanelAppAgentGenerateObjectResultView
|
|
96
|
+
| PanelAppAgentSendResultView
|
|
97
|
+
| PanelAppCapabilityGrantView
|
|
82
98
|
| ServiceActionGrantView
|
|
83
99
|
| ServiceActionInvokeResultView
|
|
84
100
|
| ServiceActionListView
|
|
@@ -98,6 +114,10 @@ export class PanelAppBridgeManager {
|
|
|
98
114
|
);
|
|
99
115
|
case 'invoke':
|
|
100
116
|
return await this.invokeWithAuthorization(session, request);
|
|
117
|
+
case 'agent.send':
|
|
118
|
+
return await this.sendAgentMessageWithAuthorization(session, request);
|
|
119
|
+
case 'agent.generateObject':
|
|
120
|
+
return await this.generateAgentObjectWithAuthorization(session, request);
|
|
101
121
|
default:
|
|
102
122
|
throw new Error(`Unsupported panel bridge method: ${String(request.method)}`);
|
|
103
123
|
}
|
|
@@ -111,22 +131,89 @@ export class PanelAppBridgeManager {
|
|
|
111
131
|
try {
|
|
112
132
|
return await nextclawClient.serviceApps.invokeServiceAction(
|
|
113
133
|
actionId,
|
|
114
|
-
request.payload?.input,
|
|
134
|
+
this.readOptionalRecord(request.payload?.input),
|
|
115
135
|
{ bridgeSessionToken: session.token },
|
|
116
136
|
);
|
|
117
137
|
} catch (error) {
|
|
118
138
|
if (!(error instanceof NextClawClientError) || error.code !== 'AUTHORIZATION_REQUIRED') {
|
|
119
139
|
throw error;
|
|
120
140
|
}
|
|
121
|
-
await this.confirmAndGrant(session, actionId, request.payload?.input);
|
|
141
|
+
await this.confirmAndGrant(session, actionId, this.readOptionalRecord(request.payload?.input));
|
|
122
142
|
return await nextclawClient.serviceApps.invokeServiceAction(
|
|
123
143
|
actionId,
|
|
124
|
-
request.payload?.input,
|
|
144
|
+
this.readOptionalRecord(request.payload?.input),
|
|
125
145
|
{ bridgeSessionToken: session.token },
|
|
126
146
|
);
|
|
127
147
|
}
|
|
128
148
|
};
|
|
129
149
|
|
|
150
|
+
private sendAgentMessageWithAuthorization = async (
|
|
151
|
+
session: PanelAppBridgeSessionView,
|
|
152
|
+
request: PanelAppBridgeRequest,
|
|
153
|
+
): Promise<PanelAppAgentSendResultView> => {
|
|
154
|
+
try {
|
|
155
|
+
return await nextclawClient.panelApps.sendAgentMessage(
|
|
156
|
+
this.readAgentSendRequest(request),
|
|
157
|
+
{ bridgeSessionToken: session.token },
|
|
158
|
+
);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (!(error instanceof NextClawClientError) || error.code !== 'AUTHORIZATION_REQUIRED') {
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
await this.confirmAndGrantAgentCapability(session, 'agent:send');
|
|
164
|
+
return await nextclawClient.panelApps.sendAgentMessage(
|
|
165
|
+
this.readAgentSendRequest(request),
|
|
166
|
+
{ bridgeSessionToken: session.token },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
private generateAgentObjectWithAuthorization = async (
|
|
172
|
+
session: PanelAppBridgeSessionView,
|
|
173
|
+
request: PanelAppBridgeRequest,
|
|
174
|
+
): Promise<PanelAppAgentGenerateObjectResultView> => {
|
|
175
|
+
try {
|
|
176
|
+
return await nextclawClient.panelApps.generateAgentObject(
|
|
177
|
+
this.readGenerateObjectRequest(request),
|
|
178
|
+
{ bridgeSessionToken: session.token },
|
|
179
|
+
);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!(error instanceof NextClawClientError) || error.code !== 'AUTHORIZATION_REQUIRED') {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
await this.confirmAndGrantAgentCapability(session, 'agent:generateObject');
|
|
185
|
+
return await nextclawClient.panelApps.generateAgentObject(
|
|
186
|
+
this.readGenerateObjectRequest(request),
|
|
187
|
+
{ bridgeSessionToken: session.token },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
private confirmAndGrantAgentCapability = async (
|
|
193
|
+
session: PanelAppBridgeSessionView,
|
|
194
|
+
capability: PanelAppAgentCapabilityView,
|
|
195
|
+
): Promise<PanelAppCapabilityGrantView> => {
|
|
196
|
+
const allowed = await this.authorizationManager.requestAuthorization({
|
|
197
|
+
panelAppId: session.panelAppId,
|
|
198
|
+
actionId: capability,
|
|
199
|
+
actionTitle: capability === 'agent:send' ? 'Send agent message' : 'Generate object',
|
|
200
|
+
actionDescription: capability === 'agent:send'
|
|
201
|
+
? 'Send a message to a NextClaw Agent session.'
|
|
202
|
+
: 'Send context to a NextClaw Agent session and receive a structured object.',
|
|
203
|
+
risk: 'write',
|
|
204
|
+
});
|
|
205
|
+
if (!allowed) {
|
|
206
|
+
throw new NextClawClientError({
|
|
207
|
+
code: 'AUTHORIZATION_REJECTED',
|
|
208
|
+
message: `Permission rejected for ${capability}.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return await nextclawClient.panelApps.grantAgentCapability(
|
|
212
|
+
capability,
|
|
213
|
+
{ bridgeSessionToken: session.token },
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
130
217
|
private confirmAndGrant = async (
|
|
131
218
|
session: PanelAppBridgeSessionView,
|
|
132
219
|
actionId: string,
|
|
@@ -160,7 +247,9 @@ export class PanelAppBridgeManager {
|
|
|
160
247
|
const actions = await nextclawClient.serviceApps.listServiceActions({
|
|
161
248
|
bridgeSessionToken: session.token,
|
|
162
249
|
});
|
|
163
|
-
return actions.actions.find(
|
|
250
|
+
return actions.actions.find(
|
|
251
|
+
(action: ServiceActionListView['actions'][number]) => action.id === actionId,
|
|
252
|
+
);
|
|
164
253
|
};
|
|
165
254
|
|
|
166
255
|
private createInputPreview = (input: Record<string, unknown> | undefined): string | undefined => {
|
|
@@ -208,6 +297,32 @@ export class PanelAppBridgeManager {
|
|
|
208
297
|
return actionId;
|
|
209
298
|
};
|
|
210
299
|
|
|
300
|
+
private readOptionalRecord = (value: unknown): Record<string, unknown> | undefined => {
|
|
301
|
+
if (value === undefined) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
return this.requireRecord(value, 'input');
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
private readAgentSendRequest = (
|
|
308
|
+
request: PanelAppBridgeRequest,
|
|
309
|
+
): PanelAppAgentSendRequestView => ({
|
|
310
|
+
payload: this.requireRecord(request.payload?.request, 'agent.send request') as PanelAppAgentSendRequestView['payload'],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
private readGenerateObjectRequest = (
|
|
314
|
+
request: PanelAppBridgeRequest,
|
|
315
|
+
): PanelAppAgentGenerateObjectRequestView => ({
|
|
316
|
+
input: this.requireRecord(request.payload?.input, 'generateObject input') as PanelAppAgentGenerateObjectRequestView['input'],
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
private requireRecord = (value: unknown, label: string): Record<string, unknown> => {
|
|
320
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
321
|
+
throw new Error(`${label} is required.`);
|
|
322
|
+
}
|
|
323
|
+
return value as Record<string, unknown>;
|
|
324
|
+
};
|
|
325
|
+
|
|
211
326
|
private postResponse = (
|
|
212
327
|
params: DocBrowserIframeMessageParams,
|
|
213
328
|
response: PanelAppBridgeResponse,
|
|
@@ -239,6 +354,8 @@ export class PanelAppBridgeManager {
|
|
|
239
354
|
candidate.type === 'nextclaw:panel-app-service-actions:request' &&
|
|
240
355
|
typeof candidate.requestId === 'string' &&
|
|
241
356
|
(candidate.method === 'invoke' ||
|
|
357
|
+
candidate.method === 'agent.send' ||
|
|
358
|
+
candidate.method === 'agent.generateObject' ||
|
|
242
359
|
candidate.method === 'list' ||
|
|
243
360
|
candidate.method === 'requestGrant' ||
|
|
244
361
|
candidate.method === 'revokeGrant')
|
|
@@ -4,11 +4,15 @@ import type { DocBrowserCustomTabRenderers } from '@/shared/components/doc-brows
|
|
|
4
4
|
import { getPresenter } from '@/app/presenters/app.presenter';
|
|
5
5
|
import { AppsPanel, type AppsPanelTab } from '@/features/apps';
|
|
6
6
|
import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
|
|
7
|
+
import {
|
|
8
|
+
DOC_BROWSER_APPS_TAB_KIND,
|
|
9
|
+
DOC_BROWSER_APPS_URL,
|
|
10
|
+
DOC_BROWSER_SERVICE_APPS_URL,
|
|
11
|
+
} from '@/shared/components/doc-browser/utils/doc-browser-route-registry.utils';
|
|
7
12
|
import { t } from '@/shared/lib/i18n';
|
|
8
13
|
|
|
9
|
-
export const APPS_TAB_KIND =
|
|
14
|
+
export const APPS_TAB_KIND = DOC_BROWSER_APPS_TAB_KIND;
|
|
10
15
|
export const PANEL_APP_TAB_KIND = 'panel-app';
|
|
11
|
-
const APPS_URL = 'nextclaw://apps';
|
|
12
16
|
const DEFAULT_APPS_PANEL_TAB: AppsPanelTab = 'panel-apps';
|
|
13
17
|
const PANEL_APP_IFRAME_SANDBOX = [
|
|
14
18
|
'allow-scripts',
|
|
@@ -26,7 +30,7 @@ function isAppsPanelTab(value: unknown): value is AppsPanelTab {
|
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function createAppsPanelUrl(tab: AppsPanelTab = DEFAULT_APPS_PANEL_TAB): string {
|
|
29
|
-
return tab === DEFAULT_APPS_PANEL_TAB ?
|
|
33
|
+
return tab === DEFAULT_APPS_PANEL_TAB ? DOC_BROWSER_APPS_URL : DOC_BROWSER_SERVICE_APPS_URL;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function getAppsPanelTabFromUrl(url: string): AppsPanelTab {
|