@nextclaw/ui 0.18.3 → 0.19.0

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 (125) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/assets/api-Cyxp93Fn.js +13 -0
  3. package/dist/assets/appearance-settings-page-DsMl931U.js +1 -0
  4. package/dist/assets/{book-open-BOJLGVsx.js → book-open-Cl5CzL0g.js} +1 -1
  5. package/dist/assets/{channels-list-page-BPy2mXoD.js → channels-list-page-D_B7JGoZ.js} +2 -2
  6. package/dist/assets/chat-page-D3ZeULTv.js +1 -0
  7. package/dist/assets/config-split-page-C-YVl2mf.js +1 -0
  8. package/dist/assets/{confirm-dialog-Bsg9A62_.js → confirm-dialog-Bz3ieDQ5.js} +2 -2
  9. package/dist/assets/desktop-update-config-DO051-QG.js +1 -0
  10. package/dist/assets/{dist-IBUY9_9h.js → dist-CDPl8mm7.js} +1 -1
  11. package/dist/assets/{dist-DyUY9uQ0.js → dist-HFDWY6-K.js} +1 -1
  12. package/dist/assets/doc-browser-CWhs0JQN.js +1 -0
  13. package/dist/assets/doc-browser-D6H_nPzO.js +1 -0
  14. package/dist/assets/{doc-browser-context-BM6Af8gN.js → doc-browser-context-BKwz_j44.js} +1 -1
  15. package/dist/assets/form-actions-BUZOCfPR.js +1 -0
  16. package/dist/assets/index-DR3GLGme.css +1 -0
  17. package/dist/assets/index-j6DBnvlr.js +109 -0
  18. package/dist/assets/loader-circle-s65a4ZzL.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-BJvzGt7h.js +9 -0
  20. package/dist/assets/mcp-marketplace-page-EpFHeJzw.js +1 -0
  21. package/dist/assets/model-config-page-CBoh0DVh.js +1 -0
  22. package/dist/assets/{navigation-link-DtPZZ_Q_.js → navigation-link-CDMYpkVv.js} +2 -2
  23. package/dist/assets/plus-XDIkqpkt.js +1 -0
  24. package/dist/assets/{provider-scoped-model-input-RWZz36z7.js → provider-scoped-model-input-C2iUtZi7.js} +1 -1
  25. package/dist/assets/providers-config-page-DX1pISTo.js +1 -0
  26. package/dist/assets/react-B7Paq2b3.js +8 -0
  27. package/dist/assets/{refresh-cw-BQHORcAs.js → refresh-cw-XrnNVwuQ.js} +1 -1
  28. package/dist/assets/remote-D_7_OGoj.js +1 -0
  29. package/dist/assets/rotate-cw-BhIWpLaU.js +1 -0
  30. package/dist/assets/runtime-config-page-BVO9B8Ns.js +1 -0
  31. package/dist/assets/{save-BgiS2u-T.js → save-_nSNAYQu.js} +1 -1
  32. package/dist/assets/{search-B6mO7mmo.js → search-0OTHFRMx.js} +1 -1
  33. package/dist/assets/{search-config-page-QS-kqNDZ.js → search-config-page-BwyeUjKt.js} +1 -1
  34. package/dist/assets/secrets-config-page-2O-TzroJ.js +3 -0
  35. package/dist/assets/security-config-BgIIrg1s.js +1 -0
  36. package/dist/assets/{select-DToos7Rq.js → select-BZHvnwQB.js} +2 -2
  37. package/dist/assets/{settings-2-ZAWXrq81.js → settings-2-CM_PvDNk.js} +1 -1
  38. package/dist/assets/skeleton-Bv4MMbH7.js +1 -0
  39. package/dist/assets/{tag-chip-urB3N-kk.js → tag-chip-Ce53PMTL.js} +1 -1
  40. package/dist/assets/x-CCjWN5ys.js +1 -0
  41. package/dist/index.html +20 -20
  42. package/package.json +6 -6
  43. package/src/app/components/layout/sidebar-items.tsx +49 -15
  44. package/src/app/configs/__tests__/app-navigation.config.test.ts +21 -0
  45. package/src/app/configs/app-navigation.config.ts +10 -1
  46. package/src/app/index.tsx +4 -0
  47. package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -0
  48. package/src/features/chat/components/layout/chat-page-shell.tsx +4 -1
  49. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +26 -22
  50. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +2 -0
  51. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  52. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +1 -0
  53. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +1 -3
  54. package/src/features/chat/features/message/components/chat-message-list.container.tsx +15 -1
  55. package/src/features/chat/features/message/components/chat-tool-call-stress-harness.tsx +178 -0
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +21 -0
  57. package/src/features/chat/features/message/utils/__tests__/chat-tool-call-stress-fixture.utils.test.ts +56 -0
  58. package/src/features/chat/features/message/utils/chat-message-core.utils.ts +1 -1
  59. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +3 -10
  60. package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +29 -1
  61. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +3 -0
  62. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +3 -28
  63. package/src/features/chat/features/message/utils/chat-tool-call-stress-fixture.utils.ts +122 -0
  64. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-conversation.test.tsx +69 -0
  65. package/src/features/chat/features/ncp/hooks/use-ncp-session-conversation.ts +29 -0
  66. package/src/features/chat/features/ncp/hooks/use-ncp-session-message-history.ts +106 -1
  67. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +2 -12
  68. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +4 -2
  69. package/src/features/panel-apps/components/__tests__/panel-app-list-item.test.tsx +44 -1
  70. package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +82 -0
  71. package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +13 -6
  72. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +7 -1
  73. package/src/features/panel-apps/components/panel-app-icon.tsx +48 -0
  74. package/src/features/panel-apps/components/panel-app-list-item.tsx +19 -28
  75. package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +72 -0
  76. package/src/features/panel-apps/components/panel-app-toolbar.tsx +81 -20
  77. package/src/features/panel-apps/components/panel-apps-list.tsx +13 -26
  78. package/src/features/panel-apps/hooks/__tests__/use-panel-apps.test.tsx +105 -0
  79. package/src/features/panel-apps/hooks/use-panel-app-client-grant.ts +50 -0
  80. package/src/features/panel-apps/hooks/use-panel-app-main-runtime.ts +66 -0
  81. package/src/features/panel-apps/hooks/use-panel-apps.ts +102 -13
  82. package/src/features/panel-apps/index.ts +2 -0
  83. package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +1 -14
  84. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +9 -5
  85. package/src/features/panel-apps/pages/__tests__/panel-app-main-page.test.tsx +183 -0
  86. package/src/features/panel-apps/pages/panel-app-main-page.tsx +125 -0
  87. package/src/features/panel-apps/utils/__tests__/panel-app-view.utils.test.ts +2 -1
  88. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +5 -4
  89. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +12 -25
  90. package/src/features/right-panel-resources/index.ts +1 -0
  91. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +22 -0
  92. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +31 -2
  93. package/src/main.tsx +24 -11
  94. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +76 -0
  95. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  96. package/src/shared/lib/api/types.ts +3 -0
  97. package/src/shared/lib/api/utils/ncp-session.utils.ts +24 -1
  98. package/src/shared/lib/i18n/locales/en-US/chat.json +4 -0
  99. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +10 -0
  100. package/src/shared/lib/i18n/locales/zh-CN/chat.json +4 -0
  101. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +10 -0
  102. package/dist/assets/api-BSsQ0I4l.js +0 -13
  103. package/dist/assets/appearance-settings-page-2ilQwMl0.js +0 -1
  104. package/dist/assets/chat-page-DDR-Q4Kf.js +0 -1
  105. package/dist/assets/config-split-page-aUTKtWJu.js +0 -1
  106. package/dist/assets/desktop-update-config-Ds4pN7kz.js +0 -1
  107. package/dist/assets/doc-browser-D0arfzHY.js +0 -1
  108. package/dist/assets/doc-browser-D73yN8ts.js +0 -1
  109. package/dist/assets/form-actions-Cpd7glh8.js +0 -1
  110. package/dist/assets/index-Bsoscjk7.css +0 -1
  111. package/dist/assets/index-C2N1bYyb.js +0 -109
  112. package/dist/assets/loader-circle-C1fL3kBZ.js +0 -1
  113. package/dist/assets/mcp-marketplace-page-D8V0GU4Y.js +0 -9
  114. package/dist/assets/mcp-marketplace-page-q6T-vKoH.js +0 -1
  115. package/dist/assets/model-config-page-B7QR33ED.js +0 -1
  116. package/dist/assets/plus-B9V7Z_Eu.js +0 -1
  117. package/dist/assets/providers-config-page-B5yakiZe.js +0 -1
  118. package/dist/assets/react-D-jOo51s.js +0 -8
  119. package/dist/assets/remote-BfRYDhkz.js +0 -1
  120. package/dist/assets/rotate-cw-DuCF-3PX.js +0 -1
  121. package/dist/assets/runtime-config-page-Big45YWb.js +0 -1
  122. package/dist/assets/secrets-config-page-DE3kmDHk.js +0 -3
  123. package/dist/assets/security-config-Ds_2SXBh.js +0 -1
  124. package/dist/assets/skeleton-Bs2eO2CA.js +0 -1
  125. package/dist/assets/x-BkjTUiww.js +0 -1
@@ -0,0 +1,82 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { MemoryRouter } from "react-router-dom";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ getMainSidebarPanelApps,
6
+ PanelAppMainSidebarNav,
7
+ } from "@/features/panel-apps/components/panel-app-main-sidebar-nav";
8
+ import type { PanelAppEntryView } from "@/shared/lib/api";
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ entries: [] as PanelAppEntryView[],
12
+ }));
13
+
14
+ vi.mock("@/features/panel-apps/hooks/use-panel-apps", () => ({
15
+ usePanelApps: () => ({ data: { entries: mocks.entries } }),
16
+ }));
17
+
18
+ function createEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
19
+ return {
20
+ appId: "demo",
21
+ clientDeclared: false,
22
+ clientGranted: false,
23
+ contentPath: "/api/panel-apps/demo/content",
24
+ createdAt: "2026-08-19T00:00:00.000Z",
25
+ favorite: false,
26
+ fileName: "demo.panel.html",
27
+ id: "demo",
28
+ kind: "single-file",
29
+ mainSidebar: false,
30
+ openCount: 0,
31
+ sizeBytes: 10,
32
+ title: "Demo",
33
+ updatedAt: "2026-08-19T00:00:00.000Z",
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ describe("PanelAppMainSidebarNav", () => {
39
+ beforeEach(() => {
40
+ mocks.entries = [];
41
+ });
42
+
43
+ it("projects only manually added apps in persisted order", () => {
44
+ const entries = [
45
+ createEntry({ appId: "hidden", title: "Hidden" }),
46
+ createEntry({ appId: "second", mainSidebar: true, mainSidebarOrder: 1, title: "Second" }),
47
+ createEntry({ appId: "first", mainSidebar: true, mainSidebarOrder: 0, title: "First" }),
48
+ ];
49
+
50
+ expect(getMainSidebarPanelApps(entries).map((entry) => entry.appId)).toEqual([
51
+ "first",
52
+ "second",
53
+ ]);
54
+ });
55
+
56
+ it("renders stable app routes and hides unbound apps", () => {
57
+ mocks.entries = [
58
+ createEntry({ appId: "publisher.todo", mainSidebar: true, title: "Todo" }),
59
+ createEntry({ appId: "hidden", title: "Hidden" }),
60
+ ];
61
+
62
+ render(
63
+ <MemoryRouter initialEntries={["/apps/panel/publisher.todo"]}>
64
+ <PanelAppMainSidebarNav isCollapsed={false} />
65
+ </MemoryRouter>,
66
+ );
67
+
68
+ const link = screen.getByRole("link", { name: "Todo" });
69
+ expect(link.getAttribute("href")).toBe("/apps/panel/publisher.todo");
70
+ expect(link.getAttribute("aria-current")).toBe("page");
71
+ expect(screen.queryByText("Hidden")).toBeNull();
72
+ });
73
+
74
+ it("renders no section when no app was manually added", () => {
75
+ const { container } = render(
76
+ <MemoryRouter>
77
+ <PanelAppMainSidebarNav isCollapsed={false} />
78
+ </MemoryRouter>,
79
+ );
80
+ expect(container.firstChild).toBeNull();
81
+ });
82
+ });
@@ -1,24 +1,31 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
- import { PanelAppToolbar } from '../panel-app-toolbar';
4
+ import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
5
5
 
6
6
  describe('PanelAppToolbar', () => {
7
- it('shows the current panel app title in the detail header', async () => {
7
+ it('keeps placement in the overflow menu without adding a second back action', async () => {
8
8
  const user = userEvent.setup();
9
- const onOpenApps = vi.fn();
9
+ const onToggleMainSidebar = vi.fn();
10
10
  render(
11
11
  <PanelAppToolbar
12
12
  appTitle="墨爪助手"
13
- onOpenApps={onOpenApps}
13
+ mainSidebar={false}
14
14
  onRefresh={vi.fn()}
15
+ onToggleMainSidebar={onToggleMainSidebar}
15
16
  />,
16
17
  );
17
18
 
18
19
  expect(screen.getByText('墨爪助手')).toBeTruthy();
20
+ expect(screen.queryByRole('button', { name: 'Apps' })).toBeNull();
19
21
 
20
- await user.click(screen.getByRole('button', { name: 'Apps' }));
22
+ expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
23
+ await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
24
+ const addButton = screen.getByRole('button', { name: 'Add to main sidebar' });
25
+ expect(addButton.getAttribute('aria-pressed')).toBe('false');
26
+ await user.click(addButton);
21
27
 
22
- expect(onOpenApps).toHaveBeenCalledTimes(1);
28
+ expect(onToggleMainSidebar).toHaveBeenCalledTimes(1);
29
+ expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
23
30
  });
24
31
  });
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
22
22
  }>,
23
23
  grantPanelAppClient: vi.fn(),
24
24
  navigate: vi.fn(),
25
+ recordOpened: vi.fn(),
25
26
  refetchPanelApps: vi.fn(),
26
27
  requestAuthorization: vi.fn(async () => true),
27
28
  requestDraft: vi.fn(),
@@ -62,7 +63,7 @@ vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
62
63
  refetch: mocks.refetchPanelApps,
63
64
  }),
64
65
  useRecordPanelAppOpened: () => ({
65
- mutateAsync: vi.fn(async (id: string) => mocks.entries.find((entry) => entry.id === id)),
66
+ mutate: mocks.recordOpened,
66
67
  }),
67
68
  useUpdatePanelAppPreferences: () => ({
68
69
  isPending: false,
@@ -96,6 +97,7 @@ describe('PanelAppsList', () => {
96
97
  mocks.grantPanelAppClient.mockReset();
97
98
  mocks.navigate.mockReset();
98
99
  mocks.refetchPanelApps.mockReset();
100
+ mocks.recordOpened.mockReset();
99
101
  mocks.requestAuthorization.mockReset();
100
102
  mocks.requestAuthorization.mockResolvedValue(true);
101
103
  mocks.requestDraft.mockReset();
@@ -163,5 +165,9 @@ describe('PanelAppsList', () => {
163
165
  }));
164
166
  expect(mocks.grantPanelAppClient).toHaveBeenCalledWith('demo-app');
165
167
  expect(onOpenPanelApp).toHaveBeenCalledWith(entry);
168
+ expect(mocks.recordOpened).toHaveBeenCalledWith(entry.id);
169
+ expect(onOpenPanelApp.mock.invocationCallOrder[0]).toBeLessThan(
170
+ mocks.recordOpened.mock.invocationCallOrder[0]!,
171
+ );
166
172
  });
167
173
  });
@@ -0,0 +1,48 @@
1
+ import { AppWindow } from "lucide-react";
2
+ import { cn } from "@/shared/lib/utils";
3
+
4
+ export function PanelAppIcon({
5
+ className,
6
+ icon,
7
+ imageClassName,
8
+ title,
9
+ }: {
10
+ className?: string;
11
+ icon?: string;
12
+ imageClassName?: string;
13
+ title: string;
14
+ }) {
15
+ const normalizedIcon = icon?.trim();
16
+ if (!normalizedIcon) {
17
+ return <AppWindow className={cn("h-4 w-4", className)} />;
18
+ }
19
+ if (isPanelAppImageIcon(normalizedIcon)) {
20
+ return (
21
+ <img
22
+ src={normalizedIcon}
23
+ alt=""
24
+ aria-hidden="true"
25
+ className={cn("h-5 w-5 rounded-sm object-contain", imageClassName, className)}
26
+ title={title}
27
+ />
28
+ );
29
+ }
30
+ return (
31
+ <span
32
+ aria-hidden="true"
33
+ className={cn("max-w-6 truncate text-center leading-none", className)}
34
+ title={title}
35
+ >
36
+ {normalizedIcon}
37
+ </span>
38
+ );
39
+ }
40
+
41
+ export function isPanelAppImageIcon(icon: string): boolean {
42
+ return (
43
+ icon.startsWith("data:image/") ||
44
+ icon.startsWith("http://") ||
45
+ icon.startsWith("https://") ||
46
+ icon.startsWith("/")
47
+ );
48
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, type MouseEvent } from 'react';
2
- import { AppWindow, MoreVertical, Star, Trash2, type LucideIcon } from 'lucide-react';
2
+ import { MoreVertical, PanelLeft, PanelLeftDashed, Star, Trash2, type LucideIcon } from 'lucide-react';
3
3
  import type { PanelAppEntryView } from '@/shared/lib/api';
4
+ import { PanelAppIcon } from '@/features/panel-apps/components/panel-app-icon';
4
5
  import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
5
6
  import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
6
7
  import { getLanguage, getLocale, t } from '@/shared/lib/i18n';
@@ -10,16 +11,20 @@ export function PanelAppListItem({
10
11
  deletePending,
11
12
  entry,
12
13
  favoritePending,
14
+ mainSidebarPending,
13
15
  onDelete,
14
16
  onOpen,
15
17
  onToggleFavorite,
18
+ onToggleMainSidebar,
16
19
  }: {
17
20
  deletePending: boolean;
18
21
  entry: PanelAppEntryView;
19
22
  favoritePending: boolean;
23
+ mainSidebarPending: boolean;
20
24
  onDelete: () => void;
21
25
  onOpen: () => void;
22
26
  onToggleFavorite: () => void;
27
+ onToggleMainSidebar: () => void;
23
28
  }) {
24
29
  const [isMenuOpen, setIsMenuOpen] = useState(false);
25
30
  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -32,10 +37,17 @@ export function PanelAppListItem({
32
37
  event.stopPropagation();
33
38
  onToggleFavorite();
34
39
  };
40
+ const handleMainSidebar = () => {
41
+ setIsMenuOpen(false);
42
+ onToggleMainSidebar();
43
+ };
35
44
  const openDeleteDialog = () => {
36
45
  setIsMenuOpen(false);
37
46
  setIsDeleteDialogOpen(true);
38
47
  };
48
+ const mainSidebarLabel = entry.mainSidebar
49
+ ? t('panelAppsRemoveFromMainSidebar')
50
+ : t('panelAppsAddToMainSidebar');
39
51
 
40
52
  return (
41
53
  <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">
@@ -75,6 +87,12 @@ export function PanelAppListItem({
75
87
  </button>
76
88
  </PopoverTrigger>
77
89
  <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}
95
+ />
78
96
  <PanelAppMenuItem
79
97
  destructive
80
98
  disabled={deletePending}
@@ -144,30 +162,3 @@ function formatPanelAppTime(value: string): string {
144
162
  }
145
163
  return new Intl.DateTimeFormat(locale, { year: '2-digit', month: 'numeric', day: 'numeric' }).format(date);
146
164
  }
147
-
148
- function PanelAppIcon({ icon, title }: { icon?: string; title: string }) {
149
- if (!icon) {
150
- return <AppWindow className="h-4 w-4" />;
151
- }
152
- if (isImageIcon(icon)) {
153
- return (
154
- <img
155
- src={icon}
156
- alt=""
157
- aria-hidden="true"
158
- className="h-5 w-5 rounded-sm object-contain"
159
- title={title}
160
- />
161
- );
162
- }
163
- return <span className="max-w-6 truncate text-center leading-none">{icon}</span>;
164
- }
165
-
166
- function isImageIcon(icon: string): boolean {
167
- return (
168
- icon.startsWith('data:image/') ||
169
- icon.startsWith('http://') ||
170
- icon.startsWith('https://') ||
171
- icon.startsWith('/')
172
- );
173
- }
@@ -0,0 +1,72 @@
1
+ import { useMemo } from "react";
2
+ import { SidebarNavLinkItem } from "@/app/components/layout/sidebar-items";
3
+ import { SIDEBAR_RAIL_STACK_CLASS } from "@/app/components/layout/sidebar-rail.styles";
4
+ import { PanelAppIcon } from "@/features/panel-apps/components/panel-app-icon";
5
+ import { usePanelApps } from "@/features/panel-apps/hooks/use-panel-apps";
6
+ import type { PanelAppEntryView } from "@/shared/lib/api";
7
+ import { cn } from "@/shared/lib/utils";
8
+
9
+ const MAIN_SIDEBAR_FALLBACK_ORDER = Number.MAX_SAFE_INTEGER;
10
+
11
+ export function getMainSidebarPanelApps(
12
+ entries: readonly PanelAppEntryView[],
13
+ ): PanelAppEntryView[] {
14
+ return entries
15
+ .filter((entry) => entry.mainSidebar)
16
+ .sort((left, right) =>
17
+ (left.mainSidebarOrder ?? MAIN_SIDEBAR_FALLBACK_ORDER) -
18
+ (right.mainSidebarOrder ?? MAIN_SIDEBAR_FALLBACK_ORDER) ||
19
+ left.title.localeCompare(right.title)
20
+ );
21
+ }
22
+
23
+ export function PanelAppMainSidebarNav({
24
+ isCollapsed,
25
+ }: {
26
+ isCollapsed: boolean;
27
+ }) {
28
+ const panelApps = usePanelApps();
29
+ const entries = useMemo(
30
+ () => getMainSidebarPanelApps(panelApps.data?.entries ?? []),
31
+ [panelApps.data?.entries],
32
+ );
33
+
34
+ if (entries.length === 0) {
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <div
40
+ className={cn(
41
+ "mt-1 border-t border-border/50 pt-1",
42
+ isCollapsed ? "px-0" : "px-3",
43
+ )}
44
+ data-testid="panel-app-main-sidebar-nav"
45
+ >
46
+ <ul className={isCollapsed ? SIDEBAR_RAIL_STACK_CLASS : "space-y-0.5"}>
47
+ {entries.map((entry) => (
48
+ <li
49
+ 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>
68
+ ))}
69
+ </ul>
70
+ </div>
71
+ );
72
+ }
@@ -1,43 +1,104 @@
1
- import { ArrowLeft, RefreshCw } from 'lucide-react';
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';
4
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
5
+ import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
2
6
  import { t } from '@/shared/lib/i18n';
3
7
 
4
8
  type PanelAppToolbarProps = {
5
9
  appTitle: string;
6
- onOpenApps: () => void;
10
+ mainSidebar?: boolean;
11
+ mainSidebarPending?: boolean;
7
12
  onRefresh: () => void;
13
+ onToggleMainSidebar?: () => void;
8
14
  };
9
15
 
10
16
  export function PanelAppToolbar({
11
17
  appTitle,
12
- onOpenApps,
18
+ mainSidebar = false,
19
+ mainSidebarPending = false,
13
20
  onRefresh,
21
+ onToggleMainSidebar,
14
22
  }: PanelAppToolbarProps) {
23
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
24
+ const mainSidebarLabel = mainSidebar
25
+ ? t('panelAppsRemoveFromMainSidebar')
26
+ : t('panelAppsAddToMainSidebar');
27
+
15
28
  return (
16
29
  <div className="flex items-center justify-between gap-2 border-b border-border/70 bg-card px-3.5 py-2 shrink-0">
17
- <button
18
- type="button"
19
- onClick={onOpenApps}
20
- className="inline-flex shrink-0 items-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground"
21
- title={t('appsTitle')}
22
- aria-label={t('appsTitle')}
23
- >
24
- <ArrowLeft className="h-3.5 w-3.5" />
25
- </button>
26
30
  <span
27
31
  className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground"
28
32
  title={appTitle}
29
33
  >
30
34
  {appTitle}
31
35
  </span>
32
- <button
33
- type="button"
36
+ {onToggleMainSidebar ? (
37
+ <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
38
+ <PopoverTrigger asChild>
39
+ <IconActionButton
40
+ icon={<MoreVertical className="h-3.5 w-3.5" />}
41
+ label={t('panelAppsMoreActions')}
42
+ size="sm"
43
+ />
44
+ </PopoverTrigger>
45
+ <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>
61
+ </PopoverContent>
62
+ </Popover>
63
+ ) : null}
64
+ <IconActionButton
65
+ icon={<RefreshCw className="h-3.5 w-3.5" />}
66
+ label={t('panelAppsRefreshCurrent')}
34
67
  onClick={onRefresh}
35
- className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground"
36
- title={t('panelAppsRefreshCurrent')}
37
- aria-label={t('panelAppsRefreshCurrent')}
38
- >
39
- <RefreshCw className="h-3.5 w-3.5" />
40
- </button>
68
+ size="sm"
69
+ />
41
70
  </div>
42
71
  );
43
72
  }
73
+
74
+ export function PanelAppDocBrowserToolbar({
75
+ appId,
76
+ appTitle,
77
+ onRefresh,
78
+ }: {
79
+ appId: string | null;
80
+ appTitle: string;
81
+ onRefresh: () => void;
82
+ }) {
83
+ const panelApps = usePanelApps();
84
+ const updatePreferences = useUpdatePanelAppPreferences();
85
+ const entry = useMemo(
86
+ () => panelApps.data?.entries.find((candidate) => candidate.appId === appId),
87
+ [appId, panelApps.data?.entries],
88
+ );
89
+
90
+ return (
91
+ <PanelAppToolbar
92
+ appTitle={entry?.title ?? appTitle}
93
+ mainSidebar={entry?.mainSidebar}
94
+ mainSidebarPending={updatePreferences.isPending}
95
+ onRefresh={onRefresh}
96
+ onToggleMainSidebar={entry
97
+ ? () => updatePreferences.mutate({
98
+ id: entry.id,
99
+ preferences: { mainSidebar: !entry.mainSidebar },
100
+ })
101
+ : undefined}
102
+ />
103
+ );
104
+ }
@@ -3,7 +3,8 @@ import { AppWindow, FileCode2, HelpCircle, MessageSquarePlus, RefreshCw } from '
3
3
  import { useNavigate } from 'react-router-dom';
4
4
  import { useAppPresenter } from '@/app/components/app-presenter-provider';
5
5
  import { PanelAppListItem } from '@/features/panel-apps/components/panel-app-list-item';
6
- import { useDeletePanelApp, useGrantPanelAppClient, usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
6
+ import { usePanelAppClientGrant } from '@/features/panel-apps/hooks/use-panel-app-client-grant';
7
+ import { useDeletePanelApp, usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
7
8
  import { getPanelAppViewEntries } from '@/features/panel-apps/utils/panel-app-view.utils';
8
9
  import type { PanelAppViewMode } from '@/features/panel-apps/utils/panel-app-view.utils';
9
10
  import type { PanelAppEntryView } from '@/shared/lib/api';
@@ -22,7 +23,7 @@ export function PanelAppsList({
22
23
  const deletePanelApp = useDeletePanelApp();
23
24
  const updatePreferences = useUpdatePanelAppPreferences();
24
25
  const recordOpened = useRecordPanelAppOpened();
25
- const grantClient = useGrantPanelAppClient();
26
+ const { ensurePanelAppClientGrant } = usePanelAppClientGrant();
26
27
  const presenter = useAppPresenter();
27
28
  const navigate = useNavigate();
28
29
  const [viewMode, setViewMode] = useState<PanelAppViewMode>('smart');
@@ -36,37 +37,21 @@ export function PanelAppsList({
36
37
  if (!(await ensurePanelAppClientGrant(entry))) {
37
38
  return;
38
39
  }
39
- try {
40
- onOpenPanelApp(await recordOpened.mutateAsync(entry.id));
41
- } catch {
42
- onOpenPanelApp(entry);
43
- }
40
+ onOpenPanelApp(entry);
41
+ recordOpened.mutate(entry.id);
44
42
  };
45
43
 
46
- const ensurePanelAppClientGrant = async (entry: PanelAppEntryView): Promise<boolean> => {
47
- if (!entry.clientDeclared || entry.clientGranted) {
48
- return true;
49
- }
50
- const allowed = await presenter.serviceActionAuthorizationManager.requestAuthorization({
51
- panelAppId: entry.appId,
52
- actions: [{
53
- actionId: 'nextclaw.client',
54
- actionTitle: t('panelAppsClientGrantTitle'),
55
- actionDescription: t('panelAppsClientGrantDescription'),
56
- risk: 'dangerous',
57
- }],
44
+ const toggleFavorite = (entry: PanelAppEntryView) => {
45
+ updatePreferences.mutate({
46
+ id: entry.id,
47
+ preferences: { favorite: !entry.favorite },
58
48
  });
59
- if (!allowed) {
60
- return false;
61
- }
62
- await grantClient.mutateAsync(entry.appId);
63
- return true;
64
49
  };
65
50
 
66
- const toggleFavorite = (entry: PanelAppEntryView) => {
51
+ const toggleMainSidebar = (entry: PanelAppEntryView) => {
67
52
  updatePreferences.mutate({
68
53
  id: entry.id,
69
- preferences: { favorite: !entry.favorite },
54
+ preferences: { mainSidebar: !entry.mainSidebar },
70
55
  });
71
56
  };
72
57
 
@@ -144,9 +129,11 @@ export function PanelAppsList({
144
129
  entry={entry}
145
130
  deletePending={deletePanelApp.isPending}
146
131
  favoritePending={updatePreferences.isPending}
132
+ mainSidebarPending={updatePreferences.isPending}
147
133
  onDelete={() => deleteEntry(entry)}
148
134
  onOpen={() => void openPanelApp(entry)}
149
135
  onToggleFavorite={() => toggleFavorite(entry)}
136
+ onToggleMainSidebar={() => toggleMainSidebar(entry)}
150
137
  />
151
138
  ))}
152
139
  </div>
@@ -0,0 +1,105 @@
1
+ import type { ReactNode } from 'react';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
6
+ import type { PanelAppEntryView, PanelAppListView } from '@/shared/lib/api';
7
+ import type * as SharedApi from '@/shared/lib/api';
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ updatePreferences: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('@/shared/lib/api', async (importOriginal) => {
14
+ const original = await importOriginal<typeof SharedApi>();
15
+ return {
16
+ ...original,
17
+ nextclawClient: {
18
+ ...original.nextclawClient,
19
+ panelApps: {
20
+ ...original.nextclawClient.panelApps,
21
+ updatePanelAppPreferences: mocks.updatePreferences,
22
+ },
23
+ },
24
+ };
25
+ });
26
+
27
+ vi.mock('sonner', () => ({ toast: { error: vi.fn() } }));
28
+
29
+ const entry: PanelAppEntryView = {
30
+ id: 'todo',
31
+ appId: 'publisher.todo',
32
+ fileName: 'todo',
33
+ kind: 'folder',
34
+ title: 'Todo',
35
+ contentPath: '/api/panel-apps/publisher.todo/content',
36
+ createdAt: '2026-08-19T00:00:00.000Z',
37
+ updatedAt: '2026-08-19T00:00:00.000Z',
38
+ sizeBytes: 1,
39
+ favorite: false,
40
+ mainSidebar: false,
41
+ clientDeclared: false,
42
+ clientGranted: false,
43
+ openCount: 0,
44
+ };
45
+
46
+ function createHarness() {
47
+ const queryClient = new QueryClient({
48
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
49
+ });
50
+ queryClient.setQueryData<PanelAppListView>(['panel-apps'], {
51
+ workspacePath: '/workspace',
52
+ panelsPath: '/workspace/panels',
53
+ entries: [entry],
54
+ });
55
+ return {
56
+ queryClient,
57
+ wrapper: ({ children }: { children: ReactNode }) => (
58
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
59
+ ),
60
+ };
61
+ }
62
+
63
+ describe('useUpdatePanelAppPreferences', () => {
64
+ beforeEach(() => {
65
+ mocks.updatePreferences.mockReset();
66
+ });
67
+
68
+ it('updates the main-sidebar cache before local persistence completes', async () => {
69
+ let resolveRequest: (value: PanelAppEntryView) => void = () => undefined;
70
+ mocks.updatePreferences.mockReturnValue(new Promise((resolve) => {
71
+ resolveRequest = resolve;
72
+ }));
73
+ const { queryClient, wrapper } = createHarness();
74
+ const { result } = renderHook(() => useUpdatePanelAppPreferences(), { wrapper });
75
+
76
+ act(() => result.current.mutate({
77
+ id: entry.id,
78
+ preferences: { mainSidebar: true },
79
+ }));
80
+
81
+ await waitFor(() => expect(
82
+ queryClient.getQueryData<PanelAppListView>(['panel-apps'])?.entries[0]?.mainSidebar,
83
+ ).toBe(true));
84
+ expect(result.current.isPending).toBe(true);
85
+
86
+ resolveRequest({ ...entry, mainSidebar: true, mainSidebarOrder: 0 });
87
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
88
+ });
89
+
90
+ it('rolls the optimistic entry back when persistence fails', async () => {
91
+ mocks.updatePreferences.mockRejectedValue(new Error('write failed'));
92
+ const { queryClient, wrapper } = createHarness();
93
+ const { result } = renderHook(() => useUpdatePanelAppPreferences(), { wrapper });
94
+
95
+ act(() => result.current.mutate({
96
+ id: entry.id,
97
+ preferences: { mainSidebar: true },
98
+ }));
99
+
100
+ await waitFor(() => expect(result.current.isError).toBe(true));
101
+ expect(
102
+ queryClient.getQueryData<PanelAppListView>(['panel-apps'])?.entries[0]?.mainSidebar,
103
+ ).toBe(false);
104
+ });
105
+ });