@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.
- package/CHANGELOG.md +26 -0
- package/dist/assets/api-Cyxp93Fn.js +13 -0
- package/dist/assets/appearance-settings-page-DsMl931U.js +1 -0
- package/dist/assets/{book-open-BOJLGVsx.js → book-open-Cl5CzL0g.js} +1 -1
- package/dist/assets/{channels-list-page-BPy2mXoD.js → channels-list-page-D_B7JGoZ.js} +2 -2
- package/dist/assets/chat-page-D3ZeULTv.js +1 -0
- package/dist/assets/config-split-page-C-YVl2mf.js +1 -0
- package/dist/assets/{confirm-dialog-Bsg9A62_.js → confirm-dialog-Bz3ieDQ5.js} +2 -2
- package/dist/assets/desktop-update-config-DO051-QG.js +1 -0
- package/dist/assets/{dist-IBUY9_9h.js → dist-CDPl8mm7.js} +1 -1
- package/dist/assets/{dist-DyUY9uQ0.js → dist-HFDWY6-K.js} +1 -1
- package/dist/assets/doc-browser-CWhs0JQN.js +1 -0
- package/dist/assets/doc-browser-D6H_nPzO.js +1 -0
- package/dist/assets/{doc-browser-context-BM6Af8gN.js → doc-browser-context-BKwz_j44.js} +1 -1
- package/dist/assets/form-actions-BUZOCfPR.js +1 -0
- package/dist/assets/index-DR3GLGme.css +1 -0
- package/dist/assets/index-j6DBnvlr.js +109 -0
- package/dist/assets/loader-circle-s65a4ZzL.js +1 -0
- package/dist/assets/mcp-marketplace-page-BJvzGt7h.js +9 -0
- package/dist/assets/mcp-marketplace-page-EpFHeJzw.js +1 -0
- package/dist/assets/model-config-page-CBoh0DVh.js +1 -0
- package/dist/assets/{navigation-link-DtPZZ_Q_.js → navigation-link-CDMYpkVv.js} +2 -2
- package/dist/assets/plus-XDIkqpkt.js +1 -0
- package/dist/assets/{provider-scoped-model-input-RWZz36z7.js → provider-scoped-model-input-C2iUtZi7.js} +1 -1
- package/dist/assets/providers-config-page-DX1pISTo.js +1 -0
- package/dist/assets/react-B7Paq2b3.js +8 -0
- package/dist/assets/{refresh-cw-BQHORcAs.js → refresh-cw-XrnNVwuQ.js} +1 -1
- package/dist/assets/remote-D_7_OGoj.js +1 -0
- package/dist/assets/rotate-cw-BhIWpLaU.js +1 -0
- package/dist/assets/runtime-config-page-BVO9B8Ns.js +1 -0
- package/dist/assets/{save-BgiS2u-T.js → save-_nSNAYQu.js} +1 -1
- package/dist/assets/{search-B6mO7mmo.js → search-0OTHFRMx.js} +1 -1
- package/dist/assets/{search-config-page-QS-kqNDZ.js → search-config-page-BwyeUjKt.js} +1 -1
- package/dist/assets/secrets-config-page-2O-TzroJ.js +3 -0
- package/dist/assets/security-config-BgIIrg1s.js +1 -0
- package/dist/assets/{select-DToos7Rq.js → select-BZHvnwQB.js} +2 -2
- package/dist/assets/{settings-2-ZAWXrq81.js → settings-2-CM_PvDNk.js} +1 -1
- package/dist/assets/skeleton-Bv4MMbH7.js +1 -0
- package/dist/assets/{tag-chip-urB3N-kk.js → tag-chip-Ce53PMTL.js} +1 -1
- package/dist/assets/x-CCjWN5ys.js +1 -0
- package/dist/index.html +20 -20
- package/package.json +6 -6
- package/src/app/components/layout/sidebar-items.tsx +49 -15
- package/src/app/configs/__tests__/app-navigation.config.test.ts +21 -0
- package/src/app/configs/app-navigation.config.ts +10 -1
- package/src/app/index.tsx +4 -0
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +7 -0
- package/src/features/chat/components/layout/chat-page-shell.tsx +4 -1
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +26 -22
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +2 -0
- package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
- package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +1 -0
- package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +1 -3
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +15 -1
- package/src/features/chat/features/message/components/chat-tool-call-stress-harness.tsx +178 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +21 -0
- package/src/features/chat/features/message/utils/__tests__/chat-tool-call-stress-fixture.utils.test.ts +56 -0
- package/src/features/chat/features/message/utils/chat-message-core.utils.ts +1 -1
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +3 -10
- package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +29 -1
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +3 -0
- package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +3 -28
- package/src/features/chat/features/message/utils/chat-tool-call-stress-fixture.utils.ts +122 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-conversation.test.tsx +69 -0
- package/src/features/chat/features/ncp/hooks/use-ncp-session-conversation.ts +29 -0
- package/src/features/chat/features/ncp/hooks/use-ncp-session-message-history.ts +106 -1
- package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +2 -12
- package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +4 -2
- package/src/features/panel-apps/components/__tests__/panel-app-list-item.test.tsx +44 -1
- package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +82 -0
- package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +13 -6
- package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +7 -1
- package/src/features/panel-apps/components/panel-app-icon.tsx +48 -0
- package/src/features/panel-apps/components/panel-app-list-item.tsx +19 -28
- package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +72 -0
- package/src/features/panel-apps/components/panel-app-toolbar.tsx +81 -20
- package/src/features/panel-apps/components/panel-apps-list.tsx +13 -26
- package/src/features/panel-apps/hooks/__tests__/use-panel-apps.test.tsx +105 -0
- package/src/features/panel-apps/hooks/use-panel-app-client-grant.ts +50 -0
- package/src/features/panel-apps/hooks/use-panel-app-main-runtime.ts +66 -0
- package/src/features/panel-apps/hooks/use-panel-apps.ts +102 -13
- package/src/features/panel-apps/index.ts +2 -0
- package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +1 -14
- package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +9 -5
- package/src/features/panel-apps/pages/__tests__/panel-app-main-page.test.tsx +183 -0
- package/src/features/panel-apps/pages/panel-app-main-page.tsx +125 -0
- package/src/features/panel-apps/utils/__tests__/panel-app-view.utils.test.ts +2 -1
- package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +5 -4
- package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +12 -25
- package/src/features/right-panel-resources/index.ts +1 -0
- package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +22 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +31 -2
- package/src/main.tsx +24 -11
- package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +76 -0
- package/src/shared/lib/api/ncp-session.types.ts +1 -0
- package/src/shared/lib/api/types.ts +3 -0
- package/src/shared/lib/api/utils/ncp-session.utils.ts +24 -1
- package/src/shared/lib/i18n/locales/en-US/chat.json +4 -0
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +10 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +4 -0
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +10 -0
- package/dist/assets/api-BSsQ0I4l.js +0 -13
- package/dist/assets/appearance-settings-page-2ilQwMl0.js +0 -1
- package/dist/assets/chat-page-DDR-Q4Kf.js +0 -1
- package/dist/assets/config-split-page-aUTKtWJu.js +0 -1
- package/dist/assets/desktop-update-config-Ds4pN7kz.js +0 -1
- package/dist/assets/doc-browser-D0arfzHY.js +0 -1
- package/dist/assets/doc-browser-D73yN8ts.js +0 -1
- package/dist/assets/form-actions-Cpd7glh8.js +0 -1
- package/dist/assets/index-Bsoscjk7.css +0 -1
- package/dist/assets/index-C2N1bYyb.js +0 -109
- package/dist/assets/loader-circle-C1fL3kBZ.js +0 -1
- package/dist/assets/mcp-marketplace-page-D8V0GU4Y.js +0 -9
- package/dist/assets/mcp-marketplace-page-q6T-vKoH.js +0 -1
- package/dist/assets/model-config-page-B7QR33ED.js +0 -1
- package/dist/assets/plus-B9V7Z_Eu.js +0 -1
- package/dist/assets/providers-config-page-B5yakiZe.js +0 -1
- package/dist/assets/react-D-jOo51s.js +0 -8
- package/dist/assets/remote-BfRYDhkz.js +0 -1
- package/dist/assets/rotate-cw-DuCF-3PX.js +0 -1
- package/dist/assets/runtime-config-page-Big45YWb.js +0 -1
- package/dist/assets/secrets-config-page-DE3kmDHk.js +0 -3
- package/dist/assets/security-config-Ds_2SXBh.js +0 -1
- package/dist/assets/skeleton-Bs2eO2CA.js +0 -1
- 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 '
|
|
4
|
+
import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
|
|
5
5
|
|
|
6
6
|
describe('PanelAppToolbar', () => {
|
|
7
|
-
it('
|
|
7
|
+
it('keeps placement in the overflow menu without adding a second back action', async () => {
|
|
8
8
|
const user = userEvent.setup();
|
|
9
|
-
const
|
|
9
|
+
const onToggleMainSidebar = vi.fn();
|
|
10
10
|
render(
|
|
11
11
|
<PanelAppToolbar
|
|
12
12
|
appTitle="墨爪助手"
|
|
13
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
} catch {
|
|
42
|
-
onOpenPanelApp(entry);
|
|
43
|
-
}
|
|
40
|
+
onOpenPanelApp(entry);
|
|
41
|
+
recordOpened.mutate(entry.id);
|
|
44
42
|
};
|
|
45
43
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
51
|
+
const toggleMainSidebar = (entry: PanelAppEntryView) => {
|
|
67
52
|
updatePreferences.mutate({
|
|
68
53
|
id: entry.id,
|
|
69
|
-
preferences: {
|
|
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
|
+
});
|