@nextclaw/ui 0.18.4 → 0.19.1

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 (126) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/api-Cyxp93Fn.js +13 -0
  3. package/dist/assets/appearance-settings-page-NlQIHbXh.js +1 -0
  4. package/dist/assets/{book-open-BOJLGVsx.js → book-open-Cl5CzL0g.js} +1 -1
  5. package/dist/assets/{channels-list-page-DKrUSeNz.js → channels-list-page-DhXfsjxF.js} +2 -2
  6. package/dist/assets/chat-page-C4DHLN6T.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-CpsXBUJK.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-Bfr0I0Ga.js +109 -0
  17. package/dist/assets/index-DcqrfCMy.css +1 -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-BRScCkS-.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-BFzyywsF.js → provider-scoped-model-input-CD93ZOgZ.js} +1 -1
  25. package/dist/assets/providers-config-page-CFdgth2A.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-DDN70hyU.js +1 -0
  29. package/dist/assets/rotate-cw-BhIWpLaU.js +1 -0
  30. package/dist/assets/runtime-config-page-BxJlm6qT.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-ydeDD1OQ.js → search-config-page-2VAarRcG.js} +1 -1
  34. package/dist/assets/secrets-config-page-KoFrETds.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 +5 -5
  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 +57 -2
  70. package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +133 -0
  71. package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +68 -7
  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 +7 -29
  75. package/src/features/panel-apps/components/panel-app-main-sidebar-menu-item.tsx +37 -0
  76. package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +123 -0
  77. package/src/features/panel-apps/components/panel-app-toolbar.tsx +61 -20
  78. package/src/features/panel-apps/components/panel-apps-list.tsx +5 -27
  79. package/src/features/panel-apps/hooks/__tests__/use-panel-apps.test.tsx +105 -0
  80. package/src/features/panel-apps/hooks/use-panel-app-client-grant.ts +50 -0
  81. package/src/features/panel-apps/hooks/use-panel-app-main-runtime.ts +66 -0
  82. package/src/features/panel-apps/hooks/use-panel-apps.ts +102 -13
  83. package/src/features/panel-apps/index.ts +2 -0
  84. package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +1 -14
  85. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +9 -5
  86. package/src/features/panel-apps/pages/__tests__/panel-app-main-page.test.tsx +183 -0
  87. package/src/features/panel-apps/pages/panel-app-main-page.tsx +125 -0
  88. package/src/features/panel-apps/utils/__tests__/panel-app-view.utils.test.ts +2 -1
  89. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +5 -4
  90. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +12 -25
  91. package/src/features/right-panel-resources/index.ts +2 -0
  92. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +21 -0
  93. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +37 -0
  94. package/src/main.tsx +24 -11
  95. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +102 -0
  96. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  97. package/src/shared/lib/api/types.ts +3 -0
  98. package/src/shared/lib/api/utils/ncp-session.utils.ts +24 -1
  99. package/src/shared/lib/i18n/locales/en-US/chat.json +4 -0
  100. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +10 -0
  101. package/src/shared/lib/i18n/locales/zh-CN/chat.json +4 -0
  102. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +10 -0
  103. package/dist/assets/api-BSsQ0I4l.js +0 -13
  104. package/dist/assets/appearance-settings-page-CEPxG1w0.js +0 -1
  105. package/dist/assets/chat-page-sGhIjLSr.js +0 -1
  106. package/dist/assets/config-split-page-aUTKtWJu.js +0 -1
  107. package/dist/assets/desktop-update-config-VgvIATTT.js +0 -1
  108. package/dist/assets/doc-browser-D0arfzHY.js +0 -1
  109. package/dist/assets/doc-browser-D73yN8ts.js +0 -1
  110. package/dist/assets/form-actions-Cpd7glh8.js +0 -1
  111. package/dist/assets/index-Bsoscjk7.css +0 -1
  112. package/dist/assets/index-CtPLj_hk.js +0 -109
  113. package/dist/assets/loader-circle-C1fL3kBZ.js +0 -1
  114. package/dist/assets/mcp-marketplace-page-D8V0GU4Y.js +0 -9
  115. package/dist/assets/mcp-marketplace-page-q6T-vKoH.js +0 -1
  116. package/dist/assets/model-config-page-DQ-iQFSO.js +0 -1
  117. package/dist/assets/plus-B9V7Z_Eu.js +0 -1
  118. package/dist/assets/providers-config-page-DUYwVuYe.js +0 -1
  119. package/dist/assets/react-D-jOo51s.js +0 -8
  120. package/dist/assets/remote-CNFQ6bFY.js +0 -1
  121. package/dist/assets/rotate-cw-DuCF-3PX.js +0 -1
  122. package/dist/assets/runtime-config-page-CaWoWoQk.js +0 -1
  123. package/dist/assets/secrets-config-page-BZYqw8BF.js +0 -3
  124. package/dist/assets/security-config-Ds_2SXBh.js +0 -1
  125. package/dist/assets/skeleton-Bs2eO2CA.js +0 -1
  126. package/dist/assets/x-BkjTUiww.js +0 -1
@@ -0,0 +1,133 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { MemoryRouter } from "react-router-dom";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ getMainSidebarPanelApps,
7
+ PanelAppMainSidebarNav,
8
+ } from "@/features/panel-apps/components/panel-app-main-sidebar-nav";
9
+ import type { PanelAppEntryView } from "@/shared/lib/api";
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ entries: [] as PanelAppEntryView[],
13
+ mutate: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("@/features/panel-apps/hooks/use-panel-apps", () => ({
17
+ usePanelApps: () => ({ data: { entries: mocks.entries } }),
18
+ useUpdatePanelAppPreferences: () => ({
19
+ isPending: false,
20
+ mutate: mocks.mutate,
21
+ }),
22
+ }));
23
+
24
+ function createEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
25
+ return {
26
+ appId: "demo",
27
+ clientDeclared: false,
28
+ clientGranted: false,
29
+ contentPath: "/api/panel-apps/demo/content",
30
+ createdAt: "2026-08-19T00:00:00.000Z",
31
+ favorite: false,
32
+ fileName: "demo.panel.html",
33
+ id: "demo",
34
+ kind: "single-file",
35
+ mainSidebar: false,
36
+ openCount: 0,
37
+ sizeBytes: 10,
38
+ title: "Demo",
39
+ updatedAt: "2026-08-19T00:00:00.000Z",
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe("PanelAppMainSidebarNav", () => {
45
+ beforeEach(() => {
46
+ mocks.entries = [];
47
+ mocks.mutate.mockReset();
48
+ });
49
+
50
+ it("projects only manually added apps in persisted order", () => {
51
+ const entries = [
52
+ createEntry({ appId: "hidden", title: "Hidden" }),
53
+ createEntry({ appId: "second", mainSidebar: true, mainSidebarOrder: 1, title: "Second" }),
54
+ createEntry({ appId: "first", mainSidebar: true, mainSidebarOrder: 0, title: "First" }),
55
+ ];
56
+
57
+ expect(getMainSidebarPanelApps(entries).map((entry) => entry.appId)).toEqual([
58
+ "first",
59
+ "second",
60
+ ]);
61
+ });
62
+
63
+ it("renders stable app routes and hides unbound apps", () => {
64
+ mocks.entries = [
65
+ createEntry({ appId: "publisher.todo", mainSidebar: true, title: "Todo" }),
66
+ createEntry({ appId: "hidden", title: "Hidden" }),
67
+ ];
68
+
69
+ render(
70
+ <MemoryRouter initialEntries={["/apps/panel/publisher.todo"]}>
71
+ <PanelAppMainSidebarNav isCollapsed={false} />
72
+ </MemoryRouter>,
73
+ );
74
+
75
+ const link = screen.getByRole("link", { name: "Todo" });
76
+ expect(link.getAttribute("href")).toBe("/apps/panel/publisher.todo");
77
+ expect(link.getAttribute("aria-current")).toBe("page");
78
+ expect(screen.queryByText("Hidden")).toBeNull();
79
+ });
80
+
81
+ it("removes an expanded entry from its hover actions without nesting the button in the link", async () => {
82
+ const user = userEvent.setup();
83
+ mocks.entries = [
84
+ createEntry({ appId: "publisher.todo", mainSidebar: true, title: "Todo" }),
85
+ ];
86
+
87
+ render(
88
+ <MemoryRouter initialEntries={["/apps/panel/publisher.todo"]}>
89
+ <PanelAppMainSidebarNav isCollapsed={false} />
90
+ </MemoryRouter>,
91
+ );
92
+
93
+ const link = screen.getByRole("link", { name: "Todo" });
94
+ const menuButton = screen.getByRole("button", {
95
+ name: "More panel app actions: Todo",
96
+ });
97
+ expect(link.contains(menuButton)).toBe(false);
98
+ expect(menuButton.className).toContain("group-hover/panel-app:opacity-100");
99
+
100
+ await user.click(menuButton);
101
+ await user.click(screen.getByRole("button", { name: "Remove from main sidebar" }));
102
+
103
+ expect(mocks.mutate).toHaveBeenCalledWith({
104
+ id: "demo",
105
+ preferences: { mainSidebar: false },
106
+ });
107
+ expect(link.getAttribute("aria-current")).toBe("page");
108
+ });
109
+
110
+ it("keeps the collapsed rail as one navigation target", () => {
111
+ mocks.entries = [
112
+ createEntry({ appId: "publisher.todo", mainSidebar: true, title: "Todo" }),
113
+ ];
114
+
115
+ render(
116
+ <MemoryRouter>
117
+ <PanelAppMainSidebarNav isCollapsed />
118
+ </MemoryRouter>,
119
+ );
120
+
121
+ expect(screen.getByRole("link", { name: "Todo" })).toBeTruthy();
122
+ expect(screen.queryByRole("button", { name: "More panel app actions: Todo" })).toBeNull();
123
+ });
124
+
125
+ it("renders no section when no app was manually added", () => {
126
+ const { container } = render(
127
+ <MemoryRouter>
128
+ <PanelAppMainSidebarNav isCollapsed={false} />
129
+ </MemoryRouter>,
130
+ );
131
+ expect(container.firstChild).toBeNull();
132
+ });
133
+ });
@@ -1,24 +1,85 @@
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';
4
- import { PanelAppToolbar } from '../panel-app-toolbar';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
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
+ };
5
32
 
6
33
  describe('PanelAppToolbar', () => {
7
- it('shows the current panel app title in the detail header', async () => {
34
+ beforeEach(() => {
35
+ mainSidebarMutation.isPending = false;
36
+ mainSidebarMutation.mutate.mockReset();
37
+ });
38
+
39
+ it('keeps placement in the overflow menu without adding a second back action', async () => {
8
40
  const user = userEvent.setup();
9
- const onOpenApps = vi.fn();
10
41
  render(
11
42
  <PanelAppToolbar
12
43
  appTitle="墨爪助手"
13
- onOpenApps={onOpenApps}
44
+ entry={entry}
14
45
  onRefresh={vi.fn()}
15
46
  />,
16
47
  );
17
48
 
18
49
  expect(screen.getByText('墨爪助手')).toBeTruthy();
50
+ expect(screen.queryByRole('button', { name: 'Apps' })).toBeNull();
51
+
52
+ expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
53
+ await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
54
+ const addButton = screen.getByRole('button', { name: 'Add to main sidebar' });
55
+ expect(addButton.getAttribute('aria-pressed')).toBe('false');
56
+ await user.click(addButton);
57
+
58
+ expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
59
+ id: 'ink-assistant',
60
+ preferences: { mainSidebar: true },
61
+ });
62
+ expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
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
+ );
19
74
 
20
- await user.click(screen.getByRole('button', { name: 'Apps' }));
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);
21
79
 
22
- expect(onOpenApps).toHaveBeenCalledTimes(1);
80
+ expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
81
+ id: 'ink-assistant',
82
+ preferences: { mainSidebar: false },
83
+ });
23
84
  });
24
85
  });
@@ -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,8 @@
1
1
  import { useState, type MouseEvent } from 'react';
2
- import { AppWindow, MoreVertical, 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
+ import { PanelAppIcon } from '@/features/panel-apps/components/panel-app-icon';
5
+ import { PanelAppMainSidebarMenuItem } from '@/features/panel-apps/components/panel-app-main-sidebar-menu-item';
4
6
  import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
5
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
6
8
  import { getLanguage, getLocale, t } from '@/shared/lib/i18n';
@@ -36,7 +38,6 @@ export function PanelAppListItem({
36
38
  setIsMenuOpen(false);
37
39
  setIsDeleteDialogOpen(true);
38
40
  };
39
-
40
41
  return (
41
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">
42
43
  <div className="flex min-w-0 items-start gap-2">
@@ -75,6 +76,10 @@ export function PanelAppListItem({
75
76
  </button>
76
77
  </PopoverTrigger>
77
78
  <PopoverContent align="end" className="w-48 rounded-xl p-1.5">
79
+ <PanelAppMainSidebarMenuItem
80
+ entry={entry}
81
+ onSelect={() => setIsMenuOpen(false)}
82
+ />
78
83
  <PanelAppMenuItem
79
84
  destructive
80
85
  disabled={deletePending}
@@ -144,30 +149,3 @@ function formatPanelAppTime(value: string): string {
144
149
  }
145
150
  return new Intl.DateTimeFormat(locale, { year: '2-digit', month: 'numeric', day: 'numeric' }).format(date);
146
151
  }
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,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
+ }
@@ -0,0 +1,123 @@
1
+ import { useMemo, useState } from "react";
2
+ import { MoreVertical } from "lucide-react";
3
+ import { SidebarNavLinkItem } from "@/app/components/layout/sidebar-items";
4
+ import { SIDEBAR_RAIL_STACK_CLASS } from "@/app/components/layout/sidebar-rail.styles";
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";
7
+ import { usePanelApps } from "@/features/panel-apps/hooks/use-panel-apps";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover";
9
+ import type { PanelAppEntryView } from "@/shared/lib/api";
10
+ import { t } from "@/shared/lib/i18n";
11
+ import { cn } from "@/shared/lib/utils";
12
+
13
+ const MAIN_SIDEBAR_FALLBACK_ORDER = Number.MAX_SAFE_INTEGER;
14
+
15
+ export function getMainSidebarPanelApps(
16
+ entries: readonly PanelAppEntryView[],
17
+ ): PanelAppEntryView[] {
18
+ return entries
19
+ .filter((entry) => entry.mainSidebar)
20
+ .sort((left, right) =>
21
+ (left.mainSidebarOrder ?? MAIN_SIDEBAR_FALLBACK_ORDER) -
22
+ (right.mainSidebarOrder ?? MAIN_SIDEBAR_FALLBACK_ORDER) ||
23
+ left.title.localeCompare(right.title)
24
+ );
25
+ }
26
+
27
+ export function PanelAppMainSidebarNav({
28
+ isCollapsed,
29
+ }: {
30
+ isCollapsed: boolean;
31
+ }) {
32
+ const panelApps = usePanelApps();
33
+ const entries = useMemo(
34
+ () => getMainSidebarPanelApps(panelApps.data?.entries ?? []),
35
+ [panelApps.data?.entries],
36
+ );
37
+
38
+ if (entries.length === 0) {
39
+ return null;
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className={cn(
45
+ "mt-1 border-t border-border/50 pt-1",
46
+ isCollapsed ? "px-0" : "px-3",
47
+ )}
48
+ data-testid="panel-app-main-sidebar-nav"
49
+ >
50
+ <ul className={isCollapsed ? SIDEBAR_RAIL_STACK_CLASS : "space-y-0.5"}>
51
+ {entries.map((entry) => (
52
+ <PanelAppMainSidebarNavItem
53
+ key={entry.appId}
54
+ entry={entry}
55
+ isCollapsed={isCollapsed}
56
+ />
57
+ ))}
58
+ </ul>
59
+ </div>
60
+ );
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,43 +1,84 @@
1
- import { ArrowLeft, RefreshCw } from 'lucide-react';
1
+ import { useMemo, useState } from 'react';
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';
6
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
7
+ import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
8
+ import type { PanelAppEntryView } from '@/shared/lib/api';
2
9
  import { t } from '@/shared/lib/i18n';
3
10
 
4
11
  type PanelAppToolbarProps = {
5
12
  appTitle: string;
6
- onOpenApps: () => void;
13
+ entry?: PanelAppEntryView;
7
14
  onRefresh: () => void;
8
15
  };
9
16
 
10
17
  export function PanelAppToolbar({
11
18
  appTitle,
12
- onOpenApps,
19
+ entry,
13
20
  onRefresh,
14
21
  }: PanelAppToolbarProps) {
22
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
23
+
15
24
  return (
16
25
  <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
26
  <span
27
27
  className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground"
28
28
  title={appTitle}
29
29
  >
30
30
  {appTitle}
31
31
  </span>
32
- <button
33
- type="button"
32
+ {entry ? (
33
+ <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
34
+ <PopoverTrigger asChild>
35
+ <IconActionButton
36
+ icon={<MoreVertical className="h-3.5 w-3.5" />}
37
+ label={t('panelAppsMoreActions')}
38
+ size="sm"
39
+ />
40
+ </PopoverTrigger>
41
+ <PopoverContent align="end" className="w-48 rounded-xl p-1.5">
42
+ <PanelAppMainSidebarMenuItem
43
+ entry={entry}
44
+ onSelect={() => setIsMenuOpen(false)}
45
+ />
46
+ </PopoverContent>
47
+ </Popover>
48
+ ) : null}
49
+ <IconActionButton
50
+ icon={<RefreshCw className="h-3.5 w-3.5" />}
51
+ label={t('panelAppsRefreshCurrent')}
34
52
  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>
53
+ size="sm"
54
+ />
41
55
  </div>
42
56
  );
43
57
  }
58
+
59
+ export function PanelAppDocBrowserToolbar({
60
+ appTitle,
61
+ currentUrl,
62
+ onRefresh,
63
+ resourceUri,
64
+ }: {
65
+ appTitle: string;
66
+ currentUrl: string;
67
+ onRefresh: () => void;
68
+ resourceUri?: string;
69
+ }) {
70
+ const panelApps = usePanelApps();
71
+ const appId = readPanelAppIdFromTab({ currentUrl, resourceUri });
72
+ const entry = useMemo(
73
+ () => panelApps.data?.entries.find((candidate) => candidate.appId === appId),
74
+ [appId, panelApps.data?.entries],
75
+ );
76
+
77
+ return (
78
+ <PanelAppToolbar
79
+ appTitle={entry?.title ?? appTitle}
80
+ entry={entry}
81
+ onRefresh={onRefresh}
82
+ />
83
+ );
84
+ }
@@ -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,31 +37,8 @@ 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
- }
44
- };
45
-
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
- }],
58
- });
59
- if (!allowed) {
60
- return false;
61
- }
62
- await grantClient.mutateAsync(entry.appId);
63
- return true;
40
+ onOpenPanelApp(entry);
41
+ recordOpened.mutate(entry.id);
64
42
  };
65
43
 
66
44
  const toggleFavorite = (entry: PanelAppEntryView) => {