@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,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
+ });
@@ -0,0 +1,50 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { useAppPresenter } from "@/app/components/app-presenter-provider";
3
+ import { useGrantPanelAppClient } from "@/features/panel-apps/hooks/use-panel-apps";
4
+ import type { PanelAppEntryView } from "@/shared/lib/api";
5
+ import { t } from "@/shared/lib/i18n";
6
+
7
+ export function usePanelAppClientGrant() {
8
+ const presenter = useAppPresenter();
9
+ const { isPending, mutateAsync: grantPanelAppClient } = useGrantPanelAppClient();
10
+ const pendingGrantsRef = useRef(new Map<string, Promise<boolean>>());
11
+
12
+ const ensurePanelAppClientGrant = useCallback(async (
13
+ entry: PanelAppEntryView,
14
+ ): Promise<boolean> => {
15
+ if (!entry.clientDeclared || entry.clientGranted) {
16
+ return true;
17
+ }
18
+ const pendingGrant = pendingGrantsRef.current.get(entry.appId);
19
+ if (pendingGrant) {
20
+ return await pendingGrant;
21
+ }
22
+ const grant = (async () => {
23
+ const allowed = await presenter.serviceActionAuthorizationManager.requestAuthorization({
24
+ panelAppId: entry.appId,
25
+ actions: [{
26
+ actionId: "nextclaw.client",
27
+ actionTitle: t("panelAppsClientGrantTitle"),
28
+ actionDescription: t("panelAppsClientGrantDescription"),
29
+ risk: "dangerous",
30
+ }],
31
+ });
32
+ if (!allowed) {
33
+ return false;
34
+ }
35
+ await grantPanelAppClient(entry.appId);
36
+ return true;
37
+ })();
38
+ pendingGrantsRef.current.set(entry.appId, grant);
39
+ try {
40
+ return await grant;
41
+ } finally {
42
+ pendingGrantsRef.current.delete(entry.appId);
43
+ }
44
+ }, [grantPanelAppClient, presenter]);
45
+
46
+ return {
47
+ ensurePanelAppClientGrant,
48
+ isPending,
49
+ };
50
+ }
@@ -0,0 +1,66 @@
1
+ import { useId } from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { usePanelAppClientGrant } from "@/features/panel-apps/hooks/use-panel-app-client-grant";
4
+ import {
5
+ PANEL_APPS_QUERY_KEY,
6
+ replacePanelAppListEntry,
7
+ } from "@/features/panel-apps/hooks/use-panel-apps";
8
+ import {
9
+ nextclawClient,
10
+ type PanelAppEntryView,
11
+ type PanelAppListView,
12
+ } from "@/shared/lib/api";
13
+
14
+ export type PanelAppMainRuntimeState = "allowed" | "checking" | "denied" | "error" | "idle";
15
+
16
+ export function usePanelAppMainRuntime(entry: PanelAppEntryView | undefined) {
17
+ const queryClient = useQueryClient();
18
+ const runtimeInstanceId = useId();
19
+ const { ensurePanelAppClientGrant } = usePanelAppClientGrant();
20
+ const requiresClientGrant = Boolean(
21
+ entry?.clientDeclared && !entry.clientGranted,
22
+ );
23
+ const grantQuery = useQuery({
24
+ queryKey: [
25
+ "panel-app-main-client-grant",
26
+ entry?.appId ?? "none",
27
+ entry?.clientGranted ?? false,
28
+ ],
29
+ queryFn: async () => await ensurePanelAppClientGrant(entry!),
30
+ enabled: requiresClientGrant,
31
+ gcTime: 0,
32
+ retry: false,
33
+ staleTime: Number.POSITIVE_INFINITY,
34
+ });
35
+ const state: PanelAppMainRuntimeState = !entry
36
+ ? "idle"
37
+ : !requiresClientGrant
38
+ ? "allowed"
39
+ : grantQuery.isError
40
+ ? "error"
41
+ : grantQuery.isPending
42
+ ? "checking"
43
+ : grantQuery.data
44
+ ? "allowed"
45
+ : "denied";
46
+
47
+ useQuery({
48
+ queryKey: ["panel-app-main-open", runtimeInstanceId, entry?.appId ?? "none"],
49
+ queryFn: async () => {
50
+ const opened = await nextclawClient.panelApps.recordPanelAppOpened(entry!.id);
51
+ queryClient.setQueryData<PanelAppListView>(
52
+ PANEL_APPS_QUERY_KEY,
53
+ (current) => replacePanelAppListEntry(current, opened),
54
+ );
55
+ return opened;
56
+ },
57
+ enabled: Boolean(entry && state === "allowed"),
58
+ retry: false,
59
+ staleTime: Number.POSITIVE_INFINITY,
60
+ });
61
+
62
+ return {
63
+ retryClientGrant: grantQuery.refetch,
64
+ state,
65
+ };
66
+ }
@@ -1,8 +1,70 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { toast } from 'sonner';
2
3
  import { nextclawClient } from '@/shared/lib/api';
3
- import type { PanelAppPreferencesUpdateView } from '@/shared/lib/api';
4
+ import type {
5
+ PanelAppEntryView,
6
+ PanelAppListView,
7
+ PanelAppPreferencesUpdateView,
8
+ } from '@/shared/lib/api';
9
+ import { t } from '@/shared/lib/i18n';
4
10
 
5
- const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
11
+ export const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
12
+
13
+ type PanelAppPreferencesMutation = {
14
+ id: string;
15
+ preferences: PanelAppPreferencesUpdateView;
16
+ };
17
+
18
+ function matchesPanelApp(entry: PanelAppEntryView, id: string): boolean {
19
+ return entry.id === id || entry.appId === id;
20
+ }
21
+
22
+ function updateCachedPanelApp(
23
+ current: PanelAppListView | undefined,
24
+ id: string,
25
+ update: (entry: PanelAppEntryView) => PanelAppEntryView,
26
+ ): PanelAppListView | undefined {
27
+ if (!current) {
28
+ return current;
29
+ }
30
+ return {
31
+ ...current,
32
+ entries: current.entries.map((entry) => matchesPanelApp(entry, id) ? update(entry) : entry),
33
+ };
34
+ }
35
+
36
+ export function replacePanelAppListEntry(
37
+ current: PanelAppListView | undefined,
38
+ entry: PanelAppEntryView,
39
+ ): PanelAppListView | undefined {
40
+ return updateCachedPanelApp(current, entry.id, () => entry);
41
+ }
42
+
43
+ function applyOptimisticPreferences(
44
+ current: PanelAppListView | undefined,
45
+ mutation: PanelAppPreferencesMutation,
46
+ ): PanelAppListView | undefined {
47
+ const nextMainSidebarOrder = current?.entries.reduce(
48
+ (maximum, entry) => Math.max(maximum, entry.mainSidebarOrder ?? -1),
49
+ -1,
50
+ ) ?? -1;
51
+ return updateCachedPanelApp(current, mutation.id, (entry) => ({
52
+ ...entry,
53
+ ...(typeof mutation.preferences.favorite === 'boolean'
54
+ ? { favorite: mutation.preferences.favorite }
55
+ : {}),
56
+ ...(typeof mutation.preferences.mainSidebar === 'boolean'
57
+ ? mutation.preferences.mainSidebar
58
+ ? {
59
+ mainSidebar: true,
60
+ mainSidebarOrder: entry.mainSidebar
61
+ ? entry.mainSidebarOrder
62
+ : nextMainSidebarOrder + 1,
63
+ }
64
+ : { mainSidebar: false, mainSidebarOrder: undefined }
65
+ : {}),
66
+ }));
67
+ }
6
68
 
7
69
  export function usePanelApps(options: { enabled?: boolean } = {}) {
8
70
  return useQuery({
@@ -16,15 +78,39 @@ export function usePanelApps(options: { enabled?: boolean } = {}) {
16
78
  export function useUpdatePanelAppPreferences() {
17
79
  const queryClient = useQueryClient();
18
80
  return useMutation({
19
- mutationFn: ({
20
- id,
21
- preferences,
22
- }: {
23
- id: string;
24
- preferences: PanelAppPreferencesUpdateView;
25
- }) => nextclawClient.panelApps.updatePanelAppPreferences(id, preferences),
26
- onSuccess: () => {
27
- void queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY });
81
+ mutationFn: ({ id, preferences }: PanelAppPreferencesMutation) =>
82
+ nextclawClient.panelApps.updatePanelAppPreferences(id, preferences),
83
+ onMutate: async (mutation) => {
84
+ await queryClient.cancelQueries({ queryKey: PANEL_APPS_QUERY_KEY });
85
+ const previousEntry = queryClient
86
+ .getQueryData<PanelAppListView>(PANEL_APPS_QUERY_KEY)
87
+ ?.entries.find((entry) => matchesPanelApp(entry, mutation.id));
88
+ queryClient.setQueryData<PanelAppListView>(
89
+ PANEL_APPS_QUERY_KEY,
90
+ (current) => applyOptimisticPreferences(current, mutation),
91
+ );
92
+ return { previousEntry };
93
+ },
94
+ onSuccess: (entry) => {
95
+ queryClient.setQueryData<PanelAppListView>(
96
+ PANEL_APPS_QUERY_KEY,
97
+ (current) => replacePanelAppListEntry(current, entry),
98
+ );
99
+ },
100
+ onError: (error, mutation, context) => {
101
+ const previousEntry = context?.previousEntry;
102
+ if (previousEntry) {
103
+ queryClient.setQueryData<PanelAppListView>(
104
+ PANEL_APPS_QUERY_KEY,
105
+ (current) => updateCachedPanelApp(
106
+ current,
107
+ mutation.id,
108
+ () => previousEntry,
109
+ ),
110
+ );
111
+ }
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ toast.error(`${t('panelAppsPreferenceUpdateFailed')}: ${message}`);
28
114
  },
29
115
  });
30
116
  }
@@ -33,8 +119,11 @@ export function useRecordPanelAppOpened() {
33
119
  const queryClient = useQueryClient();
34
120
  return useMutation({
35
121
  mutationFn: (id: string) => nextclawClient.panelApps.recordPanelAppOpened(id),
36
- onSuccess: () => {
37
- void queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY });
122
+ onSuccess: (entry) => {
123
+ queryClient.setQueryData<PanelAppListView>(
124
+ PANEL_APPS_QUERY_KEY,
125
+ (current) => replacePanelAppListEntry(current, entry),
126
+ );
38
127
  },
39
128
  });
40
129
  }
@@ -1,4 +1,6 @@
1
1
  export { PanelAppsList } from './components/panel-apps-list';
2
+ export { PanelAppMainSidebarNav } from './components/panel-app-main-sidebar-nav';
3
+ export { PanelAppMainPage } from './pages/panel-app-main-page';
2
4
  export {
3
5
  useGrantPanelAppClient,
4
6
  usePanelApps,
@@ -48,10 +48,7 @@ function createIframeHarness(manager: PanelAppBridgeManager) {
48
48
  const iframe = { contentWindow } as HTMLIFrameElement;
49
49
  return {
50
50
  postMessage,
51
- send: (
52
- data: Record<string, unknown>,
53
- iframeInstanceId = 'tab-1:0:0',
54
- ) => manager.handleIframeMessage({
51
+ send: (data: Record<string, unknown>) => manager.handleIframeMessage({
55
52
  event: {
56
53
  data: {
57
54
  appId: 'mood-calendar',
@@ -61,16 +58,6 @@ function createIframeHarness(manager: PanelAppBridgeManager) {
61
58
  source: contentWindow,
62
59
  } as MessageEvent,
63
60
  iframe,
64
- iframeInstanceId,
65
- tab: {
66
- currentUrl: '/api/panel-apps/mood-calendar/content',
67
- history: [],
68
- historyIndex: 0,
69
- id: 'tab-1',
70
- kind: 'content',
71
- navVersion: 0,
72
- title: 'Mood Calendar',
73
- },
74
61
  }),
75
62
  };
76
63
  }
@@ -11,9 +11,13 @@ import type {
11
11
  ServiceActionListView,
12
12
  } from '@nextclaw/client-sdk';
13
13
  import type { ServiceActionAuthorizationManager } from '@/features/service-apps';
14
- import type { DocBrowserIframeMessageParams } from '@/shared/components/doc-browser/doc-browser-renderer.types';
15
14
  import { nextclawClient } from '@/shared/lib/api';
16
15
 
16
+ export type PanelAppIframeMessageParams = {
17
+ event: MessageEvent;
18
+ iframe: HTMLIFrameElement | null;
19
+ };
20
+
17
21
  type PanelAppBridgeRequest = {
18
22
  type: 'nextclaw:panel-app-service-actions:request';
19
23
  requestId: string;
@@ -59,18 +63,18 @@ type PanelAppBridgeResponse =
59
63
  export class PanelAppBridgeManager {
60
64
  constructor(private readonly authorizationManager: ServiceActionAuthorizationManager) {}
61
65
 
62
- handleIframeMessage = ({ event, iframe, iframeInstanceId, tab }: DocBrowserIframeMessageParams): void => {
66
+ handleIframeMessage = ({ event, iframe }: PanelAppIframeMessageParams): void => {
63
67
  if (!this.isBridgeRequest(event.data)) {
64
68
  return;
65
69
  }
66
70
  if (!iframe?.contentWindow || event.source !== iframe.contentWindow) {
67
71
  return;
68
72
  }
69
- void this.handleBridgeRequest({ event, iframe, iframeInstanceId, tab }, event.data);
73
+ void this.handleBridgeRequest({ event, iframe }, event.data);
70
74
  };
71
75
 
72
76
  private handleBridgeRequest = async (
73
- params: DocBrowserIframeMessageParams,
77
+ params: PanelAppIframeMessageParams,
74
78
  request: PanelAppBridgeRequest,
75
79
  ): Promise<void> => {
76
80
  try {
@@ -340,7 +344,7 @@ export class PanelAppBridgeManager {
340
344
  };
341
345
 
342
346
  private postResponse = (
343
- params: DocBrowserIframeMessageParams,
347
+ params: PanelAppIframeMessageParams,
344
348
  response: PanelAppBridgeResponse,
345
349
  ): void => {
346
350
  params.iframe?.contentWindow?.postMessage(response, '*');
@@ -0,0 +1,183 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { MemoryRouter, Route, Routes } from "react-router-dom";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { PanelAppMainPage } from "@/features/panel-apps/pages/panel-app-main-page";
7
+ import { PANEL_APP_IFRAME_SANDBOX } from "@/features/panel-apps/utils/panel-app-iframe.utils";
8
+ import type { PanelAppEntryView } from "@/shared/lib/api";
9
+ import type * as SharedApi from "@/shared/lib/api";
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ bridgeMessage: vi.fn(),
13
+ ensureClientGrant: vi.fn(async () => true),
14
+ entries: [] as PanelAppEntryView[],
15
+ openApps: vi.fn(),
16
+ openTarget: vi.fn(),
17
+ recordOpenedApi: vi.fn(),
18
+ refetch: vi.fn(),
19
+ updatePreferences: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@/app/components/app-presenter-provider", () => ({
23
+ useAppPresenter: () => ({
24
+ panelAppBridgeManager: { handleIframeMessage: mocks.bridgeMessage },
25
+ }),
26
+ }));
27
+
28
+ vi.mock("@/features/panel-apps/hooks/use-panel-app-client-grant", () => ({
29
+ usePanelAppClientGrant: () => ({
30
+ ensurePanelAppClientGrant: mocks.ensureClientGrant,
31
+ isPending: false,
32
+ }),
33
+ }));
34
+
35
+ vi.mock("@/features/panel-apps/hooks/use-panel-apps", () => ({
36
+ usePanelApps: () => ({
37
+ data: { entries: mocks.entries },
38
+ error: null,
39
+ isError: false,
40
+ isLoading: false,
41
+ refetch: mocks.refetch,
42
+ }),
43
+ useUpdatePanelAppPreferences: () => ({
44
+ isPending: false,
45
+ mutate: mocks.updatePreferences,
46
+ }),
47
+ }));
48
+
49
+ vi.mock("@/shared/lib/api", async (importOriginal) => {
50
+ const actual = await importOriginal<typeof SharedApi>();
51
+ return {
52
+ ...actual,
53
+ nextclawClient: {
54
+ ...actual.nextclawClient,
55
+ panelApps: {
56
+ ...actual.nextclawClient.panelApps,
57
+ recordPanelAppOpened: mocks.recordOpenedApi,
58
+ },
59
+ },
60
+ };
61
+ });
62
+
63
+ vi.mock("@/shared/components/doc-browser", () => ({
64
+ useDocBrowser: () => ({ openTarget: mocks.openTarget }),
65
+ }));
66
+
67
+ vi.mock("@/features/panel-apps/utils/panel-app-doc-browser.utils", () => ({
68
+ openApps: mocks.openApps,
69
+ }));
70
+
71
+ function createEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
72
+ return {
73
+ appId: "publisher.todo",
74
+ clientDeclared: false,
75
+ clientGranted: false,
76
+ contentPath: "/api/panel-apps/publisher.todo/content",
77
+ createdAt: "2026-08-19T00:00:00.000Z",
78
+ favorite: false,
79
+ fileName: "todo.panel.html",
80
+ id: "todo",
81
+ kind: "single-file",
82
+ mainSidebar: true,
83
+ mainSidebarOrder: 0,
84
+ openCount: 0,
85
+ sizeBytes: 10,
86
+ title: "Rust Todo",
87
+ updatedAt: "2026-08-19T00:00:00.000Z",
88
+ ...overrides,
89
+ };
90
+ }
91
+
92
+ function createQueryClient(): QueryClient {
93
+ return new QueryClient({
94
+ defaultOptions: { queries: { retry: false } },
95
+ });
96
+ }
97
+
98
+ function renderPage(queryClient = createQueryClient()) {
99
+ return render(
100
+ <QueryClientProvider client={queryClient}>
101
+ <MemoryRouter initialEntries={["/apps/panel/publisher.todo"]}>
102
+ <Routes>
103
+ <Route path="/apps/panel/:appId" element={<PanelAppMainPage />} />
104
+ </Routes>
105
+ </MemoryRouter>
106
+ </QueryClientProvider>,
107
+ );
108
+ }
109
+
110
+ describe("PanelAppMainPage", () => {
111
+ beforeEach(() => {
112
+ vi.clearAllMocks();
113
+ mocks.entries = [createEntry()];
114
+ mocks.ensureClientGrant.mockResolvedValue(true);
115
+ mocks.recordOpenedApi.mockResolvedValue(createEntry());
116
+ });
117
+
118
+ it("hosts the selected app in the existing sandbox and records one open", async () => {
119
+ renderPage();
120
+
121
+ const iframe = await screen.findByTitle("Rust Todo");
122
+ expect(iframe.getAttribute("src")).toBe("/api/panel-apps/publisher.todo/content");
123
+ expect(iframe.getAttribute("sandbox")).toBe(PANEL_APP_IFRAME_SANDBOX);
124
+ await waitFor(() => expect(mocks.recordOpenedApi).toHaveBeenCalledWith("todo"));
125
+
126
+ fireEvent(window, new MessageEvent("message", { data: { type: "ignored" } }));
127
+ expect(mocks.bridgeMessage).toHaveBeenCalledWith(expect.objectContaining({
128
+ event: expect.any(MessageEvent),
129
+ iframe,
130
+ }));
131
+ });
132
+
133
+ it("renders the app edge-to-edge without a duplicate host header", async () => {
134
+ renderPage();
135
+ const iframe = await screen.findByTitle("Rust Todo");
136
+
137
+ expect(screen.queryByRole("heading", { name: "Rust Todo" })).toBeNull();
138
+ expect(screen.queryByRole("button", { name: "Open in right panel" })).toBeNull();
139
+ expect(screen.queryByRole("button", { name: "Refresh current panel app" })).toBeNull();
140
+ expect(iframe.parentElement?.children).toHaveLength(1);
141
+ expect(iframe.className).toContain("h-full");
142
+ expect(iframe.className).toContain("w-full");
143
+ });
144
+
145
+ it("does not mount a client-enabled app when authorization is denied", async () => {
146
+ mocks.entries = [createEntry({ clientDeclared: true })];
147
+ mocks.ensureClientGrant.mockResolvedValue(false);
148
+ renderPage();
149
+
150
+ expect(await screen.findByText(/required NextClaw Client permission was not granted/)).toBeTruthy();
151
+ expect(screen.queryByTitle("Rust Todo")).toBeNull();
152
+ expect(mocks.recordOpenedApi).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("does not reuse an old client grant decision across main-page instances", async () => {
156
+ const queryClient = createQueryClient();
157
+ mocks.entries = [createEntry({ clientDeclared: true })];
158
+ const first = renderPage(queryClient);
159
+ await screen.findByTitle("Rust Todo");
160
+ expect(mocks.ensureClientGrant).toHaveBeenCalledTimes(1);
161
+
162
+ first.unmount();
163
+ await waitFor(() => expect(queryClient.getQueryData([
164
+ "panel-app-main-client-grant",
165
+ "publisher.todo",
166
+ false,
167
+ ])).toBeUndefined());
168
+
169
+ renderPage(queryClient);
170
+ await screen.findByTitle("Rust Todo");
171
+ expect(mocks.ensureClientGrant).toHaveBeenCalledTimes(2);
172
+ });
173
+
174
+ it("shows a recoverable unavailable state for disabled or uninstalled apps", async () => {
175
+ const user = userEvent.setup();
176
+ mocks.entries = [];
177
+ renderPage();
178
+
179
+ expect(screen.getByText(/This panel app is unavailable/)).toBeTruthy();
180
+ await user.click(screen.getByRole("button", { name: "Open app management" }));
181
+ expect(mocks.openApps).toHaveBeenCalledTimes(1);
182
+ });
183
+ });