@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{api-Cyxp93Fn.js → api-BH6Oslx4.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-DsMl931U.js → appearance-settings-page-zhgfcGgW.js} +1 -1
  4. package/dist/assets/{book-open-Cl5CzL0g.js → book-open-DGTS6oEO.js} +1 -1
  5. package/dist/assets/{channels-list-page-D_B7JGoZ.js → channels-list-page-3D5lhZoT.js} +2 -2
  6. package/dist/assets/chat-page-BXu3hnOK.js +1 -0
  7. package/dist/assets/{config-split-page-C-YVl2mf.js → config-split-page-B55XD_ZY.js} +1 -1
  8. package/dist/assets/{confirm-dialog-Bz3ieDQ5.js → confirm-dialog-BCCreI_f.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DO051-QG.js → desktop-update-config-Blol-Umu.js} +1 -1
  10. package/dist/assets/{dist-CDPl8mm7.js → dist-BYSUQaO2.js} +1 -1
  11. package/dist/assets/{dist-HFDWY6-K.js → dist-IRWcJpfm.js} +1 -1
  12. package/dist/assets/doc-browser-BExPq1Hk.js +1 -0
  13. package/dist/assets/{doc-browser-D6H_nPzO.js → doc-browser-D8rNrWHk.js} +1 -1
  14. package/dist/assets/{doc-browser-context-BKwz_j44.js → doc-browser-context-DjTbIbQc.js} +1 -1
  15. package/dist/assets/{form-actions-BUZOCfPR.js → form-actions-C8hazgNP.js} +1 -1
  16. package/dist/assets/index-Btj8yN4T.css +1 -0
  17. package/dist/assets/index-CHcGqqBe.js +109 -0
  18. package/dist/assets/loader-circle-C75DUmmm.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-CIMRYgOY.js +9 -0
  20. package/dist/assets/mcp-marketplace-page-D2NlhG1k.js +1 -0
  21. package/dist/assets/model-config-page-ChR-ENjp.js +1 -0
  22. package/dist/assets/{navigation-link-CDMYpkVv.js → navigation-link-kGAcL2N2.js} +1 -1
  23. package/dist/assets/plus-TzSIMKWW.js +1 -0
  24. package/dist/assets/privacy-settings-page-v9LTFvJe.js +1 -0
  25. package/dist/assets/{provider-scoped-model-input-C2iUtZi7.js → provider-scoped-model-input-DiSpKSk0.js} +1 -1
  26. package/dist/assets/providers-config-page-BvXa03cp.js +1 -0
  27. package/dist/assets/{react-B7Paq2b3.js → react-Dwvp5yeX.js} +1 -1
  28. package/dist/assets/{refresh-cw-XrnNVwuQ.js → refresh-cw-C_oj_h_s.js} +1 -1
  29. package/dist/assets/remote-BgYduCQR.js +1 -0
  30. package/dist/assets/rotate-cw-BT8HxlcB.js +1 -0
  31. package/dist/assets/{runtime-config-page-BVO9B8Ns.js → runtime-config-page-BuYof2Ty.js} +1 -1
  32. package/dist/assets/{save-_nSNAYQu.js → save-BZE6H02N.js} +1 -1
  33. package/dist/assets/{search-0OTHFRMx.js → search-C7Tj40kC.js} +1 -1
  34. package/dist/assets/{search-config-page-BwyeUjKt.js → search-config-page-CQTJOsOf.js} +1 -1
  35. package/dist/assets/{secrets-config-page-2O-TzroJ.js → secrets-config-page-B__VLAEQ.js} +2 -2
  36. package/dist/assets/security-config-CtJqaPVL.js +1 -0
  37. package/dist/assets/{select-BZHvnwQB.js → select-fZ5tifar.js} +1 -1
  38. package/dist/assets/{settings-2-CM_PvDNk.js → settings-2-BHGgNlOH.js} +1 -1
  39. package/dist/assets/skeleton-CJLc1CWn.js +1 -0
  40. package/dist/assets/{tag-chip-Ce53PMTL.js → tag-chip-CwYZcRoe.js} +1 -1
  41. package/dist/assets/x-BMl7cucN.js +1 -0
  42. package/dist/index.html +20 -20
  43. package/package.json +3 -3
  44. package/src/app/configs/app-navigation.config.ts +6 -0
  45. package/src/app/index.tsx +8 -0
  46. package/src/features/panel-apps/components/__tests__/panel-app-list-item.test.tsx +24 -12
  47. package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +51 -0
  48. package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +59 -5
  49. package/src/features/panel-apps/components/panel-app-list-item.tsx +5 -18
  50. package/src/features/panel-apps/components/panel-app-main-sidebar-menu-item.tsx +37 -0
  51. package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +71 -20
  52. package/src/features/panel-apps/components/panel-app-toolbar.tsx +18 -38
  53. package/src/features/panel-apps/components/panel-apps-list.tsx +0 -9
  54. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -2
  55. package/src/features/right-panel-resources/index.ts +1 -0
  56. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +12 -0
  57. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +8 -0
  58. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +33 -0
  59. package/src/features/settings/pages/privacy-settings-page.tsx +83 -0
  60. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +26 -0
  61. package/src/shared/hooks/use-config.ts +21 -1
  62. package/src/shared/lib/api/types.ts +13 -0
  63. package/src/shared/lib/api/utils/config.utils.ts +8 -0
  64. package/src/shared/lib/i18n/locales/en-US/core.json +19 -0
  65. package/src/shared/lib/i18n/locales/zh-CN/core.json +19 -0
  66. package/dist/assets/chat-page-D3ZeULTv.js +0 -1
  67. package/dist/assets/doc-browser-CWhs0JQN.js +0 -1
  68. package/dist/assets/index-DR3GLGme.css +0 -1
  69. package/dist/assets/index-j6DBnvlr.js +0 -109
  70. package/dist/assets/loader-circle-s65a4ZzL.js +0 -1
  71. package/dist/assets/mcp-marketplace-page-BJvzGt7h.js +0 -9
  72. package/dist/assets/mcp-marketplace-page-EpFHeJzw.js +0 -1
  73. package/dist/assets/model-config-page-CBoh0DVh.js +0 -1
  74. package/dist/assets/plus-XDIkqpkt.js +0 -1
  75. package/dist/assets/providers-config-page-DX1pISTo.js +0 -1
  76. package/dist/assets/remote-D_7_OGoj.js +0 -1
  77. package/dist/assets/rotate-cw-BhIWpLaU.js +0 -1
  78. package/dist/assets/security-config-BgIIrg1s.js +0 -1
  79. package/dist/assets/skeleton-Bv4MMbH7.js +0 -1
  80. 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
- mainSidebar={false}
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(onToggleMainSidebar).toHaveBeenCalledTimes(1);
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, PanelLeft, PanelLeftDashed, Star, Trash2, type LucideIcon } from 'lucide-react';
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
- <PanelAppMenuItem
91
- disabled={mainSidebarPending}
92
- icon={entry.mainSidebar ? PanelLeftDashed : PanelLeft}
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
- <li
52
+ <PanelAppMainSidebarNavItem
49
53
  key={entry.appId}
50
- className={isCollapsed ? "flex justify-center" : undefined}
51
- >
52
- <SidebarNavLinkItem
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, PanelLeft, PanelLeftDashed, RefreshCw } from 'lucide-react';
3
- import { usePanelApps, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
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
- mainSidebar?: boolean;
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
- mainSidebar = false,
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
- {onToggleMainSidebar ? (
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
- <button
47
- type="button"
48
- disabled={mainSidebarPending}
49
- aria-pressed={mainSidebar}
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 updatePreferences = useUpdatePanelAppPreferences();
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
- mainSidebar={entry?.mainSidebar}
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,
@@ -11,6 +11,7 @@ export {
11
11
  createPanelAppResourceUri,
12
12
  createPanelAppRightPanelResourceTarget,
13
13
  readPanelAppIdFromResourceUri,
14
+ readPanelAppIdFromTab,
14
15
  RIGHT_PANEL_APPS_TAB_KIND,
15
16
  RIGHT_PANEL_APPS_URL,
16
17
  RIGHT_PANEL_HOME_TAB_KIND,
@@ -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", () => {