@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/dist/assets/api-DruAcori.js +15 -0
  3. package/dist/assets/{book-open-qc0UVn6m.js → book-open-cPX0Za7Z.js} +1 -1
  4. package/dist/assets/channels-list-page-D-tv11dc.js +8 -0
  5. package/dist/assets/chat-page-MyXEZI8R.js +105 -0
  6. package/dist/assets/{config-split-page-DW8tP9Bc.js → config-split-page-BFh_Qixx.js} +1 -1
  7. package/dist/assets/confirm-dialog-B0TP-vPd.js +5 -0
  8. package/dist/assets/{createLucideIcon-CA0sgoMJ.js → createLucideIcon-Br1EFMEr.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DARSwQG8.js → desktop-update-config-oZpIKKtS.js} +1 -1
  10. package/dist/assets/{dist-D5zsy7pX.js → dist-CpwDZDtv.js} +1 -1
  11. package/dist/assets/{dist-DNxIa3pe.js → dist-rzakR_bu.js} +1 -1
  12. package/dist/assets/doc-browser-B2OFjScU.js +3 -0
  13. package/dist/assets/doc-browser-BrUQEZ1j.js +1 -0
  14. package/dist/assets/doc-browser-context-DB6TTHeE.js +1 -0
  15. package/dist/assets/doc-browser-route-registry.utils-x5ijvk0u.js +1 -0
  16. package/dist/assets/{external-link-C9fbyi6k.js → external-link-C3usm3se.js} +1 -1
  17. package/dist/assets/index-D2H2CKYR.js +101 -0
  18. package/dist/assets/index-v0fAGXQW.css +1 -0
  19. package/dist/assets/{mcp-marketplace-page-BiExW5wq.js → mcp-marketplace-page-CegP14Hl.js} +2 -2
  20. package/dist/assets/mcp-marketplace-page-MjpFEEkY.js +1 -0
  21. package/dist/assets/model-config-fIE_Asps.js +1 -0
  22. package/dist/assets/play-CqNi0u02.js +1 -0
  23. package/dist/assets/plus-BcIC8dG1.js +1 -0
  24. package/dist/assets/{provider-scoped-model-input-Bk8jlpRm.js → provider-scoped-model-input-D4zRsGZ6.js} +1 -1
  25. package/dist/assets/providers-list-CqsOk2hr.js +1 -0
  26. package/dist/assets/react-B2X_ph0a.js +1 -0
  27. package/dist/assets/remote-BMWQUPDW.js +1 -0
  28. package/dist/assets/runtime-config-page-BJ86cC8a.js +1 -0
  29. package/dist/assets/{save-CG4Hb1u8.js → save-D10M5vQg.js} +1 -1
  30. package/dist/assets/{search-lWpOIHEn.js → search-CdhgFNwf.js} +1 -1
  31. package/dist/assets/search-config-TbCPlRE7.js +1 -0
  32. package/dist/assets/{secrets-config-ZbLzjIRx.js → secrets-config-DBIEKP_V.js} +1 -1
  33. package/dist/assets/{setting-row-CNfTKDjc.js → setting-row-8VkukJoH.js} +1 -1
  34. package/dist/assets/{tabs-custom-BbZoysAl.js → tabs-custom-BBXu1OGs.js} +1 -1
  35. package/dist/assets/{tag-chip-Dl8JbhC7.js → tag-chip-ix15P-r4.js} +1 -1
  36. package/dist/assets/wrench-qVIwShTF.js +1 -0
  37. package/dist/assets/x-Chb07O-Y.js +1 -0
  38. package/dist/index.html +17 -15
  39. package/package.json +8 -8
  40. package/src/features/panel-apps/components/panel-app-list-item.test.tsx +60 -0
  41. package/src/features/panel-apps/components/panel-app-list-item.tsx +120 -30
  42. package/src/features/panel-apps/components/panel-apps-list.tsx +8 -1
  43. package/src/features/panel-apps/hooks/use-panel-apps.ts +10 -0
  44. package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +85 -1
  45. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +123 -6
  46. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +7 -3
  47. package/src/features/panel-apps/utils/panel-app-view.utils.test.ts +66 -0
  48. package/src/features/panel-apps/utils/panel-app-view.utils.ts +13 -2
  49. package/src/index.css +23 -0
  50. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +170 -0
  51. package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
  52. package/src/shared/components/doc-browser/doc-browser-home-page.tsx +95 -0
  53. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +0 -90
  54. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +117 -44
  55. package/src/shared/components/doc-browser/doc-browser.test.tsx +159 -1
  56. package/src/shared/components/doc-browser/doc-browser.tsx +42 -23
  57. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +183 -71
  58. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +52 -1
  59. package/src/shared/components/doc-browser/types/doc-browser.types.ts +9 -0
  60. package/src/shared/components/doc-browser/utils/doc-browser-route-registry.utils.ts +232 -0
  61. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +11 -0
  62. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +2 -0
  63. package/src/shared/lib/api/types.ts +1 -0
  64. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +10 -0
  65. package/dist/assets/api-BTZui7nj.js +0 -15
  66. package/dist/assets/channels-list-page-DBtU_M1Y.js +0 -8
  67. package/dist/assets/chat-page-BcoJYo91.js +0 -105
  68. package/dist/assets/doc-browser-B9skxzVo.js +0 -1
  69. package/dist/assets/doc-browser-CwyITRXx.js +0 -1
  70. package/dist/assets/doc-browser-context-Do2qSrB8.js +0 -1
  71. package/dist/assets/index-BYRP_BMj.css +0 -1
  72. package/dist/assets/index-DckPGTdM.js +0 -103
  73. package/dist/assets/mcp-marketplace-page-Cj6MDSkE.js +0 -1
  74. package/dist/assets/model-config-DbdY7fF1.js +0 -1
  75. package/dist/assets/notice-card-CyiVcu_Z.js +0 -5
  76. package/dist/assets/play-D-s0C4MN.js +0 -1
  77. package/dist/assets/plus-DOwjM_kF.js +0 -1
  78. package/dist/assets/providers-list-CJjNStDV.js +0 -1
  79. package/dist/assets/react-BaRotFd6.js +0 -1
  80. package/dist/assets/remote-gl8QckNc.js +0 -1
  81. package/dist/assets/runtime-config-page-BMFc1yzf.js +0 -1
  82. package/dist/assets/search-config-DUOR26if.js +0 -1
  83. package/dist/assets/x-D6GwT_8k.js +0 -1
  84. /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 { MouseEvent } from 'react';
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 { formatDateTime, t } from '@/shared/lib/i18n';
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')} ${formatDateTime(entry.lastOpenedAt)}`
20
- : `${t('panelAppsUpdated')} ${formatDateTime(entry.updatedAt)}`;
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 flex w-full min-w-0 items-start gap-2 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">
28
- <button
29
- type="button"
30
- onClick={onOpen}
31
- className="flex min-w-0 flex-1 items-start gap-3 text-left"
32
- >
33
- <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-amber-50 text-base text-amber-700">
34
- <PanelAppIcon icon={entry.icon} title={entry.title} />
35
- </span>
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-0.5 block line-clamp-2 text-xs leading-5 text-gray-600">{entry.description}</span>
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-1 block truncate text-xs text-gray-400">{secondary}</span>
42
- </span>
43
- </button>
44
- <button
45
- type="button"
46
- onClick={handleFavorite}
47
- disabled={favoritePending}
48
- className="rounded-md p-1.5 text-gray-400 transition-colors hover:bg-white hover:text-amber-500 disabled:opacity-50"
49
- title={favoriteLabel}
50
- aria-label={favoriteLabel}
51
- >
52
- <Star className={entry.favorite ? 'h-4 w-4 fill-amber-400 text-amber-500' : 'h-4 w-4'} />
53
- </button>
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: 'invoke' | 'list' | 'requestGrant' | 'revokeGrant';
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?: Record<string, unknown>;
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((action) => action.id === actionId);
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 = 'apps';
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 ? APPS_URL : `${APPS_URL}?tab=${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 {