@nextclaw/ui 0.14.0 → 0.14.1-beta.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 (194) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{api-B50yVdT8.js → api-dQ6uQSZG.js} +3 -3
  3. package/dist/assets/channels-list-page-DfpjB8XB.js +8 -0
  4. package/dist/assets/chat-page-D3QaEqjs.js +105 -0
  5. package/dist/assets/config-split-page-CeFfbaqc.js +1 -0
  6. package/dist/assets/{confirm-dialog-DRSelLdI.js → confirm-dialog-C95Y4QPC.js} +1 -1
  7. package/dist/assets/desktop-update-config-CcK6jiEb.js +1 -0
  8. package/dist/assets/{dist-D19wiQwB.js → dist-DTGzeil6.js} +1 -1
  9. package/dist/assets/{dist-NZRV-BxD.js → dist-fGogBmPA.js} +1 -1
  10. package/dist/assets/doc-browser-CuBTcBEZ.js +1 -0
  11. package/dist/assets/doc-browser-DVHhEb96.js +1 -0
  12. package/dist/assets/{doc-browser-context-C8KPbj-p.js → doc-browser-context-BfMXwnrz.js} +1 -1
  13. package/dist/assets/{ellipsis-BYy8TP0A.js → ellipsis-D2F4KMiU.js} +1 -1
  14. package/dist/assets/{external-link-528k6MDD.js → external-link-CcgtBK26.js} +1 -1
  15. package/dist/assets/index-ByJCbQqr.css +1 -0
  16. package/dist/assets/index-DjXqiloY.js +103 -0
  17. package/dist/assets/mcp-marketplace-page-98-V__3u.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-BNq_PNmO.js +40 -0
  19. package/dist/assets/model-config-page-aAUkZkSX.js +1 -0
  20. package/dist/assets/plus-1Rxl5FlV.js +1 -0
  21. package/dist/assets/provider-scoped-model-input-we4_Z4gP.js +1 -0
  22. package/dist/assets/providers-config-page-CtMkPSyu.js +1 -0
  23. package/dist/assets/{react-Dsotw5vM.js → react-CTqdhFMk.js} +1 -1
  24. package/dist/assets/remote-fQ7N_r8u.js +1 -0
  25. package/dist/assets/rotate-cw-CzfIeAJj.js +1 -0
  26. package/dist/assets/runtime-config-page-TD1UrTbG.js +1 -0
  27. package/dist/assets/{save-CHDnDTok.js → save-C9fKWfVB.js} +1 -1
  28. package/dist/assets/search-D5FQ5q0X.js +1 -0
  29. package/dist/assets/search-config-page-AMEJJNIS.js +1 -0
  30. package/dist/assets/secrets-config-page-CcgJnvBO.js +3 -0
  31. package/dist/assets/select-Cu5n0E-3.js +41 -0
  32. package/dist/assets/{tag-chip-Cr8GwpRE.js → tag-chip-C5aOtjxu.js} +1 -1
  33. package/dist/assets/use-config-D8TESeo6.js +1 -0
  34. package/dist/assets/x-1qETdQ70.js +1 -0
  35. package/dist/index.html +14 -27
  36. package/package.json +9 -9
  37. package/src/app/presenters/app.presenter.ts +2 -0
  38. package/src/features/account/components/account-panel.tsx +2 -2
  39. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +12 -63
  40. package/src/features/chat/components/conversation/chat-conversation-content.tsx +11 -50
  41. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +1 -0
  42. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +15 -2
  43. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +122 -85
  44. package/src/features/chat/features/input/hooks/use-chat-input-bar-query-state.ts +120 -0
  45. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +87 -0
  46. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +66 -41
  47. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +1 -0
  48. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +22 -36
  49. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +80 -52
  50. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +89 -0
  51. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +9 -1
  52. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +115 -0
  53. package/src/features/chat/features/message/components/chat-message-list.container.tsx +4 -0
  54. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +310 -0
  55. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +119 -1
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-test.utils.ts +43 -0
  57. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +1 -346
  58. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +51 -0
  59. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +28 -5
  60. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +14 -9
  61. package/src/features/chat/features/message/utils/file-operation/diff.utils.ts +39 -36
  62. package/src/features/chat/features/message/utils/file-operation/line-builder.utils.ts +46 -38
  63. package/src/features/chat/features/message/utils/file-operation/parsed-block.utils.ts +18 -15
  64. package/src/features/chat/features/ncp/hooks/__tests__/use-ui-show-content-event.test.tsx +1 -0
  65. package/src/features/chat/features/ncp/utils/ncp-chat-query-derived.utils.ts +11 -3
  66. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +27 -0
  67. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +25 -25
  68. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +19 -1
  69. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +46 -0
  70. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +15 -2
  71. package/src/features/chat/features/session/hooks/__tests__/use-selected-session-context-window-indicator.test.tsx +63 -0
  72. package/src/features/chat/features/session/hooks/use-selected-session-context-window-indicator.ts +7 -10
  73. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +8 -0
  74. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +2 -0
  75. package/src/features/chat/features/session-type/components/__tests__/chat-sidebar-create-menu.test.tsx +24 -0
  76. package/src/features/chat/features/session-type/components/chat-sidebar-create-menu.tsx +10 -1
  77. package/src/features/chat/features/session-type/hooks/use-chat-session-type-state.ts +1 -0
  78. package/src/features/chat/features/session-type/utils/chat-session-type.utils.ts +4 -0
  79. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +193 -0
  80. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +123 -0
  81. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +104 -0
  82. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +72 -0
  83. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +54 -0
  84. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +132 -0
  85. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +82 -0
  86. package/src/features/chat/features/welcome/components/chat-welcome.tsx +121 -0
  87. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +53 -0
  88. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-selection.utils.test.ts +63 -0
  89. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-visibility.utils.test.ts +34 -0
  90. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +71 -0
  91. package/src/features/chat/features/welcome/utils/chat-welcome-selection.utils.ts +79 -0
  92. package/src/features/chat/features/welcome/utils/chat-welcome-visibility.utils.ts +17 -0
  93. package/src/features/chat/index.ts +1 -0
  94. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +93 -0
  95. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +129 -0
  96. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +34 -0
  97. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +143 -1
  98. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +30 -0
  99. package/src/features/chat/managers/__tests__/recent-selection.manager.test.ts +37 -14
  100. package/src/features/chat/managers/chat-draft-intent.manager.ts +46 -0
  101. package/src/features/chat/managers/chat-input.manager.ts +37 -15
  102. package/src/features/chat/managers/chat-run.manager.ts +3 -0
  103. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +19 -2
  104. package/src/features/chat/managers/chat-thread.manager.ts +4 -0
  105. package/src/features/chat/managers/chat-ui.manager.ts +2 -6
  106. package/src/features/chat/managers/recent-selection.manager.ts +21 -67
  107. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +47 -1
  108. package/src/features/chat/pages/ncp-chat-page.tsx +18 -0
  109. package/src/features/chat/stores/chat-input.store.ts +9 -1
  110. package/src/features/chat/stores/recent-selection.store.ts +85 -0
  111. package/src/features/chat/types/chat-input-bar.types.ts +5 -1
  112. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +27 -23
  113. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +137 -0
  114. package/src/features/panel-apps/components/panel-apps-list.tsx +119 -5
  115. package/src/features/panel-apps/index.ts +2 -0
  116. package/src/features/panel-apps/utils/panel-app-entry-match.utils.ts +17 -0
  117. package/src/features/settings/components/config/provider-form.tsx +284 -387
  118. package/src/features/settings/components/config/provider-status-badge.tsx +4 -4
  119. package/src/features/settings/components/config/secrets-config-form.tsx +11 -135
  120. package/src/features/settings/hooks/use-provider-auth-flow.ts +140 -0
  121. package/src/features/settings/pages/__tests__/model-config-page.test.tsx +16 -0
  122. package/src/features/settings/pages/secrets-config-page.tsx +2 -1
  123. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +60 -1
  124. package/src/features/settings/utils/__tests__/secrets-config-form.utils.test.ts +66 -0
  125. package/src/features/settings/utils/provider-form-context.utils.ts +131 -0
  126. package/src/features/settings/utils/provider-form-model.utils.ts +101 -0
  127. package/src/features/settings/utils/provider-form-support.utils.ts +102 -1
  128. package/src/features/settings/utils/secrets-config-form.utils.ts +139 -0
  129. package/src/features/system-status/components/config/runtime-agent-list-card.tsx +17 -11
  130. package/src/features/system-status/components/config/runtime-binding-list-card.tsx +14 -9
  131. package/src/features/system-status/components/config/runtime-config-editor.tsx +8 -5
  132. package/src/features/system-status/components/config/runtime-entry-list-card.tsx +14 -9
  133. package/src/features/system-status/components/config/runtime-settings-card.tsx +16 -7
  134. package/src/features/system-status/components/runtime-security-card.tsx +79 -25
  135. package/src/shared/components/common/__tests__/searchable-model-input.test.tsx +34 -0
  136. package/src/shared/components/common/provider-scoped-model-input.tsx +1 -0
  137. package/src/shared/components/common/searchable-model-input.tsx +102 -87
  138. package/src/shared/components/ui/README.md +7 -0
  139. package/src/shared/components/ui/popover.tsx +19 -3
  140. package/src/shared/components/ui/select.tsx +14 -4
  141. package/src/shared/hooks/use-infinite-scroll-loader.ts +14 -10
  142. package/src/shared/lib/api/chat-session-type.types.ts +7 -0
  143. package/src/shared/lib/api/index.ts +2 -0
  144. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  145. package/src/shared/lib/api/preferences/preference.types.ts +22 -0
  146. package/src/shared/lib/api/preferences/preference.utils.ts +40 -0
  147. package/src/shared/lib/api/types.ts +3 -0
  148. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -1
  149. package/src/shared/lib/i18n/locales/en-US/core.json +1 -0
  150. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +10 -0
  151. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -1
  152. package/src/shared/lib/i18n/locales/zh-CN/core.json +1 -0
  153. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +10 -0
  154. package/dist/assets/app-presenter-provider-BI-ewLbO.js +0 -3
  155. package/dist/assets/arrow-left-BOCJyLbQ.js +0 -1
  156. package/dist/assets/book-open-Bd2Xbrrc.js +0 -1
  157. package/dist/assets/channels-list-page-moJkpxtU.js +0 -8
  158. package/dist/assets/chat-page-CO27cl4v.js +0 -105
  159. package/dist/assets/config-split-page-CsQ2rIdq.js +0 -1
  160. package/dist/assets/desktop-update-config-qa-SynOc.js +0 -1
  161. package/dist/assets/doc-browser-2ddUJCA0.js +0 -1
  162. package/dist/assets/doc-browser-BjHUczQb.js +0 -1
  163. package/dist/assets/es2015-BUIIKCNh.js +0 -41
  164. package/dist/assets/host-capabilities-9bB_998Z.js +0 -1
  165. package/dist/assets/index-CMNrRhnG.js +0 -101
  166. package/dist/assets/index-UTmTW1eU.css +0 -1
  167. package/dist/assets/key-round-D3vD9dj7.js +0 -1
  168. package/dist/assets/loader-circle-DV9TSRns.js +0 -1
  169. package/dist/assets/mcp-marketplace-page-CS5Lh3oA.js +0 -1
  170. package/dist/assets/mcp-marketplace-page-DEOMcWb0.js +0 -40
  171. package/dist/assets/model-config-page-BSc4o7pb.js +0 -1
  172. package/dist/assets/notice-card-qqlbsOoR.js +0 -1
  173. package/dist/assets/plus-Bf9qhPDO.js +0 -1
  174. package/dist/assets/popover-BnmPOXe3.js +0 -1
  175. package/dist/assets/provider-scoped-model-input-Ca56s475.js +0 -1
  176. package/dist/assets/providers-config-page-D8Kf4U3u.js +0 -1
  177. package/dist/assets/refresh-cw-Bmx4wc9P.js +0 -1
  178. package/dist/assets/remote-DNMEy_c4.js +0 -1
  179. package/dist/assets/rotate-cw-CL0sVBel.js +0 -1
  180. package/dist/assets/runtime-config-page-CUFtKr0U.js +0 -1
  181. package/dist/assets/search-D7L-TCH-.js +0 -1
  182. package/dist/assets/search-config-page-CA3RVAef.js +0 -1
  183. package/dist/assets/secrets-config-page-95yaH1fa.js +0 -3
  184. package/dist/assets/status-dot-Dm4h-bjn.js +0 -1
  185. package/dist/assets/tabs-CJvw5Lqp.js +0 -1
  186. package/dist/assets/tooltip-Bxzi6aDL.js +0 -1
  187. package/dist/assets/trash-2-CSfX-BvU.js +0 -1
  188. package/dist/assets/use-config-TzwzTgg0.js +0 -1
  189. package/dist/assets/x-fkIWvi07.js +0 -1
  190. package/src/features/chat/components/__tests__/chat-welcome.test.tsx +0 -45
  191. package/src/features/chat/components/chat-welcome.tsx +0 -102
  192. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-bar-controller.test.tsx +0 -152
  193. package/src/features/chat/features/input/hooks/use-chat-input-bar-controller.ts +0 -142
  194. /package/dist/assets/{config-hints-Cl2XAhzV.js → config-hints-CTqBnCDp.js} +0 -0
@@ -0,0 +1,85 @@
1
+ import { create } from 'zustand';
2
+ import { createJSONStorage, persist } from 'zustand/middleware';
3
+
4
+ export type RecentSelectionScope = {
5
+ namespace?: string | null;
6
+ };
7
+
8
+ type RecentSelectionStoreState = {
9
+ entriesByKey: Record<string, string[]>;
10
+ setEntries: (key: string, values: string[]) => void;
11
+ };
12
+
13
+ const RECENT_SELECTION_STORE_KEY = 'nextclaw.chat.recent-selection.store';
14
+
15
+ export function normalizeRecentSelectionValue(value: unknown): string | null {
16
+ if (typeof value !== 'string') {
17
+ return null;
18
+ }
19
+ const normalized = value.trim();
20
+ return normalized.length > 0 ? normalized : null;
21
+ }
22
+
23
+ export function normalizeRecentSelectionList(values: unknown, limit: number): string[] {
24
+ if (!Array.isArray(values)) {
25
+ return [];
26
+ }
27
+ return Array.from(
28
+ new Set(values.map(normalizeRecentSelectionValue).filter((value): value is string => Boolean(value))),
29
+ ).slice(0, limit);
30
+ }
31
+
32
+ export function resolveRecentSelectionKey(
33
+ options: { storageKey: string },
34
+ scope?: RecentSelectionScope,
35
+ ): string {
36
+ const namespace = normalizeRecentSelectionValue(scope?.namespace);
37
+ return namespace
38
+ ? `${options.storageKey}.${encodeURIComponent(namespace.toLowerCase())}`
39
+ : options.storageKey;
40
+ }
41
+
42
+ function normalizePersistedEntries(value: unknown): Record<string, string[]> {
43
+ if (!value || typeof value !== 'object') {
44
+ return {};
45
+ }
46
+ const entries: Record<string, string[]> = {};
47
+ for (const [key, values] of Object.entries(value)) {
48
+ const normalizedKey = normalizeRecentSelectionValue(key);
49
+ if (!normalizedKey) {
50
+ continue;
51
+ }
52
+ const normalizedValues = normalizeRecentSelectionList(values, Number.POSITIVE_INFINITY);
53
+ if (normalizedValues.length > 0) {
54
+ entries[normalizedKey] = normalizedValues;
55
+ }
56
+ }
57
+ return entries;
58
+ }
59
+
60
+ export const useRecentSelectionStore = create<RecentSelectionStoreState>()(
61
+ persist(
62
+ (set) => ({
63
+ entriesByKey: {},
64
+ setEntries: (key, values) => set((state) => ({
65
+ entriesByKey: {
66
+ ...state.entriesByKey,
67
+ [key]: normalizeRecentSelectionList(values, Number.POSITIVE_INFINITY),
68
+ },
69
+ })),
70
+ }),
71
+ {
72
+ name: RECENT_SELECTION_STORE_KEY,
73
+ storage: createJSONStorage(() => window.localStorage),
74
+ partialize: (state) => ({
75
+ entriesByKey: state.entriesByKey,
76
+ }),
77
+ merge: (persistedState, currentState) => ({
78
+ ...currentState,
79
+ entriesByKey: normalizePersistedEntries(
80
+ (persistedState as { entriesByKey?: Record<string, unknown> } | undefined)?.entriesByKey,
81
+ ),
82
+ }),
83
+ },
84
+ ),
85
+ );
@@ -37,7 +37,11 @@ export type ChatInputBarAdapterTexts = {
37
37
  modelNoOptionsLabel: string;
38
38
  recentModelsLabel: string;
39
39
  allModelsLabel: string;
40
- sessionTypePlaceholder: string;
40
+ favoriteModelsLabel: string;
41
+ modelSearchPlaceholder: string;
42
+ modelSearchEmptyLabel: string;
43
+ favoriteModelLabel: string;
44
+ unfavoriteModelLabel: string;
41
45
  thinkingLabels: Record<ChatThinkingLevel, string>;
42
46
  noModelOptionsLabel: string;
43
47
  configureProviderLabel: string;
@@ -16,29 +16,30 @@ function buildInstalledRecordFromInstall(params: {
16
16
  request: MarketplaceInstallRequest;
17
17
  result: MarketplaceInstallResult;
18
18
  }): MarketplaceInstalledRecord {
19
+ const { request, result } = params;
19
20
  const installedAt = new Date().toISOString();
20
21
 
21
- if (params.result.type === 'skill') {
22
+ if (result.type === 'skill') {
22
23
  return {
23
24
  type: 'skill',
24
- spec: params.result.spec,
25
- id: params.request.skill ?? params.result.spec,
26
- label: params.request.skill ?? params.result.name ?? params.result.spec,
25
+ spec: result.spec,
26
+ id: request.skill ?? result.spec,
27
+ label: request.skill ?? result.name ?? result.spec,
27
28
  source: 'workspace',
28
- installPath: params.request.installPath,
29
+ installPath: request.installPath,
29
30
  installedAt
30
31
  };
31
32
  }
32
33
 
33
34
  return {
34
- type: params.result.type,
35
- spec: params.result.spec,
36
- id: params.result.name ?? params.result.spec,
37
- label: params.result.name ?? params.result.spec,
35
+ type: result.type,
36
+ spec: result.spec,
37
+ id: result.name ?? result.spec,
38
+ label: result.name ?? result.spec,
38
39
  source: 'marketplace',
39
40
  origin: 'marketplace',
40
- enabled: params.request.enabled ?? true,
41
- runtimeStatus: params.request.enabled === false ? 'disabled' : 'ready',
41
+ enabled: request.enabled ?? true,
42
+ runtimeStatus: request.enabled === false ? 'disabled' : 'ready',
42
43
  installedAt
43
44
  };
44
45
  }
@@ -47,10 +48,11 @@ function matchesInstalledRecord(record: MarketplaceInstalledRecord, params: {
47
48
  id?: string;
48
49
  spec?: string;
49
50
  }): boolean {
50
- if (params.spec && record.spec === params.spec) {
51
+ const { id, spec } = params;
52
+ if (spec && record.spec === spec) {
51
53
  return true;
52
54
  }
53
- if (params.id && record.id === params.id) {
55
+ if (id && record.id === id) {
54
56
  return true;
55
57
  }
56
58
  return false;
@@ -70,7 +72,8 @@ export function applyInstallResultToInstalledView(params: {
70
72
  request: MarketplaceInstallRequest;
71
73
  result: MarketplaceInstallResult;
72
74
  }): MarketplaceInstalledView {
73
- const current = ensureInstalledView(params.result.type, params.view);
75
+ const { result, view } = params;
76
+ const current = ensureInstalledView(result.type, view);
74
77
  const optimisticRecord = buildInstalledRecordFromInstall(params);
75
78
  const existingIndex = current.records.findIndex((record) => matchesInstalledRecord(record, {
76
79
  id: optimisticRecord.id,
@@ -89,7 +92,7 @@ export function applyInstallResultToInstalledView(params: {
89
92
 
90
93
  return {
91
94
  ...current,
92
- type: params.result.type,
95
+ type: result.type,
93
96
  records: nextRecords,
94
97
  specs: dedupeSpecs(nextRecords),
95
98
  total: nextRecords.length
@@ -101,12 +104,13 @@ export function applyManageResultToInstalledView(params: {
101
104
  request: MarketplaceManageRequest;
102
105
  result: MarketplaceManageResult;
103
106
  }): MarketplaceInstalledView {
104
- const current = ensureInstalledView(params.result.type, params.view);
107
+ const { request, result, view } = params;
108
+ const current = ensureInstalledView(result.type, view);
105
109
 
106
- if (params.result.action === 'uninstall' || params.result.action === 'remove') {
110
+ if (result.action === 'uninstall' || result.action === 'remove') {
107
111
  const nextRecords = current.records.filter((record) => !matchesInstalledRecord(record, {
108
- id: params.result.id,
109
- spec: params.request.spec
112
+ id: result.id,
113
+ spec: request.spec
110
114
  }));
111
115
 
112
116
  return {
@@ -119,13 +123,13 @@ export function applyManageResultToInstalledView(params: {
119
123
 
120
124
  const nextRecords = current.records.map((record) => {
121
125
  if (!matchesInstalledRecord(record, {
122
- id: params.result.id,
123
- spec: params.request.spec
126
+ id: result.id,
127
+ spec: request.spec
124
128
  })) {
125
129
  return record;
126
130
  }
127
131
 
128
- if (params.result.action === 'disable') {
132
+ if (result.action === 'disable') {
129
133
  return {
130
134
  ...record,
131
135
  enabled: false,
@@ -133,7 +137,7 @@ export function applyManageResultToInstalledView(params: {
133
137
  };
134
138
  }
135
139
 
136
- if (params.result.action === 'update') {
140
+ if (result.action === 'update') {
137
141
  return {
138
142
  ...record,
139
143
  installedAt: new Date().toISOString(),
@@ -0,0 +1,137 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { PanelAppsList } from '@/features/panel-apps/components/panel-apps-list';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ entries: [] as Array<{
8
+ appId: string;
9
+ clientDeclared: boolean;
10
+ clientGranted: boolean;
11
+ contentPath: string;
12
+ createdAt: string;
13
+ description?: string;
14
+ favorite: boolean;
15
+ fileName: string;
16
+ id: string;
17
+ kind: 'single-file';
18
+ openCount: number;
19
+ sizeBytes: number;
20
+ title: string;
21
+ updatedAt: string;
22
+ }>,
23
+ navigate: vi.fn(),
24
+ refetchPanelApps: vi.fn(),
25
+ requestDraft: vi.fn(),
26
+ }));
27
+
28
+ vi.mock('@/app/components/app-presenter-provider', () => ({
29
+ useAppPresenter: () => ({
30
+ chatDraftIntentManager: {
31
+ requestDraft: mocks.requestDraft,
32
+ },
33
+ serviceActionAuthorizationManager: {
34
+ requestAuthorization: vi.fn(async () => true),
35
+ },
36
+ }),
37
+ }));
38
+
39
+ vi.mock('react-router-dom', async () => ({
40
+ ...(await vi.importActual('react-router-dom') as object),
41
+ useNavigate: () => mocks.navigate,
42
+ }));
43
+
44
+ vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
45
+ useDeletePanelApp: () => ({
46
+ isPending: false,
47
+ mutate: vi.fn(),
48
+ }),
49
+ useGrantPanelAppClient: () => ({
50
+ mutateAsync: vi.fn(),
51
+ }),
52
+ usePanelApps: () => ({
53
+ data: {
54
+ entries: mocks.entries,
55
+ panelsPath: '/workspace/panels',
56
+ workspacePath: '/workspace',
57
+ },
58
+ isError: false,
59
+ isLoading: false,
60
+ refetch: mocks.refetchPanelApps,
61
+ }),
62
+ useRecordPanelAppOpened: () => ({
63
+ mutateAsync: vi.fn(async (id: string) => mocks.entries.find((entry) => entry.id === id)),
64
+ }),
65
+ useUpdatePanelAppPreferences: () => ({
66
+ isPending: false,
67
+ mutate: vi.fn(),
68
+ }),
69
+ }));
70
+
71
+ function createPanelAppEntry(overrides: Partial<(typeof mocks.entries)[number]> = {}) {
72
+ return {
73
+ appId: 'demo',
74
+ clientDeclared: false,
75
+ clientGranted: false,
76
+ contentPath: '/api/panel-apps/demo/content',
77
+ createdAt: '2026-05-28T08:00:00.000Z',
78
+ description: 'A compact demo panel.',
79
+ favorite: false,
80
+ fileName: 'demo.panel.html',
81
+ id: 'demo',
82
+ kind: 'single-file' as const,
83
+ openCount: 0,
84
+ sizeBytes: 12,
85
+ title: 'Demo Panel',
86
+ updatedAt: '2026-05-28T09:00:00.000Z',
87
+ ...overrides,
88
+ };
89
+ }
90
+
91
+ describe('PanelAppsList', () => {
92
+ beforeEach(() => {
93
+ mocks.entries = [];
94
+ mocks.navigate.mockReset();
95
+ mocks.refetchPanelApps.mockReset();
96
+ mocks.requestDraft.mockReset();
97
+ });
98
+
99
+ it('shows first-use guidance when no panel apps exist', async () => {
100
+ const user = userEvent.setup();
101
+
102
+ render(<PanelAppsList onOpenPanelApp={vi.fn()} />);
103
+
104
+ expect(screen.getByText('Create your first panel app')).toBeTruthy();
105
+ expect(screen.getByText('Ask NextClaw to generate one')).toBeTruthy();
106
+ expect(screen.getByText('/workspace/panels')).toBeTruthy();
107
+
108
+ await user.click(screen.getAllByRole('button', { name: 'Refresh panel apps' }).at(-1)!);
109
+
110
+ expect(mocks.refetchPanelApps).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it('fills a sample panel app prompt from first-use guidance', async () => {
114
+ const user = userEvent.setup();
115
+
116
+ render(<PanelAppsList onOpenPanelApp={vi.fn()} />);
117
+
118
+ await user.click(screen.getByRole('button', { name: /Create sample panel app/ }));
119
+
120
+ expect(mocks.requestDraft).toHaveBeenCalledWith(expect.stringContaining('personal task board'));
121
+ expect(mocks.navigate).toHaveBeenCalledWith('/chat');
122
+ });
123
+
124
+ it('shows a filtered empty state instead of first-use guidance when apps exist', async () => {
125
+ const user = userEvent.setup();
126
+ mocks.entries = [createPanelAppEntry()];
127
+
128
+ render(<PanelAppsList onOpenPanelApp={vi.fn()} />);
129
+
130
+ expect(screen.getByText('Demo Panel')).toBeTruthy();
131
+
132
+ await user.click(screen.getByRole('button', { name: 'Favorites' }));
133
+
134
+ expect(screen.getByText('No panel apps in this view')).toBeTruthy();
135
+ expect(screen.queryByText('Create your first panel app')).toBeNull();
136
+ });
137
+ });
@@ -1,5 +1,6 @@
1
1
  import { useMemo, useState, type ReactNode } from 'react';
2
- import { HelpCircle, RefreshCw } from 'lucide-react';
2
+ import { AppWindow, FileCode2, HelpCircle, MessageSquarePlus, RefreshCw } from 'lucide-react';
3
+ import { useNavigate } from 'react-router-dom';
3
4
  import { useAppPresenter } from '@/app/components/app-presenter-provider';
4
5
  import { PanelAppListItem } from '@/features/panel-apps/components/panel-app-list-item';
5
6
  import { useDeletePanelApp, useGrantPanelAppClient, usePanelApps, useRecordPanelAppOpened, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
@@ -10,6 +11,8 @@ import { Tabs, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
10
11
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/shared/components/ui/tooltip';
11
12
  import { t } from '@/shared/lib/i18n';
12
13
 
14
+ const EMPTY_PANEL_APP_ENTRIES: PanelAppEntryView[] = [];
15
+
13
16
  export function PanelAppsList({
14
17
  headerContent,
15
18
  onOpenPanelApp,
@@ -23,10 +26,12 @@ export function PanelAppsList({
23
26
  const recordOpened = useRecordPanelAppOpened();
24
27
  const grantClient = useGrantPanelAppClient();
25
28
  const presenter = useAppPresenter();
29
+ const navigate = useNavigate();
26
30
  const [viewMode, setViewMode] = useState<PanelAppViewMode>('smart');
31
+ const allEntries = panelApps.data?.entries ?? EMPTY_PANEL_APP_ENTRIES;
27
32
  const entries = useMemo(
28
- () => getPanelAppViewEntries(panelApps.data?.entries ?? [], viewMode),
29
- [panelApps.data?.entries, viewMode],
33
+ () => getPanelAppViewEntries(allEntries, viewMode),
34
+ [allEntries, viewMode],
30
35
  );
31
36
 
32
37
  const openPanelApp = async (entry: PanelAppEntryView) => {
@@ -71,6 +76,11 @@ export function PanelAppsList({
71
76
  deletePanelApp.mutate(entry.id);
72
77
  };
73
78
 
79
+ const startExamplePanelAppDraft = () => {
80
+ navigate('/chat');
81
+ presenter.chatDraftIntentManager.requestDraft(t('panelAppsExamplePrompt'));
82
+ };
83
+
74
84
  if (panelApps.isLoading) {
75
85
  return (
76
86
  <div className="flex h-full items-center justify-center text-sm text-gray-500">{t('panelAppsLoading')}</div>
@@ -122,8 +132,14 @@ export function PanelAppsList({
122
132
  </TabsList>
123
133
  </Tabs>
124
134
  </div>
125
- {entries.length === 0 ? (
126
- <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-gray-500">{t('panelAppsEmpty')}</div>
135
+ {allEntries.length === 0 ? (
136
+ <PanelAppsEmptyGuide
137
+ panelsPath={panelApps.data?.panelsPath}
138
+ onCreateExample={startExamplePanelAppDraft}
139
+ onRefresh={() => void panelApps.refetch()}
140
+ />
141
+ ) : entries.length === 0 ? (
142
+ <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-gray-500">{t('panelAppsEmptyFiltered')}</div>
127
143
  ) : (
128
144
  <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
129
145
  <div className="space-y-1.5">
@@ -144,3 +160,101 @@ export function PanelAppsList({
144
160
  </div>
145
161
  );
146
162
  }
163
+
164
+ function PanelAppsEmptyGuide({
165
+ onCreateExample,
166
+ panelsPath,
167
+ onRefresh,
168
+ }: {
169
+ onCreateExample: () => void;
170
+ panelsPath?: string;
171
+ onRefresh: () => void;
172
+ }) {
173
+ return (
174
+ <div className="flex flex-1 items-center justify-center overflow-y-auto px-5 py-6 text-center">
175
+ <div className="w-full max-w-sm">
176
+ <div className="mx-auto flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
177
+ <AppWindow className="h-5 w-5" />
178
+ </div>
179
+ <h2 className="mt-3 text-sm font-semibold text-gray-900">{t('panelAppsEmptyTitle')}</h2>
180
+ <p className="mt-1 text-xs leading-5 text-gray-500">{t('panelAppsEmptyDescription')}</p>
181
+
182
+ <div className="mt-4 space-y-2 text-left">
183
+ <PanelAppsEmptyGuideStep
184
+ icon={<MessageSquarePlus className="h-4 w-4" />}
185
+ title={t('panelAppsEmptyAskTitle')}
186
+ description={t('panelAppsEmptyAskDescription')}
187
+ actionLabel={t('panelAppsEmptyAskAction')}
188
+ onAction={onCreateExample}
189
+ />
190
+ <PanelAppsEmptyGuideStep
191
+ icon={<FileCode2 className="h-4 w-4" />}
192
+ title={t('panelAppsEmptyFileTitle')}
193
+ description={t('panelAppsEmptyFileDescription')}
194
+ />
195
+ </div>
196
+
197
+ {panelsPath ? (
198
+ <div className="mt-4 rounded-md bg-gray-50 px-3 py-2 text-left">
199
+ <div className="text-[11px] font-medium uppercase text-gray-400">{t('panelAppsPanelsPath')}</div>
200
+ <code className="mt-1 block break-all text-xs text-gray-600">{panelsPath}</code>
201
+ </div>
202
+ ) : null}
203
+
204
+ <button
205
+ type="button"
206
+ onClick={onRefresh}
207
+ className="mt-4 inline-flex items-center justify-center gap-1.5 rounded-md border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
208
+ >
209
+ <RefreshCw className="h-3.5 w-3.5" />
210
+ {t('panelAppsRefresh')}
211
+ </button>
212
+ </div>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ function PanelAppsEmptyGuideStep({
218
+ actionLabel,
219
+ description,
220
+ icon,
221
+ onAction,
222
+ title,
223
+ }: {
224
+ actionLabel?: string;
225
+ description: string;
226
+ icon: ReactNode;
227
+ onAction?: () => void;
228
+ title: string;
229
+ }) {
230
+ const content = (
231
+ <>
232
+ <div className="mt-0.5 text-gray-500">{icon}</div>
233
+ <div className="min-w-0">
234
+ <div className="text-xs font-medium text-gray-800">{title}</div>
235
+ <div className="mt-0.5 text-xs leading-5 text-gray-500">{description}</div>
236
+ {actionLabel ? (
237
+ <div className="mt-2 text-xs font-medium text-primary">{actionLabel}</div>
238
+ ) : null}
239
+ </div>
240
+ </>
241
+ );
242
+
243
+ if (onAction) {
244
+ return (
245
+ <button
246
+ type="button"
247
+ onClick={onAction}
248
+ className="flex w-full gap-2 rounded-md bg-gray-50 px-3 py-2.5 text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
249
+ >
250
+ {content}
251
+ </button>
252
+ );
253
+ }
254
+
255
+ return (
256
+ <div className="flex gap-2 rounded-md bg-gray-50 px-3 py-2.5">
257
+ {content}
258
+ </div>
259
+ );
260
+ }
@@ -1,5 +1,7 @@
1
1
  export { PanelAppsList } from './components/panel-apps-list';
2
+ export { usePanelApps } from './hooks/use-panel-apps';
2
3
  export { PanelAppBridgeManager } from './managers/panel-app-bridge.manager';
4
+ export { findPanelAppEntryByDisplayId } from './utils/panel-app-entry-match.utils';
3
5
  export {
4
6
  openApps,
5
7
  PANEL_APPS_DOC_BROWSER_RENDERERS,
@@ -0,0 +1,17 @@
1
+ import type { PanelAppEntryView } from '@/shared/lib/api';
2
+
3
+ function isPanelAppEntryMatch(entry: PanelAppEntryView, value: string): boolean {
4
+ const normalizedValue = value.trim();
5
+ return [entry.id, entry.appId, entry.fileName, entry.title].some((candidate) => candidate.trim() === normalizedValue);
6
+ }
7
+
8
+ export function findPanelAppEntryByDisplayId(
9
+ entries: readonly PanelAppEntryView[],
10
+ value: string,
11
+ ): PanelAppEntryView | null {
12
+ const normalizedValue = value.trim();
13
+ if (!normalizedValue) {
14
+ return null;
15
+ }
16
+ return entries.find((entry) => isPanelAppEntryMatch(entry, normalizedValue)) ?? null;
17
+ }