@nextclaw/ui 0.19.0 → 0.19.2
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 +14 -0
- package/dist/assets/{api-Cyxp93Fn.js → api-BH6Oslx4.js} +3 -3
- package/dist/assets/{appearance-settings-page-DsMl931U.js → appearance-settings-page-zhgfcGgW.js} +1 -1
- package/dist/assets/{book-open-Cl5CzL0g.js → book-open-DGTS6oEO.js} +1 -1
- package/dist/assets/{channels-list-page-D_B7JGoZ.js → channels-list-page-3D5lhZoT.js} +2 -2
- package/dist/assets/chat-page-BXu3hnOK.js +1 -0
- package/dist/assets/{config-split-page-C-YVl2mf.js → config-split-page-B55XD_ZY.js} +1 -1
- package/dist/assets/{confirm-dialog-Bz3ieDQ5.js → confirm-dialog-BCCreI_f.js} +1 -1
- package/dist/assets/{desktop-update-config-DO051-QG.js → desktop-update-config-Blol-Umu.js} +1 -1
- package/dist/assets/{dist-CDPl8mm7.js → dist-BYSUQaO2.js} +1 -1
- package/dist/assets/{dist-HFDWY6-K.js → dist-IRWcJpfm.js} +1 -1
- package/dist/assets/doc-browser-BExPq1Hk.js +1 -0
- package/dist/assets/{doc-browser-D6H_nPzO.js → doc-browser-D8rNrWHk.js} +1 -1
- package/dist/assets/{doc-browser-context-BKwz_j44.js → doc-browser-context-DjTbIbQc.js} +1 -1
- package/dist/assets/{form-actions-BUZOCfPR.js → form-actions-C8hazgNP.js} +1 -1
- package/dist/assets/index-Btj8yN4T.css +1 -0
- package/dist/assets/index-CHcGqqBe.js +109 -0
- package/dist/assets/loader-circle-C75DUmmm.js +1 -0
- package/dist/assets/mcp-marketplace-page-CIMRYgOY.js +9 -0
- package/dist/assets/mcp-marketplace-page-D2NlhG1k.js +1 -0
- package/dist/assets/model-config-page-ChR-ENjp.js +1 -0
- package/dist/assets/{navigation-link-CDMYpkVv.js → navigation-link-kGAcL2N2.js} +1 -1
- package/dist/assets/plus-TzSIMKWW.js +1 -0
- package/dist/assets/privacy-settings-page-v9LTFvJe.js +1 -0
- package/dist/assets/{provider-scoped-model-input-C2iUtZi7.js → provider-scoped-model-input-DiSpKSk0.js} +1 -1
- package/dist/assets/providers-config-page-BvXa03cp.js +1 -0
- package/dist/assets/{react-B7Paq2b3.js → react-Dwvp5yeX.js} +1 -1
- package/dist/assets/{refresh-cw-XrnNVwuQ.js → refresh-cw-C_oj_h_s.js} +1 -1
- package/dist/assets/remote-BgYduCQR.js +1 -0
- package/dist/assets/rotate-cw-BT8HxlcB.js +1 -0
- package/dist/assets/{runtime-config-page-BVO9B8Ns.js → runtime-config-page-BuYof2Ty.js} +1 -1
- package/dist/assets/{save-_nSNAYQu.js → save-BZE6H02N.js} +1 -1
- package/dist/assets/{search-0OTHFRMx.js → search-C7Tj40kC.js} +1 -1
- package/dist/assets/{search-config-page-BwyeUjKt.js → search-config-page-CQTJOsOf.js} +1 -1
- package/dist/assets/{secrets-config-page-2O-TzroJ.js → secrets-config-page-B__VLAEQ.js} +2 -2
- package/dist/assets/security-config-CtJqaPVL.js +1 -0
- package/dist/assets/{select-BZHvnwQB.js → select-fZ5tifar.js} +1 -1
- package/dist/assets/{settings-2-CM_PvDNk.js → settings-2-BHGgNlOH.js} +1 -1
- package/dist/assets/skeleton-CJLc1CWn.js +1 -0
- package/dist/assets/{tag-chip-Ce53PMTL.js → tag-chip-CwYZcRoe.js} +1 -1
- package/dist/assets/x-BMl7cucN.js +1 -0
- package/dist/index.html +20 -20
- package/package.json +3 -3
- package/src/app/configs/app-navigation.config.ts +6 -0
- package/src/app/index.tsx +8 -0
- package/src/features/panel-apps/components/__tests__/panel-app-list-item.test.tsx +24 -12
- package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +51 -0
- package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +59 -5
- package/src/features/panel-apps/components/panel-app-list-item.tsx +5 -18
- package/src/features/panel-apps/components/panel-app-main-sidebar-menu-item.tsx +37 -0
- package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +71 -20
- package/src/features/panel-apps/components/panel-app-toolbar.tsx +18 -38
- package/src/features/panel-apps/components/panel-apps-list.tsx +0 -9
- package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -2
- package/src/features/right-panel-resources/index.ts +1 -0
- package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +12 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +8 -0
- package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +33 -0
- package/src/features/settings/pages/privacy-settings-page.tsx +83 -0
- package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +26 -0
- package/src/shared/hooks/use-config.ts +21 -1
- package/src/shared/lib/api/types.ts +13 -0
- package/src/shared/lib/api/utils/config.utils.ts +8 -0
- package/src/shared/lib/i18n/locales/en-US/core.json +19 -0
- package/src/shared/lib/i18n/locales/zh-CN/core.json +19 -0
- package/dist/assets/chat-page-D3ZeULTv.js +0 -1
- package/dist/assets/doc-browser-CWhs0JQN.js +0 -1
- package/dist/assets/index-DR3GLGme.css +0 -1
- package/dist/assets/index-j6DBnvlr.js +0 -109
- package/dist/assets/loader-circle-s65a4ZzL.js +0 -1
- package/dist/assets/mcp-marketplace-page-BJvzGt7h.js +0 -9
- package/dist/assets/mcp-marketplace-page-EpFHeJzw.js +0 -1
- package/dist/assets/model-config-page-CBoh0DVh.js +0 -1
- package/dist/assets/plus-XDIkqpkt.js +0 -1
- package/dist/assets/providers-config-page-DX1pISTo.js +0 -1
- package/dist/assets/remote-D_7_OGoj.js +0 -1
- package/dist/assets/rotate-cw-BhIWpLaU.js +0 -1
- package/dist/assets/security-config-BgIIrg1s.js +0 -1
- package/dist/assets/skeleton-Bv4MMbH7.js +0 -1
- package/dist/assets/x-CCjWN5ys.js +0 -1
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
|
|
5
5
|
|
|
6
|
+
const mainSidebarMutation = vi.hoisted(() => ({
|
|
7
|
+
isPending: false,
|
|
8
|
+
mutate: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
|
|
12
|
+
usePanelApps: vi.fn(),
|
|
13
|
+
useUpdatePanelAppPreferences: () => mainSidebarMutation,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const entry = {
|
|
17
|
+
appId: 'ink-assistant',
|
|
18
|
+
clientDeclared: false,
|
|
19
|
+
clientGranted: false,
|
|
20
|
+
contentPath: '/api/panel-apps/ink-assistant/content',
|
|
21
|
+
createdAt: '2026-08-19T00:00:00.000Z',
|
|
22
|
+
favorite: false,
|
|
23
|
+
fileName: 'ink-assistant.panel.html',
|
|
24
|
+
id: 'ink-assistant',
|
|
25
|
+
kind: 'single-file' as const,
|
|
26
|
+
mainSidebar: false,
|
|
27
|
+
openCount: 0,
|
|
28
|
+
sizeBytes: 10,
|
|
29
|
+
title: '墨爪助手',
|
|
30
|
+
updatedAt: '2026-08-19T00:00:00.000Z',
|
|
31
|
+
};
|
|
32
|
+
|
|
6
33
|
describe('PanelAppToolbar', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
mainSidebarMutation.isPending = false;
|
|
36
|
+
mainSidebarMutation.mutate.mockReset();
|
|
37
|
+
});
|
|
38
|
+
|
|
7
39
|
it('keeps placement in the overflow menu without adding a second back action', async () => {
|
|
8
40
|
const user = userEvent.setup();
|
|
9
|
-
const onToggleMainSidebar = vi.fn();
|
|
10
41
|
render(
|
|
11
42
|
<PanelAppToolbar
|
|
12
43
|
appTitle="墨爪助手"
|
|
13
|
-
|
|
44
|
+
entry={entry}
|
|
14
45
|
onRefresh={vi.fn()}
|
|
15
|
-
onToggleMainSidebar={onToggleMainSidebar}
|
|
16
46
|
/>,
|
|
17
47
|
);
|
|
18
48
|
|
|
@@ -25,7 +55,31 @@ describe('PanelAppToolbar', () => {
|
|
|
25
55
|
expect(addButton.getAttribute('aria-pressed')).toBe('false');
|
|
26
56
|
await user.click(addButton);
|
|
27
57
|
|
|
28
|
-
expect(
|
|
58
|
+
expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
|
|
59
|
+
id: 'ink-assistant',
|
|
60
|
+
preferences: { mainSidebar: true },
|
|
61
|
+
});
|
|
29
62
|
expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
|
|
30
63
|
});
|
|
64
|
+
|
|
65
|
+
it('uses the same menu item to remove an app already in the main sidebar', async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(
|
|
68
|
+
<PanelAppToolbar
|
|
69
|
+
appTitle="墨爪助手"
|
|
70
|
+
entry={{ ...entry, mainSidebar: true }}
|
|
71
|
+
onRefresh={vi.fn()}
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
|
|
76
|
+
const removeButton = screen.getByRole('button', { name: 'Remove from main sidebar' });
|
|
77
|
+
expect(removeButton.getAttribute('aria-pressed')).toBe('true');
|
|
78
|
+
await user.click(removeButton);
|
|
79
|
+
|
|
80
|
+
expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
|
|
81
|
+
id: 'ink-assistant',
|
|
82
|
+
preferences: { mainSidebar: false },
|
|
83
|
+
});
|
|
84
|
+
});
|
|
31
85
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, type MouseEvent } from 'react';
|
|
2
|
-
import { MoreVertical,
|
|
2
|
+
import { MoreVertical, Star, Trash2, type LucideIcon } from 'lucide-react';
|
|
3
3
|
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
4
4
|
import { PanelAppIcon } from '@/features/panel-apps/components/panel-app-icon';
|
|
5
|
+
import { PanelAppMainSidebarMenuItem } from '@/features/panel-apps/components/panel-app-main-sidebar-menu-item';
|
|
5
6
|
import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
|
|
6
7
|
import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
|
|
7
8
|
import { getLanguage, getLocale, t } from '@/shared/lib/i18n';
|
|
@@ -11,20 +12,16 @@ export function PanelAppListItem({
|
|
|
11
12
|
deletePending,
|
|
12
13
|
entry,
|
|
13
14
|
favoritePending,
|
|
14
|
-
mainSidebarPending,
|
|
15
15
|
onDelete,
|
|
16
16
|
onOpen,
|
|
17
17
|
onToggleFavorite,
|
|
18
|
-
onToggleMainSidebar,
|
|
19
18
|
}: {
|
|
20
19
|
deletePending: boolean;
|
|
21
20
|
entry: PanelAppEntryView;
|
|
22
21
|
favoritePending: boolean;
|
|
23
|
-
mainSidebarPending: boolean;
|
|
24
22
|
onDelete: () => void;
|
|
25
23
|
onOpen: () => void;
|
|
26
24
|
onToggleFavorite: () => void;
|
|
27
|
-
onToggleMainSidebar: () => void;
|
|
28
25
|
}) {
|
|
29
26
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
30
27
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
@@ -37,18 +34,10 @@ export function PanelAppListItem({
|
|
|
37
34
|
event.stopPropagation();
|
|
38
35
|
onToggleFavorite();
|
|
39
36
|
};
|
|
40
|
-
const handleMainSidebar = () => {
|
|
41
|
-
setIsMenuOpen(false);
|
|
42
|
-
onToggleMainSidebar();
|
|
43
|
-
};
|
|
44
37
|
const openDeleteDialog = () => {
|
|
45
38
|
setIsMenuOpen(false);
|
|
46
39
|
setIsDeleteDialogOpen(true);
|
|
47
40
|
};
|
|
48
|
-
const mainSidebarLabel = entry.mainSidebar
|
|
49
|
-
? t('panelAppsRemoveFromMainSidebar')
|
|
50
|
-
: t('panelAppsAddToMainSidebar');
|
|
51
|
-
|
|
52
41
|
return (
|
|
53
42
|
<div className="group w-full min-w-0 rounded-lg border border-border/60 bg-card px-2.5 py-2.5 transition-colors hover:bg-muted/40">
|
|
54
43
|
<div className="flex min-w-0 items-start gap-2">
|
|
@@ -87,11 +76,9 @@ export function PanelAppListItem({
|
|
|
87
76
|
</button>
|
|
88
77
|
</PopoverTrigger>
|
|
89
78
|
<PopoverContent align="end" className="w-48 rounded-xl p-1.5">
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
label={mainSidebarLabel}
|
|
94
|
-
onClick={handleMainSidebar}
|
|
79
|
+
<PanelAppMainSidebarMenuItem
|
|
80
|
+
entry={entry}
|
|
81
|
+
onSelect={() => setIsMenuOpen(false)}
|
|
95
82
|
/>
|
|
96
83
|
<PanelAppMenuItem
|
|
97
84
|
destructive
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PanelLeft, PanelLeftDashed } from 'lucide-react';
|
|
2
|
+
import { useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
|
|
3
|
+
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
4
|
+
import { t } from '@/shared/lib/i18n';
|
|
5
|
+
|
|
6
|
+
export function PanelAppMainSidebarMenuItem({
|
|
7
|
+
entry,
|
|
8
|
+
onSelect,
|
|
9
|
+
}: {
|
|
10
|
+
entry: PanelAppEntryView;
|
|
11
|
+
onSelect?: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
const updatePreferences = useUpdatePanelAppPreferences();
|
|
14
|
+
const label = entry.mainSidebar
|
|
15
|
+
? t('panelAppsRemoveFromMainSidebar')
|
|
16
|
+
: t('panelAppsAddToMainSidebar');
|
|
17
|
+
const Icon = entry.mainSidebar ? PanelLeftDashed : PanelLeft;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
disabled={updatePreferences.isPending}
|
|
23
|
+
aria-pressed={entry.mainSidebar}
|
|
24
|
+
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
25
|
+
onClick={() => {
|
|
26
|
+
onSelect?.();
|
|
27
|
+
updatePreferences.mutate({
|
|
28
|
+
id: entry.id,
|
|
29
|
+
preferences: { mainSidebar: !entry.mainSidebar },
|
|
30
|
+
});
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<Icon className="h-4 w-4 shrink-0" />
|
|
34
|
+
<span>{label}</span>
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { MoreVertical } from "lucide-react";
|
|
2
3
|
import { SidebarNavLinkItem } from "@/app/components/layout/sidebar-items";
|
|
3
4
|
import { SIDEBAR_RAIL_STACK_CLASS } from "@/app/components/layout/sidebar-rail.styles";
|
|
4
5
|
import { PanelAppIcon } from "@/features/panel-apps/components/panel-app-icon";
|
|
6
|
+
import { PanelAppMainSidebarMenuItem } from "@/features/panel-apps/components/panel-app-main-sidebar-menu-item";
|
|
5
7
|
import { usePanelApps } from "@/features/panel-apps/hooks/use-panel-apps";
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover";
|
|
6
9
|
import type { PanelAppEntryView } from "@/shared/lib/api";
|
|
10
|
+
import { t } from "@/shared/lib/i18n";
|
|
7
11
|
import { cn } from "@/shared/lib/utils";
|
|
8
12
|
|
|
9
13
|
const MAIN_SIDEBAR_FALLBACK_ORDER = Number.MAX_SAFE_INTEGER;
|
|
@@ -45,28 +49,75 @@ export function PanelAppMainSidebarNav({
|
|
|
45
49
|
>
|
|
46
50
|
<ul className={isCollapsed ? SIDEBAR_RAIL_STACK_CLASS : "space-y-0.5"}>
|
|
47
51
|
{entries.map((entry) => (
|
|
48
|
-
<
|
|
52
|
+
<PanelAppMainSidebarNavItem
|
|
49
53
|
key={entry.appId}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
to={`/apps/panel/${encodeURIComponent(entry.appId)}`}
|
|
54
|
-
label={entry.title}
|
|
55
|
-
iconNode={(
|
|
56
|
-
<PanelAppIcon
|
|
57
|
-
icon={entry.icon}
|
|
58
|
-
title={entry.title}
|
|
59
|
-
className="h-full w-full text-[13px]"
|
|
60
|
-
imageClassName="rounded-[3px]"
|
|
61
|
-
/>
|
|
62
|
-
)}
|
|
63
|
-
density="compact"
|
|
64
|
-
collapsed={isCollapsed}
|
|
65
|
-
className={isCollapsed ? undefined : "rounded-lg px-2.5 py-1.5"}
|
|
66
|
-
/>
|
|
67
|
-
</li>
|
|
54
|
+
entry={entry}
|
|
55
|
+
isCollapsed={isCollapsed}
|
|
56
|
+
/>
|
|
68
57
|
))}
|
|
69
58
|
</ul>
|
|
70
59
|
</div>
|
|
71
60
|
);
|
|
72
61
|
}
|
|
62
|
+
|
|
63
|
+
function PanelAppMainSidebarNavItem({
|
|
64
|
+
entry,
|
|
65
|
+
isCollapsed,
|
|
66
|
+
}: {
|
|
67
|
+
entry: PanelAppEntryView;
|
|
68
|
+
isCollapsed: boolean;
|
|
69
|
+
}) {
|
|
70
|
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
71
|
+
const link = (
|
|
72
|
+
<SidebarNavLinkItem
|
|
73
|
+
to={`/apps/panel/${encodeURIComponent(entry.appId)}`}
|
|
74
|
+
label={entry.title}
|
|
75
|
+
iconNode={(
|
|
76
|
+
<PanelAppIcon
|
|
77
|
+
icon={entry.icon}
|
|
78
|
+
title={entry.title}
|
|
79
|
+
className="h-full w-full text-[13px]"
|
|
80
|
+
imageClassName="rounded-[3px]"
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
density="compact"
|
|
84
|
+
collapsed={isCollapsed}
|
|
85
|
+
className={isCollapsed ? undefined : "rounded-lg py-1.5 pl-2.5 pr-9"}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (isCollapsed) {
|
|
90
|
+
return <li className="flex justify-center">{link}</li>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const menuLabel = `${t("panelAppsMoreActions")}: ${entry.title}`;
|
|
94
|
+
return (
|
|
95
|
+
<li>
|
|
96
|
+
<div className="group/panel-app relative">
|
|
97
|
+
{link}
|
|
98
|
+
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
|
99
|
+
<PopoverTrigger asChild>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
aria-label={menuLabel}
|
|
103
|
+
className={cn(
|
|
104
|
+
"absolute right-1 top-1/2 z-[1] -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-[color,background-color,opacity] hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
|
|
105
|
+
isMenuOpen
|
|
106
|
+
? "pointer-events-auto opacity-100"
|
|
107
|
+
: "pointer-events-none opacity-0 group-hover/panel-app:pointer-events-auto group-hover/panel-app:opacity-100 group-focus-within/panel-app:pointer-events-auto group-focus-within/panel-app:opacity-100",
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
<MoreVertical className="h-4 w-4" />
|
|
111
|
+
</button>
|
|
112
|
+
</PopoverTrigger>
|
|
113
|
+
<PopoverContent align="end" className="w-48 rounded-xl p-1.5">
|
|
114
|
+
<PanelAppMainSidebarMenuItem
|
|
115
|
+
entry={entry}
|
|
116
|
+
onSelect={() => setIsMenuOpen(false)}
|
|
117
|
+
/>
|
|
118
|
+
</PopoverContent>
|
|
119
|
+
</Popover>
|
|
120
|
+
</div>
|
|
121
|
+
</li>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -1,29 +1,25 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react';
|
|
2
|
-
import { MoreVertical,
|
|
3
|
-
import {
|
|
2
|
+
import { MoreVertical, RefreshCw } from 'lucide-react';
|
|
3
|
+
import { PanelAppMainSidebarMenuItem } from '@/features/panel-apps/components/panel-app-main-sidebar-menu-item';
|
|
4
|
+
import { usePanelApps } from '@/features/panel-apps/hooks/use-panel-apps';
|
|
5
|
+
import { readPanelAppIdFromTab } from '@/features/right-panel-resources';
|
|
4
6
|
import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
|
|
5
7
|
import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
|
|
8
|
+
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
6
9
|
import { t } from '@/shared/lib/i18n';
|
|
7
10
|
|
|
8
11
|
type PanelAppToolbarProps = {
|
|
9
12
|
appTitle: string;
|
|
10
|
-
|
|
11
|
-
mainSidebarPending?: boolean;
|
|
13
|
+
entry?: PanelAppEntryView;
|
|
12
14
|
onRefresh: () => void;
|
|
13
|
-
onToggleMainSidebar?: () => void;
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export function PanelAppToolbar({
|
|
17
18
|
appTitle,
|
|
18
|
-
|
|
19
|
-
mainSidebarPending = false,
|
|
19
|
+
entry,
|
|
20
20
|
onRefresh,
|
|
21
|
-
onToggleMainSidebar,
|
|
22
21
|
}: PanelAppToolbarProps) {
|
|
23
22
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
24
|
-
const mainSidebarLabel = mainSidebar
|
|
25
|
-
? t('panelAppsRemoveFromMainSidebar')
|
|
26
|
-
: t('panelAppsAddToMainSidebar');
|
|
27
23
|
|
|
28
24
|
return (
|
|
29
25
|
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-card px-3.5 py-2 shrink-0">
|
|
@@ -33,7 +29,7 @@ export function PanelAppToolbar({
|
|
|
33
29
|
>
|
|
34
30
|
{appTitle}
|
|
35
31
|
</span>
|
|
36
|
-
{
|
|
32
|
+
{entry ? (
|
|
37
33
|
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
|
38
34
|
<PopoverTrigger asChild>
|
|
39
35
|
<IconActionButton
|
|
@@ -43,21 +39,10 @@ export function PanelAppToolbar({
|
|
|
43
39
|
/>
|
|
44
40
|
</PopoverTrigger>
|
|
45
41
|
<PopoverContent align="end" className="w-48 rounded-xl p-1.5">
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
51
|
-
onClick={() => {
|
|
52
|
-
setIsMenuOpen(false);
|
|
53
|
-
onToggleMainSidebar();
|
|
54
|
-
}}
|
|
55
|
-
>
|
|
56
|
-
{mainSidebar
|
|
57
|
-
? <PanelLeftDashed className="h-4 w-4 shrink-0" />
|
|
58
|
-
: <PanelLeft className="h-4 w-4 shrink-0" />}
|
|
59
|
-
<span>{mainSidebarLabel}</span>
|
|
60
|
-
</button>
|
|
42
|
+
<PanelAppMainSidebarMenuItem
|
|
43
|
+
entry={entry}
|
|
44
|
+
onSelect={() => setIsMenuOpen(false)}
|
|
45
|
+
/>
|
|
61
46
|
</PopoverContent>
|
|
62
47
|
</Popover>
|
|
63
48
|
) : null}
|
|
@@ -72,16 +57,18 @@ export function PanelAppToolbar({
|
|
|
72
57
|
}
|
|
73
58
|
|
|
74
59
|
export function PanelAppDocBrowserToolbar({
|
|
75
|
-
appId,
|
|
76
60
|
appTitle,
|
|
61
|
+
currentUrl,
|
|
77
62
|
onRefresh,
|
|
63
|
+
resourceUri,
|
|
78
64
|
}: {
|
|
79
|
-
appId: string | null;
|
|
80
65
|
appTitle: string;
|
|
66
|
+
currentUrl: string;
|
|
81
67
|
onRefresh: () => void;
|
|
68
|
+
resourceUri?: string;
|
|
82
69
|
}) {
|
|
83
70
|
const panelApps = usePanelApps();
|
|
84
|
-
const
|
|
71
|
+
const appId = readPanelAppIdFromTab({ currentUrl, resourceUri });
|
|
85
72
|
const entry = useMemo(
|
|
86
73
|
() => panelApps.data?.entries.find((candidate) => candidate.appId === appId),
|
|
87
74
|
[appId, panelApps.data?.entries],
|
|
@@ -90,15 +77,8 @@ export function PanelAppDocBrowserToolbar({
|
|
|
90
77
|
return (
|
|
91
78
|
<PanelAppToolbar
|
|
92
79
|
appTitle={entry?.title ?? appTitle}
|
|
93
|
-
|
|
94
|
-
mainSidebarPending={updatePreferences.isPending}
|
|
80
|
+
entry={entry}
|
|
95
81
|
onRefresh={onRefresh}
|
|
96
|
-
onToggleMainSidebar={entry
|
|
97
|
-
? () => updatePreferences.mutate({
|
|
98
|
-
id: entry.id,
|
|
99
|
-
preferences: { mainSidebar: !entry.mainSidebar },
|
|
100
|
-
})
|
|
101
|
-
: undefined}
|
|
102
82
|
/>
|
|
103
83
|
);
|
|
104
84
|
}
|
|
@@ -48,13 +48,6 @@ export function PanelAppsList({
|
|
|
48
48
|
});
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
const toggleMainSidebar = (entry: PanelAppEntryView) => {
|
|
52
|
-
updatePreferences.mutate({
|
|
53
|
-
id: entry.id,
|
|
54
|
-
preferences: { mainSidebar: !entry.mainSidebar },
|
|
55
|
-
});
|
|
56
|
-
};
|
|
57
|
-
|
|
58
51
|
const deleteEntry = (entry: PanelAppEntryView) => {
|
|
59
52
|
deletePanelApp.mutate(entry.id);
|
|
60
53
|
};
|
|
@@ -129,11 +122,9 @@ export function PanelAppsList({
|
|
|
129
122
|
entry={entry}
|
|
130
123
|
deletePending={deletePanelApp.isPending}
|
|
131
124
|
favoritePending={updatePreferences.isPending}
|
|
132
|
-
mainSidebarPending={updatePreferences.isPending}
|
|
133
125
|
onDelete={() => deleteEntry(entry)}
|
|
134
126
|
onOpen={() => void openPanelApp(entry)}
|
|
135
127
|
onToggleFavorite={() => toggleFavorite(entry)}
|
|
136
|
-
onToggleMainSidebar={() => toggleMainSidebar(entry)}
|
|
137
128
|
/>
|
|
138
129
|
))}
|
|
139
130
|
</div>
|
|
@@ -6,7 +6,6 @@ import { AppsPanel, type AppsPanelTab } from '@/features/apps';
|
|
|
6
6
|
import { PanelAppDocBrowserToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
|
|
7
7
|
import {
|
|
8
8
|
createPanelAppRightPanelResourceTarget,
|
|
9
|
-
readPanelAppIdFromResourceUri,
|
|
10
9
|
RIGHT_PANEL_APPS_TAB_KIND,
|
|
11
10
|
RIGHT_PANEL_APPS_URL,
|
|
12
11
|
RIGHT_PANEL_HOME_TAB_KIND,
|
|
@@ -78,9 +77,10 @@ export const PANEL_APPS_DOC_BROWSER_RENDERERS: DocBrowserCustomTabRenderers = {
|
|
|
78
77
|
renderIcon: () => <AppWindow className="w-4 h-4 text-primary shrink-0" />,
|
|
79
78
|
renderToolbar: ({ refreshIframe, tab }) => (
|
|
80
79
|
<PanelAppDocBrowserToolbar
|
|
81
|
-
appId={readPanelAppIdFromResourceUri(tab.resourceUri ?? tab.currentUrl)}
|
|
82
80
|
appTitle={tab.title || t('panelAppsTitle')}
|
|
81
|
+
currentUrl={tab.currentUrl}
|
|
83
82
|
onRefresh={refreshIframe}
|
|
83
|
+
resourceUri={tab.resourceUri}
|
|
84
84
|
/>
|
|
85
85
|
),
|
|
86
86
|
supportsScrollRestoration: true,
|
package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createPanelAppResourceUri,
|
|
7
7
|
createPanelAppRightPanelResourceTarget,
|
|
8
8
|
readPanelAppIdFromResourceUri,
|
|
9
|
+
readPanelAppIdFromTab,
|
|
9
10
|
} from '@/features/right-panel-resources/utils/right-panel-resource-uri.utils';
|
|
10
11
|
|
|
11
12
|
function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
|
|
@@ -78,6 +79,17 @@ describe('right-panel-resource uri utils', () => {
|
|
|
78
79
|
expect(readPanelAppIdFromResourceUri('nextclaw://apps')).toBeNull();
|
|
79
80
|
});
|
|
80
81
|
|
|
82
|
+
it('falls back to the stable current URL for restored panel app shortcuts', () => {
|
|
83
|
+
expect(readPanelAppIdFromTab({
|
|
84
|
+
currentUrl: '/api/panel-apps/publisher.todo/content',
|
|
85
|
+
resourceUri: 'nextclaw://panel-app',
|
|
86
|
+
})).toBe('publisher.todo');
|
|
87
|
+
expect(readPanelAppIdFromTab({
|
|
88
|
+
currentUrl: '/api/panel-apps/publisher.todo/content?path=%2Ftmp%2Ftodo.panel',
|
|
89
|
+
resourceUri: 'nextclaw://panel-app/publisher.todo?path=%2Ftmp%2Ftodo.panel',
|
|
90
|
+
})).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
81
93
|
it('keeps panel app image icons as dock image icons', () => {
|
|
82
94
|
const target = createPanelAppRightPanelResourceTarget(createPanelAppEntry({
|
|
83
95
|
icon: '/api/panel-apps/demo/assets/icon.png',
|
|
@@ -107,6 +107,14 @@ export function readPanelAppIdFromResourceUri(value: string): string | null {
|
|
|
107
107
|
: readPanelAppIdFromParsedResourceUri(uri);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export function readPanelAppIdFromTab(
|
|
111
|
+
tab: Pick<DocBrowserTab, 'currentUrl' | 'resourceUri'>,
|
|
112
|
+
): string | null {
|
|
113
|
+
return (tab.resourceUri
|
|
114
|
+
? readPanelAppIdFromResourceUri(tab.resourceUri)
|
|
115
|
+
: null) ?? readPanelAppIdFromResourceUri(tab.currentUrl);
|
|
116
|
+
}
|
|
117
|
+
|
|
110
118
|
function isPanelAppImageIcon(icon: string): boolean {
|
|
111
119
|
return (
|
|
112
120
|
icon.startsWith('data:image/') ||
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { PrivacySettingsPage } from '@/features/settings/pages/privacy-settings-page';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({ mutate: vi.fn() }));
|
|
6
|
+
|
|
7
|
+
vi.mock('@/shared/hooks/use-config', () => ({
|
|
8
|
+
useConfig: () => ({
|
|
9
|
+
data: { productAnalytics: { enabled: false, audience: 'external' } },
|
|
10
|
+
isLoading: false
|
|
11
|
+
}),
|
|
12
|
+
useUpdateProductAnalytics: () => ({
|
|
13
|
+
isPending: false,
|
|
14
|
+
mutate: mocks.mutate
|
|
15
|
+
})
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe('PrivacySettingsPage', () => {
|
|
19
|
+
beforeEach(() => mocks.mutate.mockClear());
|
|
20
|
+
|
|
21
|
+
it('requires explicit opt-in and saves the selected test audience', () => {
|
|
22
|
+
render(<PrivacySettingsPage />);
|
|
23
|
+
|
|
24
|
+
const shareSwitch = screen.getByRole('switch', { name: 'Share product activity' });
|
|
25
|
+
expect(shareSwitch.getAttribute('aria-checked')).toBe('false');
|
|
26
|
+
fireEvent.click(shareSwitch);
|
|
27
|
+
expect(mocks.mutate).toHaveBeenCalledWith({ data: { enabled: true } });
|
|
28
|
+
|
|
29
|
+
fireEvent.click(screen.getByRole('combobox', { name: 'Analytics audience' }));
|
|
30
|
+
fireEvent.click(screen.getByRole('option', { name: 'QA testing' }));
|
|
31
|
+
expect(mocks.mutate).toHaveBeenCalledWith({ data: { audience: 'qa' } });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { SettingsPage } from '@/shared/components/settings/settings-page';
|
|
2
|
+
import { SettingRow, SettingsGroup, SettingsSection } from '@/shared/components/settings/setting-row';
|
|
3
|
+
import { Switch } from '@/shared/components/ui/switch';
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
|
5
|
+
import { useConfig, useUpdateProductAnalytics } from '@/shared/hooks/use-config';
|
|
6
|
+
import type { ProductAnalyticsAudience } from '@/shared/lib/api';
|
|
7
|
+
import { t } from '@/shared/lib/i18n';
|
|
8
|
+
|
|
9
|
+
export function PrivacySettingsPage(): JSX.Element {
|
|
10
|
+
const configQuery = useConfig();
|
|
11
|
+
const updateMutation = useUpdateProductAnalytics();
|
|
12
|
+
const enabled = configQuery.data?.productAnalytics.enabled ?? false;
|
|
13
|
+
const audience = configQuery.data?.productAnalytics.audience ?? 'external';
|
|
14
|
+
|
|
15
|
+
const updateEnabled = (nextEnabled: boolean) => {
|
|
16
|
+
updateMutation.mutate({ data: { enabled: nextEnabled } });
|
|
17
|
+
};
|
|
18
|
+
const updateAudience = (nextAudience: string) => {
|
|
19
|
+
const value = nextAudience as ProductAnalyticsAudience;
|
|
20
|
+
updateMutation.mutate({ data: { audience: value } });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<SettingsPage title={t('privacyPageTitle')} description={t('privacyPageDescription')}>
|
|
25
|
+
<SettingsSection title={t('productAnalyticsSectionTitle')} description={t('productAnalyticsSectionDescription')}>
|
|
26
|
+
<SettingsGroup>
|
|
27
|
+
<SettingRow
|
|
28
|
+
title={t('productAnalyticsEnabledTitle')}
|
|
29
|
+
description={t('productAnalyticsEnabledDescription')}
|
|
30
|
+
control={(
|
|
31
|
+
<Switch
|
|
32
|
+
aria-label={t('productAnalyticsEnabledTitle')}
|
|
33
|
+
checked={enabled}
|
|
34
|
+
disabled={configQuery.isLoading || updateMutation.isPending}
|
|
35
|
+
onCheckedChange={updateEnabled}
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
38
|
+
/>
|
|
39
|
+
<SettingRow
|
|
40
|
+
title={t('productAnalyticsAudienceTitle')}
|
|
41
|
+
description={t('productAnalyticsAudienceDescription')}
|
|
42
|
+
control={(
|
|
43
|
+
<Select
|
|
44
|
+
value={audience}
|
|
45
|
+
onValueChange={updateAudience}
|
|
46
|
+
disabled={configQuery.isLoading || updateMutation.isPending}
|
|
47
|
+
>
|
|
48
|
+
<SelectTrigger aria-label={t('productAnalyticsAudienceTitle')} className='w-40 sm:w-48'>
|
|
49
|
+
<SelectValue />
|
|
50
|
+
</SelectTrigger>
|
|
51
|
+
<SelectContent>
|
|
52
|
+
<SelectItem value='external'>{t('productAnalyticsAudienceExternal')}</SelectItem>
|
|
53
|
+
<SelectItem value='internal'>{t('productAnalyticsAudienceInternal')}</SelectItem>
|
|
54
|
+
<SelectItem value='qa'>{t('productAnalyticsAudienceQa')}</SelectItem>
|
|
55
|
+
</SelectContent>
|
|
56
|
+
</Select>
|
|
57
|
+
)}
|
|
58
|
+
/>
|
|
59
|
+
</SettingsGroup>
|
|
60
|
+
</SettingsSection>
|
|
61
|
+
|
|
62
|
+
<SettingsSection title={t('productAnalyticsDataTitle')}>
|
|
63
|
+
<SettingsGroup>
|
|
64
|
+
<SettingRow
|
|
65
|
+
layout='stacked'
|
|
66
|
+
title={t('productAnalyticsCollectedTitle')}
|
|
67
|
+
description={t('productAnalyticsCollectedDescription')}
|
|
68
|
+
/>
|
|
69
|
+
<SettingRow
|
|
70
|
+
layout='stacked'
|
|
71
|
+
title={t('productAnalyticsExcludedTitle')}
|
|
72
|
+
description={t('productAnalyticsExcludedDescription')}
|
|
73
|
+
/>
|
|
74
|
+
<SettingRow
|
|
75
|
+
layout='stacked'
|
|
76
|
+
title={t('productAnalyticsAnonymousTitle')}
|
|
77
|
+
description={t('productAnalyticsAnonymousDescription')}
|
|
78
|
+
/>
|
|
79
|
+
</SettingsGroup>
|
|
80
|
+
</SettingsSection>
|
|
81
|
+
</SettingsPage>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -537,6 +537,32 @@ describe("DocBrowser panel app navigation", () => {
|
|
|
537
537
|
preferences: { mainSidebar: true },
|
|
538
538
|
});
|
|
539
539
|
});
|
|
540
|
+
|
|
541
|
+
it("keeps placement actions when a restored shortcut only has a stable current URL", async () => {
|
|
542
|
+
const user = userEvent.setup();
|
|
543
|
+
const panelAppTab: DocBrowserTab = {
|
|
544
|
+
id: "piano",
|
|
545
|
+
kind: "panel-app",
|
|
546
|
+
title: "Piano",
|
|
547
|
+
currentUrl: "/api/panel-apps/piano/content",
|
|
548
|
+
resourceUri: "nextclaw://panel-app",
|
|
549
|
+
history: ["/api/panel-apps/piano/content"],
|
|
550
|
+
historyIndex: 0,
|
|
551
|
+
navVersion: 0,
|
|
552
|
+
};
|
|
553
|
+
docBrowserState.tabs = [panelAppTab];
|
|
554
|
+
docBrowserState.activeTabId = panelAppTab.id;
|
|
555
|
+
docBrowserState.currentTab = panelAppTab;
|
|
556
|
+
|
|
557
|
+
render(<DocBrowser customTabRenderers={PANEL_APPS_DOC_BROWSER_RENDERERS} />);
|
|
558
|
+
|
|
559
|
+
await user.click(screen.getByRole("button", { name: "More panel app actions" }));
|
|
560
|
+
await user.click(screen.getByRole("button", { name: "Add to main sidebar" }));
|
|
561
|
+
expect(panelAppHooks.mutate).toHaveBeenCalledWith({
|
|
562
|
+
id: "piano",
|
|
563
|
+
preferences: { mainSidebar: true },
|
|
564
|
+
});
|
|
565
|
+
});
|
|
540
566
|
});
|
|
541
567
|
|
|
542
568
|
describe("DocBrowser scroll restoration", () => {
|