@nextclaw/ui 0.12.35-beta.6 → 0.12.35
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 +482 -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-DXH1qYLs.js +8 -0
- package/dist/assets/chat-page-Cpo0AFHb.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-Be8x1jNy.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-BI7HSaMK.js +3 -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/doc-browser-yTfN7UBH.js +1 -0
- package/dist/assets/{external-link-C9fbyi6k.js → external-link-C3usm3se.js} +1 -1
- package/dist/assets/index-GVTiCf1s.js +101 -0
- package/dist/assets/index-v0fAGXQW.css +1 -0
- package/dist/assets/{mcp-marketplace-page-BiExW5wq.js → mcp-marketplace-page-DDjeHd1I.js} +2 -2
- package/dist/assets/mcp-marketplace-page-WUkc7n94.js +1 -0
- package/dist/assets/model-config-BjXMpU9U.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-t1tCP5y5.js} +1 -1
- package/dist/assets/providers-list-C1D5p9kK.js +1 -0
- package/dist/assets/react-B2X_ph0a.js +1 -0
- package/dist/assets/remote-BHeENKxL.js +1 -0
- package/dist/assets/runtime-config-page--O0Wq01S.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-Dzpizo-_.js +1 -0
- package/dist/assets/{secrets-config-ZbLzjIRx.js → secrets-config-D1AUSEXX.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 +153 -1
- package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +126 -9
- 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 +3 -97
- package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -0
- 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 +46 -27
- 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
|
@@ -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
|
|
|
@@ -42,14 +55,14 @@ export class PanelAppBridgeManager {
|
|
|
42
55
|
|
|
43
56
|
constructor(private readonly authorizationManager: ServiceActionAuthorizationManager) {}
|
|
44
57
|
|
|
45
|
-
handleIframeMessage = ({ event, iframe, tab }: DocBrowserIframeMessageParams): void => {
|
|
58
|
+
handleIframeMessage = ({ event, iframe, iframeInstanceId, tab }: DocBrowserIframeMessageParams): void => {
|
|
46
59
|
if (!this.isBridgeRequest(event.data)) {
|
|
47
60
|
return;
|
|
48
61
|
}
|
|
49
62
|
if (!iframe?.contentWindow || event.source !== iframe.contentWindow) {
|
|
50
63
|
return;
|
|
51
64
|
}
|
|
52
|
-
void this.handleBridgeRequest({ event, iframe, tab }, event.data);
|
|
65
|
+
void this.handleBridgeRequest({ event, iframe, iframeInstanceId, tab }, event.data);
|
|
53
66
|
};
|
|
54
67
|
|
|
55
68
|
private handleBridgeRequest = async (
|
|
@@ -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 => {
|
|
@@ -179,7 +268,7 @@ export class PanelAppBridgeManager {
|
|
|
179
268
|
params: DocBrowserIframeMessageParams,
|
|
180
269
|
): Promise<PanelAppBridgeSessionView> => {
|
|
181
270
|
const panelAppId = this.readPanelAppId(params.tab.currentUrl);
|
|
182
|
-
const key = `${params.
|
|
271
|
+
const key = `${params.iframeInstanceId}:${panelAppId}`;
|
|
183
272
|
let session = this.sessions.get(key);
|
|
184
273
|
if (!session) {
|
|
185
274
|
session = nextclawClient.panelApps.createBridgeSession({
|
|
@@ -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 {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
3
|
+
import { getPanelAppViewEntries } from './panel-app-view.utils';
|
|
4
|
+
|
|
5
|
+
function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
|
|
6
|
+
const createdAt = overrides.createdAt ?? '2026-05-01T00:00:00.000Z';
|
|
7
|
+
const updatedAt = overrides.updatedAt ?? createdAt;
|
|
8
|
+
return {
|
|
9
|
+
id: overrides.id ?? overrides.fileName ?? 'demo.panel.html',
|
|
10
|
+
fileName: overrides.fileName ?? 'demo.panel.html',
|
|
11
|
+
title: overrides.title ?? 'Demo',
|
|
12
|
+
contentPath: overrides.contentPath ?? '/api/panel-apps/demo/content',
|
|
13
|
+
createdAt,
|
|
14
|
+
updatedAt,
|
|
15
|
+
sizeBytes: overrides.sizeBytes ?? 12,
|
|
16
|
+
favorite: overrides.favorite ?? false,
|
|
17
|
+
openCount: overrides.openCount ?? 0,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('panel app view sorting', () => {
|
|
23
|
+
it('orders smart view by the latest panel app activity before favorite state', () => {
|
|
24
|
+
const entries = [
|
|
25
|
+
createPanelAppEntry({
|
|
26
|
+
fileName: 'old-favorite.panel.html',
|
|
27
|
+
title: 'Old Favorite',
|
|
28
|
+
favorite: true,
|
|
29
|
+
}),
|
|
30
|
+
createPanelAppEntry({
|
|
31
|
+
fileName: 'newly-created.panel.html',
|
|
32
|
+
title: 'Newly Created',
|
|
33
|
+
createdAt: '2026-05-27T12:00:00.000Z',
|
|
34
|
+
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
35
|
+
}),
|
|
36
|
+
createPanelAppEntry({
|
|
37
|
+
fileName: 'just-opened.panel.html',
|
|
38
|
+
title: 'Just Opened',
|
|
39
|
+
lastOpenedAt: '2026-05-28T08:00:00.000Z',
|
|
40
|
+
}),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
expect(getPanelAppViewEntries(entries, 'smart').map((entry) => entry.fileName)).toEqual([
|
|
44
|
+
'just-opened.panel.html',
|
|
45
|
+
'newly-created.panel.html',
|
|
46
|
+
'old-favorite.panel.html',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps the dedicated recent-open view scoped to apps that were opened', () => {
|
|
51
|
+
const entries = [
|
|
52
|
+
createPanelAppEntry({
|
|
53
|
+
fileName: 'newly-created.panel.html',
|
|
54
|
+
createdAt: '2026-05-27T12:00:00.000Z',
|
|
55
|
+
}),
|
|
56
|
+
createPanelAppEntry({
|
|
57
|
+
fileName: 'just-opened.panel.html',
|
|
58
|
+
lastOpenedAt: '2026-05-28T08:00:00.000Z',
|
|
59
|
+
}),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
expect(getPanelAppViewEntries(entries, 'recent-open').map((entry) => entry.fileName)).toEqual([
|
|
63
|
+
'just-opened.panel.html',
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -27,9 +27,8 @@ function comparePanelApps(left: PanelAppEntryView, right: PanelAppEntryView, mod
|
|
|
27
27
|
return compareByName(left, right);
|
|
28
28
|
}
|
|
29
29
|
return (
|
|
30
|
+
comparePanelAppActivityDesc(left, right) ||
|
|
30
31
|
Number(right.favorite) - Number(left.favorite) ||
|
|
31
|
-
compareIsoDesc(left.lastOpenedAt, right.lastOpenedAt) ||
|
|
32
|
-
compareIsoDesc(left.updatedAt, right.updatedAt) ||
|
|
33
32
|
compareByName(left, right)
|
|
34
33
|
);
|
|
35
34
|
}
|
|
@@ -38,6 +37,18 @@ function compareByName(left: PanelAppEntryView, right: PanelAppEntryView): numbe
|
|
|
38
37
|
return left.title.localeCompare(right.title);
|
|
39
38
|
}
|
|
40
39
|
|
|
40
|
+
function comparePanelAppActivityDesc(left: PanelAppEntryView, right: PanelAppEntryView): number {
|
|
41
|
+
return resolvePanelAppActivityMs(right) - resolvePanelAppActivityMs(left);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolvePanelAppActivityMs(entry: PanelAppEntryView): number {
|
|
45
|
+
return Math.max(
|
|
46
|
+
new Date(entry.lastOpenedAt ?? 0).getTime(),
|
|
47
|
+
new Date(entry.createdAt).getTime(),
|
|
48
|
+
new Date(entry.updatedAt).getTime(),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
function compareIsoDesc(left?: string, right?: string): number {
|
|
42
53
|
return new Date(right ?? 0).getTime() - new Date(left ?? 0).getTime();
|
|
43
54
|
}
|
package/src/index.css
CHANGED
|
@@ -91,6 +91,29 @@
|
|
|
91
91
|
background: hsl(var(--gray-400) / 0.48);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
.doc-browser-tab-scrollbar {
|
|
95
|
+
scrollbar-width: thin;
|
|
96
|
+
scrollbar-color: hsl(var(--gray-300) / 0.34) transparent;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.doc-browser-tab-scrollbar::-webkit-scrollbar {
|
|
100
|
+
width: 2px;
|
|
101
|
+
height: 2px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.doc-browser-tab-scrollbar::-webkit-scrollbar-track {
|
|
105
|
+
background: transparent;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.doc-browser-tab-scrollbar::-webkit-scrollbar-thumb {
|
|
109
|
+
background: hsl(var(--gray-300) / 0.34);
|
|
110
|
+
border-radius: 999px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.doc-browser-tab-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
114
|
+
background: hsl(var(--gray-400) / 0.44);
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
/* ========================================
|
|
95
118
|
GLASSMORPHISM
|
|
96
119
|
======================================== */
|
|
@@ -160,6 +160,176 @@ describe('DocBrowserProvider dedupe keys', () => {
|
|
|
160
160
|
kind: 'apps',
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
it('opens the built-in start page without falling back to docs', () => {
|
|
165
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
166
|
+
|
|
167
|
+
act(() => {
|
|
168
|
+
result.current.openNewTab();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.current.currentTab).toMatchObject({
|
|
172
|
+
currentUrl: 'nextclaw://new-tab',
|
|
173
|
+
kind: 'home',
|
|
174
|
+
title: 'Start Page',
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('infers built-in apps routes without component-level options', () => {
|
|
179
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
180
|
+
|
|
181
|
+
act(() => {
|
|
182
|
+
result.current.open('nextclaw://apps?tab=service-apps');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result.current.currentTab).toMatchObject({
|
|
186
|
+
currentUrl: 'nextclaw://apps?tab=service-apps',
|
|
187
|
+
dedupeKey: 'apps',
|
|
188
|
+
kind: 'apps',
|
|
189
|
+
title: 'Service Apps',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('uses managed history for non-doc routes', () => {
|
|
194
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
result.current.open('data:text/html,A', {
|
|
198
|
+
kind: 'content',
|
|
199
|
+
title: 'A',
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
act(() => {
|
|
203
|
+
result.current.navigate('data:text/html,B');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(result.current.currentTab).toMatchObject({
|
|
207
|
+
currentUrl: 'data:text/html,B',
|
|
208
|
+
historyIndex: 1,
|
|
209
|
+
kind: 'content',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
result.current.goBack();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(result.current.currentTab).toMatchObject({
|
|
217
|
+
currentUrl: 'data:text/html,A',
|
|
218
|
+
historyIndex: 0,
|
|
219
|
+
kind: 'content',
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('stops back and forward navigation at browser history boundaries', () => {
|
|
224
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
225
|
+
|
|
226
|
+
act(() => {
|
|
227
|
+
result.current.open('data:text/html,A', {
|
|
228
|
+
kind: 'content',
|
|
229
|
+
title: 'A',
|
|
230
|
+
});
|
|
231
|
+
result.current.navigate('data:text/html,B');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
act(() => {
|
|
235
|
+
result.current.goBack();
|
|
236
|
+
result.current.goBack();
|
|
237
|
+
result.current.goBack();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result.current.currentTab).toMatchObject({
|
|
241
|
+
kind: 'docs',
|
|
242
|
+
});
|
|
243
|
+
expect(result.current.activeHistoryIndex).toBe(0);
|
|
244
|
+
|
|
245
|
+
act(() => {
|
|
246
|
+
result.current.goForward();
|
|
247
|
+
result.current.goForward();
|
|
248
|
+
result.current.goForward();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(result.current.currentTab).toMatchObject({
|
|
252
|
+
currentUrl: 'data:text/html,B',
|
|
253
|
+
historyIndex: 1,
|
|
254
|
+
history: ['data:text/html,A', 'data:text/html,B'],
|
|
255
|
+
});
|
|
256
|
+
expect(result.current.activeHistoryIndex).toBe(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('records active tab switching in the top-level browser history', () => {
|
|
260
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
261
|
+
|
|
262
|
+
act(() => {
|
|
263
|
+
result.current.open('data:text/html,A', {
|
|
264
|
+
kind: 'content',
|
|
265
|
+
title: 'A',
|
|
266
|
+
});
|
|
267
|
+
result.current.openNewTab();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(result.current.currentTab).toMatchObject({
|
|
271
|
+
currentUrl: 'nextclaw://new-tab',
|
|
272
|
+
kind: 'home',
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
act(() => {
|
|
276
|
+
result.current.goBack();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(result.current.currentTab).toMatchObject({
|
|
280
|
+
currentUrl: 'data:text/html,A',
|
|
281
|
+
kind: 'content',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
act(() => {
|
|
285
|
+
result.current.goForward();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.current.currentTab).toMatchObject({
|
|
289
|
+
currentUrl: 'nextclaw://new-tab',
|
|
290
|
+
kind: 'home',
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('truncates forward history before appending manual navigation after going back', () => {
|
|
295
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
296
|
+
|
|
297
|
+
act(() => {
|
|
298
|
+
result.current.open('data:text/html,A', {
|
|
299
|
+
kind: 'content',
|
|
300
|
+
title: 'A',
|
|
301
|
+
});
|
|
302
|
+
result.current.navigate('data:text/html,B');
|
|
303
|
+
result.current.navigate('data:text/html,C');
|
|
304
|
+
result.current.goBack();
|
|
305
|
+
result.current.navigate('data:text/html,D');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(result.current.currentTab).toMatchObject({
|
|
309
|
+
currentUrl: 'data:text/html,D',
|
|
310
|
+
historyIndex: 2,
|
|
311
|
+
history: ['data:text/html,A', 'data:text/html,B', 'data:text/html,D'],
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('deduplicates consecutive manual navigation to the current URL', () => {
|
|
316
|
+
const { result } = renderHook(() => useDocBrowser(), { wrapper });
|
|
317
|
+
|
|
318
|
+
act(() => {
|
|
319
|
+
result.current.open('data:text/html,A', {
|
|
320
|
+
kind: 'content',
|
|
321
|
+
title: 'A',
|
|
322
|
+
});
|
|
323
|
+
result.current.navigate('data:text/html,B');
|
|
324
|
+
result.current.navigate('data:text/html,B');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(result.current.currentTab).toMatchObject({
|
|
328
|
+
currentUrl: 'data:text/html,B',
|
|
329
|
+
historyIndex: 1,
|
|
330
|
+
history: ['data:text/html,A', 'data:text/html,B'],
|
|
331
|
+
});
|
|
332
|
+
});
|
|
163
333
|
});
|
|
164
334
|
|
|
165
335
|
describe('DocBrowserProvider persistence', () => {
|
|
@@ -14,6 +14,8 @@ export type {
|
|
|
14
14
|
DocBrowserTabKind,
|
|
15
15
|
} from '@/shared/components/doc-browser/types/doc-browser.types';
|
|
16
16
|
export {
|
|
17
|
+
DOC_BROWSER_HOME_TAB_KIND,
|
|
18
|
+
DOC_BROWSER_HOME_URL,
|
|
17
19
|
DOCS_DEFAULT_BASE_URL,
|
|
18
20
|
isDocsUrl,
|
|
19
21
|
} from '@/shared/components/doc-browser/utils/doc-browser-url.utils';
|
|
@@ -37,6 +39,7 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
|
|
|
37
39
|
...snapshot,
|
|
38
40
|
currentTab,
|
|
39
41
|
open: docBrowserManager.open,
|
|
42
|
+
openNewTab: docBrowserManager.openNewTab,
|
|
40
43
|
close: docBrowserManager.close,
|
|
41
44
|
toggleMode: docBrowserManager.toggleMode,
|
|
42
45
|
navigate: docBrowserManager.navigate,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
BookOpen,
|
|
4
|
+
Boxes,
|
|
5
|
+
BrainCircuit,
|
|
6
|
+
Grid3X3,
|
|
7
|
+
Wrench,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import { useNavigate } from 'react-router-dom';
|
|
10
|
+
import type { DocBrowserContextValue } from './doc-browser-context';
|
|
11
|
+
import {
|
|
12
|
+
docBrowserRouteRegistry,
|
|
13
|
+
type DocBrowserHomeNavigationItem,
|
|
14
|
+
type DocBrowserNavigationTarget,
|
|
15
|
+
} from './utils/doc-browser-route-registry.utils';
|
|
16
|
+
import { cn } from '@/shared/lib/utils';
|
|
17
|
+
|
|
18
|
+
type DocBrowserHomePageProps = {
|
|
19
|
+
open: DocBrowserContextValue['open'];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type DocBrowserHomeItem = {
|
|
23
|
+
accentClassName: string;
|
|
24
|
+
icon: ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const HOME_ITEM_VIEW_BY_ID: Record<string, DocBrowserHomeItem> = {
|
|
28
|
+
apps: {
|
|
29
|
+
accentClassName: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
|
30
|
+
icon: <Boxes className="h-5 w-5" />,
|
|
31
|
+
},
|
|
32
|
+
'service-apps': {
|
|
33
|
+
accentClassName: 'bg-sky-50 text-sky-700 border-sky-100',
|
|
34
|
+
icon: <Grid3X3 className="h-5 w-5" />,
|
|
35
|
+
},
|
|
36
|
+
docs: {
|
|
37
|
+
accentClassName: 'bg-amber-50 text-amber-700 border-amber-100',
|
|
38
|
+
icon: <BookOpen className="h-5 w-5" />,
|
|
39
|
+
},
|
|
40
|
+
'skill-marketplace': {
|
|
41
|
+
accentClassName: 'bg-rose-50 text-rose-700 border-rose-100',
|
|
42
|
+
icon: <BrainCircuit className="h-5 w-5" />,
|
|
43
|
+
},
|
|
44
|
+
'mcp-marketplace': {
|
|
45
|
+
accentClassName: 'bg-indigo-50 text-indigo-700 border-indigo-100',
|
|
46
|
+
icon: <Wrench className="h-5 w-5" />,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function resolveHomeItemView(item: DocBrowserHomeNavigationItem): DocBrowserHomeItem {
|
|
51
|
+
return HOME_ITEM_VIEW_BY_ID[item.id] ?? {
|
|
52
|
+
accentClassName: 'bg-gray-50 text-gray-700 border-gray-100',
|
|
53
|
+
icon: <Boxes className="h-5 w-5" />,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function DocBrowserHomePage({ open }: DocBrowserHomePageProps) {
|
|
58
|
+
const navigate = useNavigate();
|
|
59
|
+
const items = docBrowserRouteRegistry.getHomeNavigationItems();
|
|
60
|
+
|
|
61
|
+
const openTarget = (target: DocBrowserNavigationTarget) => {
|
|
62
|
+
if (target.type === 'app-route') {
|
|
63
|
+
navigate(target.path);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
open(target.url, target.options);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="h-full overflow-auto bg-[#fbfaf7] px-5 py-6">
|
|
71
|
+
<div className="mx-auto w-full max-w-[640px]">
|
|
72
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(88px,1fr))] gap-x-3 gap-y-5">
|
|
73
|
+
{items.map((item) => {
|
|
74
|
+
const view = resolveHomeItemView(item);
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
key={item.id}
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => openTarget(item.target)}
|
|
80
|
+
className="group flex min-h-[82px] flex-col items-center gap-2 rounded-lg px-2 py-2 text-center transition-colors hover:bg-white/80"
|
|
81
|
+
>
|
|
82
|
+
<span className={cn('flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border shadow-[0_1px_2px_rgba(30,20,10,0.04)] transition-transform group-hover:scale-[1.03]', view.accentClassName)}>
|
|
83
|
+
{view.icon}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="min-w-0 max-w-full">
|
|
86
|
+
<span className="line-clamp-2 block text-xs font-medium leading-snug text-gray-800">{item.label}</span>
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|