@nextclaw/ui 0.12.23 → 0.12.25

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 (215) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/assets/api-DGD9_Bg4.js +15 -0
  3. package/dist/assets/app-manager-provider-oYdeYPSv.js +1 -0
  4. package/dist/assets/{book-open-DDlN5MvX.js → book-open-BcnAiKde.js} +1 -1
  5. package/dist/assets/channels-list-page-FJDuPwU6.js +8 -0
  6. package/dist/assets/chat-page-D1fMNBrT.js +1 -0
  7. package/dist/assets/config-split-page-CcrEUtwu.js +1 -0
  8. package/dist/assets/cpu-DPPwMzoC.js +3 -0
  9. package/dist/assets/{createLucideIcon-BLMK3QUd.js → createLucideIcon-DzY6wN61.js} +1 -1
  10. package/dist/assets/desktop-kk7qvZ-v.js +3 -0
  11. package/dist/assets/desktop-update-config-CP8dFYXK.js +1 -0
  12. package/dist/assets/{dialog-dxsKz7jJ.js → dialog-BKo0RItd.js} +1 -1
  13. package/dist/assets/{dist-DsYTOyq7.js → dist-CFiwgaLs.js} +1 -1
  14. package/dist/assets/doc-browser-CAhfnm0D.js +1 -0
  15. package/dist/assets/{doc-browser-context-BJuMaI3o.js → doc-browser-context-FukQHvyo.js} +1 -1
  16. package/dist/assets/doc-browser-p9DDNPWB.js +1 -0
  17. package/dist/assets/doc-browser-rZIQIjuw.js +1 -0
  18. package/dist/assets/download-CMM8po31.js +1 -0
  19. package/dist/assets/{es2015-V75WQJ2s.js → es2015-BhznEEyJ.js} +1 -1
  20. package/dist/assets/{external-link-DwfSfTLB.js → external-link-CpEvG65F.js} +1 -1
  21. package/dist/assets/i18n-D1144VAA.js +1 -0
  22. package/dist/assets/index-D-AAMKCt.js +103 -0
  23. package/dist/assets/index-DnBeV2Xm.css +1 -0
  24. package/dist/assets/{key-round-CJ5gDAAG.js → key-round-DUq47t0P.js} +1 -1
  25. package/dist/assets/marketplace-page-BrCLRIc4.js +105 -0
  26. package/dist/assets/marketplace-page-odDpPYEs.js +1 -0
  27. package/dist/assets/mcp-marketplace-page-CfbOBgKK.js +1 -0
  28. package/dist/assets/mcp-marketplace-page-DIq_SpMe.js +40 -0
  29. package/dist/assets/model-config-Bc6VVnxy.js +1 -0
  30. package/dist/assets/{notice-card-D1RNsTn_.js → notice-card-Dr6xCwva.js} +1 -1
  31. package/dist/assets/play-AqrNslHI.js +1 -0
  32. package/dist/assets/plus-B-YHtTNC.js +1 -0
  33. package/dist/assets/{popover-BMyiifTA.js → popover-BDFNiLlg.js} +1 -1
  34. package/dist/assets/provider-scoped-model-input-BMTp4BEH.js +1 -0
  35. package/dist/assets/providers-list-DN0tvISH.js +1 -0
  36. package/dist/assets/refresh-cw-CrbD8EkT.js +1 -0
  37. package/dist/assets/remote-Dr3jcfWP.js +1 -0
  38. package/dist/assets/{rotate-cw-BZ2JObNs.js → rotate-cw-BN9yjccP.js} +1 -1
  39. package/dist/assets/runtime-config-page-CRWOwBbl.js +1 -0
  40. package/dist/assets/{save-euRxl8pI.js → save-CO_4qf6b.js} +1 -1
  41. package/dist/assets/{search-CLd7m0M7.js → search-CRtQwr-h.js} +1 -1
  42. package/dist/assets/search-config-C4c1yZSP.js +1 -0
  43. package/dist/assets/secrets-config-zAF30YfO.js +3 -0
  44. package/dist/assets/{select-DTdzR8j8.js → select-BUTwE_lC.js} +1 -1
  45. package/dist/assets/{setting-row-CvKngoNI.js → setting-row-BavcnXw1.js} +1 -1
  46. package/dist/assets/settings-MWL2SMyk.js +1 -0
  47. package/dist/assets/{sparkles-DVfeSVJQ.js → sparkles-BmgOD4nY.js} +1 -1
  48. package/dist/assets/{status-dot-ChvPCib9.js → status-dot-l3kPFdq_.js} +1 -1
  49. package/dist/assets/{tabs-custom-Hia_ong0.js → tabs-custom-D48zdZoc.js} +1 -1
  50. package/dist/assets/{tag-chip-BywQeHJj.js → tag-chip-Dm2Lqnpu.js} +1 -1
  51. package/dist/assets/use-config-Cyv5IuSt.js +1 -0
  52. package/dist/assets/use-infinite-scroll-loader-Cvz8ZteY.js +1 -0
  53. package/dist/assets/x-BeyYA_h6.js +1 -0
  54. package/dist/index.html +29 -40
  55. package/package.json +9 -9
  56. package/src/app/components/layout/sidebar.layout.test.tsx +2 -4
  57. package/src/app/components/theme-provider.tsx +1 -0
  58. package/src/app/configs/app-navigation.config.ts +0 -6
  59. package/src/app/index.tsx +4 -7
  60. package/src/features/agents/components/agents-page.test.tsx +25 -15
  61. package/src/features/agents/components/agents-page.tsx +133 -172
  62. package/src/features/channels/components/config/channel-form.test.tsx +1 -0
  63. package/src/features/channels/components/config/channel-form.tsx +4 -3
  64. package/src/features/channels/components/config/weixin-channel-auth-section.test.tsx +38 -1
  65. package/src/features/channels/components/config/weixin-channel-auth-section.tsx +137 -40
  66. package/src/features/channels/index.ts +1 -1
  67. package/src/features/channels/utils/channel-form-fields.utils.test.ts +26 -0
  68. package/src/features/channels/utils/channel-form-fields.utils.ts +32 -18
  69. package/src/features/chat/components/chat-session-workspace-panel-nav.tsx +23 -4
  70. package/src/features/chat/components/chat-session-workspace-panel.tsx +34 -2
  71. package/src/features/chat/components/chat-sidebar-session-item.tsx +9 -3
  72. package/src/features/chat/components/conversation/chat-conversation-header.test.tsx +71 -0
  73. package/src/features/chat/components/conversation/chat-conversation-header.tsx +6 -0
  74. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +181 -61
  75. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +56 -25
  76. package/src/features/chat/components/conversation/session-header/chat-session-header-actions.test.tsx +24 -0
  77. package/src/features/chat/components/conversation/session-header/chat-session-header-actions.tsx +26 -5
  78. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +174 -0
  79. package/src/features/chat/components/layout/chat-sidebar.test.tsx +119 -8
  80. package/src/features/chat/components/layout/chat-sidebar.tsx +57 -75
  81. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  82. package/src/features/chat/components/workspace/session-cron-job-content.tsx +103 -0
  83. package/src/features/chat/hooks/use-hydrated-ncp-agent.test.tsx +6 -0
  84. package/src/features/chat/hooks/use-ncp-agent-runtime.test.tsx +172 -69
  85. package/src/features/chat/hooks/use-ncp-chat-derived-state.ts +2 -2
  86. package/src/features/chat/hooks/use-ncp-chat-page-data.test.tsx +70 -0
  87. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +7 -7
  88. package/src/features/chat/hooks/use-ncp-child-session-tabs-view.ts +2 -8
  89. package/src/features/chat/hooks/use-ncp-session-conversation.test.tsx +10 -0
  90. package/src/features/chat/hooks/use-ncp-session-conversation.ts +2 -1
  91. package/src/features/chat/hooks/use-ncp-session-list-view.ts +1 -2
  92. package/src/features/chat/hooks/use-selected-session-context-window-indicator.ts +2 -4
  93. package/src/features/chat/managers/chat-session-list.manager.test.ts +21 -20
  94. package/src/features/chat/managers/chat-session-list.manager.ts +15 -24
  95. package/src/features/chat/managers/ncp-chat-input.manager.test.ts +22 -13
  96. package/src/features/chat/managers/ncp-chat-input.manager.ts +4 -2
  97. package/src/features/chat/managers/ncp-chat-presenter.manager.ts +6 -0
  98. package/src/features/chat/managers/ncp-chat-thread.manager.test.ts +52 -1
  99. package/src/features/chat/managers/ncp-chat-thread.manager.ts +21 -0
  100. package/src/features/chat/pages/ncp-chat-page.tsx +28 -17
  101. package/src/features/chat/stores/chat-session-list.store.ts +0 -3
  102. package/src/features/chat/stores/chat-thread.store.ts +4 -0
  103. package/src/features/chat/types/chat-stream.types.ts +1 -1
  104. package/src/features/chat/utils/chat-session-display.utils.test.ts +83 -1
  105. package/src/features/chat/utils/chat-session-display.utils.ts +73 -0
  106. package/src/features/chat/utils/ncp-session-adapter.utils.test.ts +22 -0
  107. package/src/features/chat/utils/ncp-session-adapter.utils.ts +33 -1
  108. package/src/features/marketplace/components/curated-shelves/marketplace-curated-scene-route.test.tsx +235 -0
  109. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.config.ts +162 -0
  110. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.tsx +355 -0
  111. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +118 -0
  112. package/src/features/marketplace/components/detail-doc/marketplace-detail-doc-renderer.ts +201 -0
  113. package/src/features/marketplace/components/detail-doc/marketplace-detail-doc.test.ts +40 -0
  114. package/src/features/marketplace/components/marketplace-catalog-grid.tsx +114 -0
  115. package/src/features/marketplace/components/marketplace-detail-doc.ts +73 -24
  116. package/src/features/marketplace/components/marketplace-item-icon.tsx +45 -0
  117. package/src/features/marketplace/components/marketplace-list-card.tsx +177 -93
  118. package/src/features/marketplace/components/marketplace-page-detail.test.tsx +9 -2
  119. package/src/features/marketplace/components/marketplace-page-parts.tsx +1 -1
  120. package/src/features/marketplace/components/marketplace-page.test.tsx +25 -6
  121. package/src/features/marketplace/components/marketplace-page.tsx +154 -132
  122. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +97 -0
  123. package/src/features/marketplace/hooks/use-marketplace.ts +59 -3
  124. package/src/features/system-status/components/config/runtime-agent-list-card.tsx +4 -8
  125. package/src/features/system-status/components/config/runtime-binding-list-card.tsx +5 -7
  126. package/src/features/system-status/components/config/runtime-config-editor.tsx +1 -19
  127. package/src/features/system-status/components/config/runtime-entry-list-card.tsx +10 -11
  128. package/src/features/system-status/components/config/runtime-settings-card.tsx +15 -23
  129. package/src/features/system-status/components/runtime-control-card.test.tsx +8 -6
  130. package/src/features/system-status/components/runtime-control-card.tsx +7 -6
  131. package/src/features/system-status/pages/runtime-config-page.test.tsx +19 -9
  132. package/src/features/system-status/pages/runtime-config-page.tsx +2 -3
  133. package/src/features/system-status/utils/runtime-config-agent.utils.ts +4 -4
  134. package/src/features/system-status/utils/system-status.utils.ts +31 -6
  135. package/src/index.css +8 -0
  136. package/src/platforms/desktop/components/desktop-app-shell.test.tsx +67 -0
  137. package/src/platforms/desktop/components/desktop-app-shell.tsx +46 -18
  138. package/src/platforms/desktop/components/desktop-window-chrome.tsx +30 -0
  139. package/src/platforms/desktop/index.ts +6 -0
  140. package/src/platforms/desktop/types/desktop-update.types.ts +3 -0
  141. package/src/platforms/desktop/utils/desktop-host.utils.ts +56 -0
  142. package/src/shared/components/common/brand-header.tsx +36 -16
  143. package/src/shared/components/config/provider-form-support.ts +2 -22
  144. package/src/shared/components/cron-config.tsx +12 -58
  145. package/src/shared/components/doc-browser/doc-browser.tsx +4 -4
  146. package/src/shared/components/ui/select.tsx +19 -7
  147. package/src/shared/lib/api/channel-auth.types.ts +1 -0
  148. package/src/shared/lib/api/ncp-session-query-cache.test.ts +26 -1
  149. package/src/shared/lib/api/ncp-session-query-cache.ts +5 -1
  150. package/src/shared/lib/api/ncp-session.types.ts +9 -0
  151. package/src/shared/lib/api/types.ts +12 -1
  152. package/src/shared/lib/api/utils/marketplace.utils.ts +7 -1
  153. package/src/shared/lib/cron/cron-job-view.utils.ts +59 -0
  154. package/src/shared/lib/cron/index.ts +1 -0
  155. package/src/shared/lib/i18n/{channel-auth.ts → channel-auth.constants.ts} +31 -0
  156. package/src/shared/lib/i18n/chat-labels.utils.ts +3 -2
  157. package/src/shared/lib/i18n/index.ts +20 -59
  158. package/src/shared/lib/i18n/{runtime-control.ts → runtime-control-labels.utils.ts} +30 -1
  159. package/src/shared/lib/provider-models/index.test.ts +39 -0
  160. package/src/shared/lib/provider-models/index.ts +1 -3
  161. package/src/shared/lib/ui-document-title/index.ts +0 -1
  162. package/tsconfig.json +1 -0
  163. package/vite.config.ts +1 -1
  164. package/vitest.config.ts +1 -1
  165. package/dist/assets/api-BGd3rgv_.js +0 -15
  166. package/dist/assets/app-manager-provider-BuJ_U9eC.js +0 -1
  167. package/dist/assets/app-navigation.config-BTdUuqXS.js +0 -1
  168. package/dist/assets/channels-list-page-BrwymXPe.js +0 -8
  169. package/dist/assets/chat-DGM6K3Qs.js +0 -61
  170. package/dist/assets/chat-page-DpmXMWNS.js +0 -1
  171. package/dist/assets/chunk-JZWAC4HX-Kydj4yEz.js +0 -3
  172. package/dist/assets/config-split-page-DIOCjj2Q.js +0 -1
  173. package/dist/assets/desktop-update-config-BGKiqc6q.js +0 -1
  174. package/dist/assets/doc-browser-C8FM5fC0.js +0 -1
  175. package/dist/assets/doc-browser-RJUOL_GO.js +0 -1
  176. package/dist/assets/doc-browser-p82AdNO-.js +0 -1
  177. package/dist/assets/folder-CeJKPx5P.js +0 -1
  178. package/dist/assets/hash-BqxRTZW5.js +0 -1
  179. package/dist/assets/i18n-DnTGDIRw.js +0 -1
  180. package/dist/assets/index-BrEdR78s.js +0 -2
  181. package/dist/assets/index-D8MKmXtO.css +0 -1
  182. package/dist/assets/loader-circle-fd-vQKtW.js +0 -1
  183. package/dist/assets/logo-badge-KAe-7d8c.js +0 -1
  184. package/dist/assets/logos-C4sYP1Vl.js +0 -1
  185. package/dist/assets/marketplace-page-B2Pm2RDJ.js +0 -1
  186. package/dist/assets/marketplace-page-CPHxlYL8.js +0 -49
  187. package/dist/assets/mcp-marketplace-page-BcjVmw36.js +0 -1
  188. package/dist/assets/mcp-marketplace-page-CswPXSjf.js +0 -40
  189. package/dist/assets/message-square-z_osm9c0.js +0 -1
  190. package/dist/assets/model-config-Cmruiqdx.js +0 -1
  191. package/dist/assets/play-Dv6Nr1Ew.js +0 -1
  192. package/dist/assets/plus-D8eKFY7h.js +0 -1
  193. package/dist/assets/provider-scoped-model-input-D7ACiMAO.js +0 -1
  194. package/dist/assets/providers-list-gg7LrfuB.js +0 -1
  195. package/dist/assets/refresh-ccw-ByVwmnN_.js +0 -1
  196. package/dist/assets/refresh-cw-PcqoYB3K.js +0 -1
  197. package/dist/assets/remote-Db2M39Cv.js +0 -1
  198. package/dist/assets/runtime-config-page-BT_VV41p.js +0 -1
  199. package/dist/assets/search-config-0VTPpz-w.js +0 -1
  200. package/dist/assets/secrets-config-DwQbLLEy.js +0 -3
  201. package/dist/assets/sessions-config-page-CAG7Zevv.js +0 -2
  202. package/dist/assets/settings-drbWqzA4.js +0 -1
  203. package/dist/assets/skeleton-BK1SOSRA.js +0 -1
  204. package/dist/assets/theme-provider-COAwWFv8.js +0 -2
  205. package/dist/assets/tooltip-BOYp8Ue7.js +0 -1
  206. package/dist/assets/trash-2-CBsHCfqq.js +0 -1
  207. package/dist/assets/use-config-DTwhNDQE.js +0 -1
  208. package/dist/assets/use-confirm-dialog-oeSqhmrx.js +0 -1
  209. package/dist/assets/use-infinite-scroll-loader-X3KGuME8.js +0 -1
  210. package/dist/assets/use-viewport-layout-C0NJAVXs.js +0 -1
  211. package/dist/assets/x-CM-XDMpk.js +0 -1
  212. package/src/features/chat/components/config/sessions-config-detail-pane.tsx +0 -244
  213. package/src/features/chat/pages/sessions-config-page.test.tsx +0 -152
  214. package/src/features/chat/pages/sessions-config-page.tsx +0 -192
  215. /package/dist/assets/{config-hints-MogHYQ8G.js → config-hints-BNfpOL4J.js} +0 -0
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
2
2
  import type { SessionEntryView } from '@/shared/lib/api';
3
3
  import { BrandHeader } from '@/shared/components/common/brand-header';
4
4
  import { StatusBadge } from '@/shared/components/common/status-badge';
5
- import { SelectItem } from '@/shared/components/ui/select';
6
5
  import { ChatSidebarListModeSwitch, ChatSidebarProjectGroups, type ChatSidebarProjectGroup } from '@/features/chat';
7
6
  import { useChatSidebarSessionLabelEditor } from '@/features/chat/hooks/use-chat-sidebar-session-label-editor';
8
7
  import { useNcpSessionListView, type NcpSessionListItemView } from '@/features/chat/hooks/use-ncp-session-list-view';
@@ -14,38 +13,36 @@ import { useAgents } from '@/shared/hooks/use-agents';
14
13
  import { getSessionProjectName } from '@/shared/lib/session-project';
15
14
  import { cn } from '@/shared/lib/utils';
16
15
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/shared/lib/i18n';
17
- import { THEME_OPTIONS, type UiTheme } from '@/shared/lib/theme';
16
+ import { THEME_OPTIONS } from '@/shared/lib/theme';
18
17
  import { useI18n } from '@/app/components/i18n-provider';
19
18
  import { useTheme } from '@/app/components/theme-provider';
20
19
  import { useDocBrowser } from '@/shared/components/doc-browser';
21
- import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/app/components/layout/sidebar-items';
20
+ import { SidebarNavLinkItem } from '@/app/components/layout/sidebar-items';
22
21
  import {
23
22
  AlarmClock,
24
23
  Bot,
25
- BookOpen,
26
24
  BrainCircuit,
27
- Languages,
28
25
  MessageSquareText,
29
- Palette,
30
- Settings
31
26
  } from 'lucide-react';
32
27
  import { ChatSidebarSessionEntry } from '@/features/chat/components/layout/chat-sidebar-session-entry';
33
28
  import {
34
29
  ChatSidebarDesktopToolbar,
35
30
  ChatSidebarMobileToolbar,
36
31
  } from '@/features/chat/components/layout/chat-sidebar-toolbar';
32
+ import { ChatSidebarUtilityMenu } from '@/features/chat/components/layout/chat-sidebar-utility-menu';
33
+ import { isMacDesktopHost, isWindowsDesktopHost } from '@/platforms/desktop';
37
34
 
38
35
  type DateGroup = {
39
36
  label: string;
40
37
  items: NcpSessionListItemView[];
41
38
  };
42
39
 
43
- function getSessionUpdatedAtTimestamp(item: NcpSessionListItemView): number {
44
- return new Date(item.session.updatedAt).getTime();
40
+ function getSessionActivityAtTimestamp(item: NcpSessionListItemView): number {
41
+ return new Date(item.session.lastMessageAt ?? item.session.createdAt).getTime();
45
42
  }
46
43
 
47
- function sortSessionItemsByUpdatedAtDesc(items: NcpSessionListItemView[]): NcpSessionListItemView[] {
48
- return [...items].sort((left, right) => getSessionUpdatedAtTimestamp(right) - getSessionUpdatedAtTimestamp(left));
44
+ function sortSessionItemsByActivityAtDesc(items: NcpSessionListItemView[]): NcpSessionListItemView[] {
45
+ return [...items].sort((left, right) => getSessionActivityAtTimestamp(right) - getSessionActivityAtTimestamp(left));
49
46
  }
50
47
 
51
48
  function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
@@ -60,8 +57,7 @@ function groupSessionsByDate(items: NcpSessionListItemView[]): DateGroup[] {
60
57
  const older: NcpSessionListItemView[] = [];
61
58
 
62
59
  for (const item of items) {
63
- const { session } = item;
64
- const ts = new Date(session.updatedAt).getTime();
60
+ const ts = getSessionActivityAtTimestamp(item);
65
61
  if (ts >= todayStart) {
66
62
  today.push(item);
67
63
  } else if (ts >= yesterdayStart) {
@@ -90,7 +86,7 @@ function groupSessionsByProject(items: NcpSessionListItemView[]): ChatSidebarPro
90
86
  continue;
91
87
  }
92
88
  const existingGroup = grouped.get(projectRoot);
93
- const updatedAt = getSessionUpdatedAtTimestamp(item);
89
+ const updatedAt = getSessionActivityAtTimestamp(item);
94
90
  if (existingGroup) {
95
91
  existingGroup.items.push(item);
96
92
  existingGroup.latestUpdatedAt = Math.max(existingGroup.latestUpdatedAt, updatedAt);
@@ -107,11 +103,28 @@ function groupSessionsByProject(items: NcpSessionListItemView[]): ChatSidebarPro
107
103
  return [...grouped.values()]
108
104
  .map((group) => ({
109
105
  ...group,
110
- items: sortSessionItemsByUpdatedAtDesc(group.items)
106
+ items: sortSessionItemsByActivityAtDesc(group.items)
111
107
  }))
112
108
  .sort((left, right) => right.latestUpdatedAt - left.latestUpdatedAt);
113
109
  }
114
110
 
111
+ function groupChildSessionsByParentKey(items: NcpSessionListItemView[]): Map<string, NcpSessionListItemView[]> {
112
+ const grouped = new Map<string, NcpSessionListItemView[]>();
113
+ for (const item of items) {
114
+ const parentSessionKey = item.session.parentSessionId?.trim();
115
+ if (!parentSessionKey) {
116
+ continue;
117
+ }
118
+ const bucket = grouped.get(parentSessionKey) ?? [];
119
+ bucket.push(item);
120
+ grouped.set(parentSessionKey, bucket);
121
+ }
122
+ for (const bucket of grouped.values()) {
123
+ bucket.sort((left, right) => getSessionActivityAtTimestamp(right) - getSessionActivityAtTimestamp(left));
124
+ }
125
+ return grouped;
126
+ }
127
+
115
128
  function sessionTitle(session: SessionEntryView): string {
116
129
  if (session.label && session.label.trim()) {
117
130
  return session.label.trim();
@@ -170,6 +183,7 @@ export function ChatSidebar({
170
183
  const presenter = usePresenter();
171
184
  const docBrowser = useDocBrowser();
172
185
  const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
186
+ const [isUtilityMenuOpen, setIsUtilityMenuOpen] = useState(false);
173
187
  const inputSnapshot = useChatInputStore((state) => state.snapshot);
174
188
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
175
189
  const systemStatus = useSystemStatus();
@@ -179,27 +193,20 @@ export function ChatSidebar({
179
193
  const { theme, setTheme } = useTheme();
180
194
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
181
195
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
196
+ const utilityThemeOptions = useMemo(
197
+ () => THEME_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) })),
198
+ [],
199
+ );
200
+ const utilityLanguageOptions = useMemo(
201
+ () => LANGUAGE_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
202
+ [],
203
+ );
182
204
  const agentsById = useMemo(
183
205
  () => new Map((agentsQuery.data?.agents ?? []).map((agent) => [agent.id, agent])),
184
206
  [agentsQuery.data?.agents]
185
207
  );
186
- const sortedItems = useMemo(() => sortSessionItemsByUpdatedAtDesc(items), [items]);
187
- const childSessionsByParentKey = useMemo(() => {
188
- const grouped = new Map<string, NcpSessionListItemView[]>();
189
- for (const item of items) {
190
- const parentSessionKey = item.session.parentSessionId?.trim();
191
- if (!parentSessionKey) {
192
- continue;
193
- }
194
- const bucket = grouped.get(parentSessionKey) ?? [];
195
- bucket.push(item);
196
- grouped.set(parentSessionKey, bucket);
197
- }
198
- for (const bucket of grouped.values()) {
199
- bucket.sort((left, right) => getSessionUpdatedAtTimestamp(right) - getSessionUpdatedAtTimestamp(left));
200
- }
201
- return grouped;
202
- }, [items]);
208
+ const sortedItems = useMemo(() => sortSessionItemsByActivityAtDesc(items), [items]);
209
+ const childSessionsByParentKey = useMemo(() => groupChildSessionsByParentKey(items), [items]);
203
210
  const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
204
211
  const projectGroups = useMemo(() => groupSessionsByProject(sortedItems), [sortedItems]);
205
212
  const defaultSessionType = inputSnapshot.defaultSessionType || 'native';
@@ -248,10 +255,8 @@ export function ChatSidebar({
248
255
  />
249
256
  );
250
257
  const createSessionAndOpenIfNeeded = (sessionType: string, projectRoot?: string | null) => {
251
- const sessionKey = typeof projectRoot === "string"
252
- ? presenter.chatSessionListManager.createSession(sessionType, projectRoot)
253
- : presenter.chatSessionListManager.createSession(sessionType);
254
- if (isMobileVariant) presenter.chatUiManager.goToSession(sessionKey);
258
+ presenter.chatSessionListManager.createSession(sessionType, typeof projectRoot === "string" ? projectRoot : undefined);
259
+ if (isMobileVariant) presenter.chatUiManager.goToChatRoot();
255
260
  };
256
261
 
257
262
  return (
@@ -263,10 +268,10 @@ export function ChatSidebar({
263
268
  : 'w-[280px] shrink-0 border-r border-gray-200/60',
264
269
  )}
265
270
  >
266
- {!isMobileVariant ? (
267
- <div className="px-5 pt-5 pb-3">
271
+ {!isMobileVariant && !isWindowsDesktopHost() ? (
272
+ <div className={cn('px-5 pb-3', isMacDesktopHost() ? 'pt-1.5' : 'pt-5')}>
268
273
  <BrandHeader
269
- className="flex items-center gap-2.5 min-w-0"
274
+ className="flex min-w-0 items-center gap-2"
270
275
  suffix={<StatusBadge status={systemStatus.connectionStatus} />}
271
276
  />
272
277
  </div>
@@ -362,43 +367,20 @@ export function ChatSidebar({
362
367
  </div>
363
368
 
364
369
  {!isMobileVariant ? (
365
- <div className="px-3 py-3 border-t border-gray-200/60 space-y-0.5">
366
- <SidebarNavLinkItem
367
- to="/settings"
368
- label={t('settings')}
369
- icon={Settings}
370
- density="compact"
371
- />
372
- <SidebarActionItem
373
- onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
374
- icon={BookOpen}
375
- label={t('docBrowserHelp')}
376
- density="compact"
370
+ <div className="px-3 py-3 border-t border-gray-200/60">
371
+ <ChatSidebarUtilityMenu
372
+ isOpen={isUtilityMenuOpen}
373
+ onOpenChange={setIsUtilityMenuOpen}
374
+ currentTheme={theme}
375
+ currentThemeLabel={currentThemeLabel}
376
+ themeOptions={utilityThemeOptions}
377
+ onSelectTheme={setTheme}
378
+ currentLanguage={language}
379
+ currentLanguageLabel={currentLanguageLabel}
380
+ languageOptions={utilityLanguageOptions}
381
+ onSelectLanguage={handleLanguageSwitch}
382
+ onOpenDocs={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
377
383
  />
378
- <SidebarSelectItem
379
- value={theme}
380
- onValueChange={(value) => setTheme(value as UiTheme)}
381
- icon={Palette}
382
- label={t('theme')}
383
- valueLabel={currentThemeLabel}
384
- density="compact"
385
- >
386
- {THEME_OPTIONS.map((option) => (
387
- <SelectItem key={option.value} value={option.value} className="text-xs">{t(option.labelKey)}</SelectItem>
388
- ))}
389
- </SidebarSelectItem>
390
- <SidebarSelectItem
391
- value={language}
392
- onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}
393
- icon={Languages}
394
- label={t('language')}
395
- valueLabel={currentLanguageLabel}
396
- density="compact"
397
- >
398
- {LANGUAGE_OPTIONS.map((option) => (
399
- <SelectItem key={option.value} value={option.value} className="text-xs">{option.label}</SelectItem>
400
- ))}
401
- </SidebarSelectItem>
402
384
  </div>
403
385
  ) : null}
404
386
  </aside>
@@ -33,6 +33,7 @@ export type ChatThreadManagerLike = {
33
33
  createSession: () => void;
34
34
  goToProviders: () => void;
35
35
  openChildSessionPanel: (params: { parentSessionKey: string; activeChildSessionKey?: string | null }) => void;
36
+ openSessionCronPanel: (sessionKey: string) => void;
36
37
  openFilePreview: (action: ChatFileOpenActionViewModel) => void;
37
38
  openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
38
39
  selectChildSessionDetail: (sessionKey: string) => void;
@@ -48,6 +49,7 @@ export type ChatPresenterLike = {
48
49
  chatInputManager: ChatInputManagerLike;
49
50
  chatSessionListManager: ChatSessionListManager;
50
51
  chatThreadManager: ChatThreadManagerLike;
52
+ startAgentCreationDraft: (prompt: string) => void;
51
53
  };
52
54
 
53
55
  const ChatPresenterContext = createContext<ChatPresenterLike | null>(null);
@@ -0,0 +1,103 @@
1
+ import { Trash2 } from "lucide-react";
2
+ import type { CronJobView } from "@/shared/lib/api";
3
+ import { Button } from "@/shared/components/ui/button";
4
+ import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
5
+ import { useDeleteCronJob } from "@/shared/hooks/use-config";
6
+ import {
7
+ describeCronDelivery,
8
+ describeCronSchedule,
9
+ formatCronDate,
10
+ } from "@/shared/lib/cron";
11
+ import { t } from "@/shared/lib/i18n";
12
+
13
+ export function SessionCronJobContent({ jobs }: { jobs: readonly CronJobView[] }) {
14
+ const deleteCronJob = useDeleteCronJob();
15
+ const { confirm, ConfirmDialog } = useConfirmDialog();
16
+
17
+ const handleDelete = async (job: CronJobView) => {
18
+ const confirmed = await confirm({
19
+ title: `${t("cronDeleteConfirm")}?`,
20
+ description: job.name ? `${job.name} (${job.id})` : job.id,
21
+ confirmLabel: t("delete"),
22
+ });
23
+ if (!confirmed) {
24
+ return;
25
+ }
26
+ deleteCronJob.mutate({ id: job.id });
27
+ };
28
+
29
+ return (
30
+ <div className="h-full overflow-y-auto custom-scrollbar px-4 py-4">
31
+ <div className="mb-4">
32
+ <div className="text-sm font-semibold text-gray-900">
33
+ {t("chatWorkspaceSessionCronJobs")}
34
+ </div>
35
+ <div className="mt-1 text-xs text-gray-500">
36
+ {t("cronTotalLabel")}: {jobs.length}
37
+ </div>
38
+ </div>
39
+ {jobs.length === 0 ? (
40
+ <div className="py-8 text-center text-sm text-gray-500">
41
+ {t("chatWorkspaceCronJobEmpty")}
42
+ </div>
43
+ ) : (
44
+ <div className="space-y-3">
45
+ {jobs.map((job) => (
46
+ <div
47
+ key={job.id}
48
+ className="rounded-lg border border-gray-200 bg-white px-3 py-3"
49
+ >
50
+ <div className="flex items-start justify-between gap-3">
51
+ <div className="min-w-0">
52
+ <div className="truncate text-sm font-semibold text-gray-900">
53
+ {job.name || job.id}
54
+ </div>
55
+ <div className="mt-1 text-[11px] text-gray-400">
56
+ {job.id}
57
+ </div>
58
+ </div>
59
+ <Button
60
+ type="button"
61
+ variant="subtle"
62
+ size="sm"
63
+ className="h-7 gap-1 rounded-lg px-2"
64
+ onClick={() => void handleDelete(job)}
65
+ disabled={deleteCronJob.isPending}
66
+ >
67
+ <Trash2 className="h-3.5 w-3.5" />
68
+ {t("delete")}
69
+ </Button>
70
+ </div>
71
+ <div className="mt-3 space-y-1.5 text-xs text-gray-500">
72
+ <div>
73
+ <span className="font-medium text-gray-700">{t("cronScheduleLabel")}:</span>{" "}
74
+ {describeCronSchedule(job)}
75
+ </div>
76
+ <div>
77
+ <span className="font-medium text-gray-700">{t("cronNextRun")}:</span>{" "}
78
+ {formatCronDate(job.state.nextRunAt)}
79
+ </div>
80
+ <div>
81
+ <span className="font-medium text-gray-700">{t("cronLastRun")}:</span>{" "}
82
+ {formatCronDate(job.state.lastRunAt)}
83
+ </div>
84
+ <div>
85
+ <span className="font-medium text-gray-700">{t("cronLastStatus")}:</span>{" "}
86
+ {job.state.lastStatus ?? "-"}
87
+ </div>
88
+ <div>
89
+ <span className="font-medium text-gray-700">{t("cronDeliverTo")}:</span>{" "}
90
+ {describeCronDelivery(job)}
91
+ </div>
92
+ </div>
93
+ <div className="mt-3 whitespace-pre-wrap break-words text-sm text-gray-700">
94
+ {job.payload.message}
95
+ </div>
96
+ </div>
97
+ ))}
98
+ </div>
99
+ )}
100
+ <ConfirmDialog />
101
+ </div>
102
+ );
103
+ }
@@ -7,6 +7,11 @@ const mocks = vi.hoisted(() => ({
7
7
  manager: {
8
8
  reset: vi.fn(),
9
9
  hydrate: vi.fn(),
10
+ getSnapshot: vi.fn(() => ({
11
+ messages: [],
12
+ streamingMessage: null,
13
+ activeRun: null,
14
+ })),
10
15
  },
11
16
  runtime: {
12
17
  snapshot: {
@@ -37,6 +42,7 @@ describe("useHydratedNcpAgent", () => {
37
42
  beforeEach(() => {
38
43
  mocks.manager.reset.mockReset();
39
44
  mocks.manager.hydrate.mockReset();
45
+ mocks.manager.getSnapshot.mockClear();
40
46
  mocks.runtime.send.mockReset();
41
47
  mocks.runtime.abort.mockReset();
42
48
  mocks.runtime.streamRun.mockReset();
@@ -1,90 +1,193 @@
1
- import { act, renderHook } from "@testing-library/react";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { NcpEventType, type NcpEndpointEvent } from "@nextclaw/ncp";
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import {
3
+ type NcpAgentClientEndpoint,
4
+ type NcpAgentSendEnvelope,
5
+ type NcpEndpointEvent,
6
+ type NcpEndpointManifest,
7
+ type NcpEndpointSubscriber,
8
+ NcpEventType,
9
+ } from "@nextclaw/ncp";
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { DefaultNcpAgentConversationStateManager } from "../../../../../ncp-packages/nextclaw-ncp-toolkit/src/agent/agent-conversation-state-manager.ts";
4
12
  import { useNcpAgentRuntime } from "../../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.ts";
5
13
 
6
- function createEvent(type: NcpEventType, delta: string): NcpEndpointEvent {
7
- return {
8
- type,
9
- payload: {
10
- sessionId: "session-1",
11
- messageId: "assistant-1",
12
- toolCallId: "tool-1",
13
- delta,
14
- },
15
- } as NcpEndpointEvent;
14
+ const now = "2026-05-14T00:00:00.000Z";
15
+
16
+ class DeferredSendClient implements NcpAgentClientEndpoint {
17
+ readonly manifest: NcpEndpointManifest = {
18
+ endpointKind: "agent",
19
+ endpointId: "deferred-send-client",
20
+ version: "0.1.0",
21
+ supportsStreaming: true,
22
+ supportsAbort: true,
23
+ supportsProactiveMessages: false,
24
+ supportsLiveSessionStream: true,
25
+ supportedPartTypes: ["text"],
26
+ expectedLatency: "seconds",
27
+ };
28
+
29
+ readonly stop = vi.fn(async () => {});
30
+ readonly start = vi.fn(async () => {});
31
+ readonly stream = vi.fn(async () => {});
32
+ readonly abort = vi.fn(async () => {});
33
+ private listeners = new Set<NcpEndpointSubscriber>();
34
+ private releaseCompletion: (() => void) | null = null;
35
+ private completionGate = new Promise<void>((resolve) => {
36
+ this.releaseCompletion = resolve;
37
+ });
38
+
39
+ emit = async (event: NcpEndpointEvent): Promise<void> => {
40
+ this.publish(event);
41
+ };
42
+
43
+ subscribe = (listener: NcpEndpointSubscriber): (() => void) => {
44
+ this.listeners.add(listener);
45
+ return () => {
46
+ this.listeners.delete(listener);
47
+ };
48
+ };
49
+
50
+ send = vi.fn(async (_envelope: NcpAgentSendEnvelope): Promise<void> => {
51
+ this.publish({
52
+ type: NcpEventType.MessageSent,
53
+ payload: {
54
+ sessionId: "session-created",
55
+ message: {
56
+ id: "user-1",
57
+ sessionId: "session-created",
58
+ role: "user",
59
+ status: "final",
60
+ parts: [{ type: "text", text: "hello" }],
61
+ timestamp: now,
62
+ },
63
+ },
64
+ });
65
+ this.publish({
66
+ type: NcpEventType.RunStarted,
67
+ payload: {
68
+ sessionId: "session-created",
69
+ messageId: "assistant-1",
70
+ runId: "run-1",
71
+ },
72
+ });
73
+ await this.completionGate;
74
+ this.publish({
75
+ type: NcpEventType.MessageTextStart,
76
+ payload: {
77
+ sessionId: "session-created",
78
+ messageId: "assistant-1",
79
+ },
80
+ });
81
+ this.publish({
82
+ type: NcpEventType.MessageTextDelta,
83
+ payload: {
84
+ sessionId: "session-created",
85
+ messageId: "assistant-1",
86
+ delta: "done",
87
+ },
88
+ });
89
+ this.publish({
90
+ type: NcpEventType.MessageTextEnd,
91
+ payload: {
92
+ sessionId: "session-created",
93
+ messageId: "assistant-1",
94
+ },
95
+ });
96
+ this.publish({
97
+ type: NcpEventType.MessageCompleted,
98
+ payload: {
99
+ sessionId: "session-created",
100
+ message: {
101
+ id: "assistant-1",
102
+ sessionId: "session-created",
103
+ role: "assistant",
104
+ status: "final",
105
+ parts: [{ type: "text", text: "done" }],
106
+ timestamp: now,
107
+ },
108
+ },
109
+ });
110
+ this.publish({
111
+ type: NcpEventType.RunFinished,
112
+ payload: {
113
+ sessionId: "session-created",
114
+ runId: "run-1",
115
+ },
116
+ });
117
+ });
118
+
119
+ release = (): void => {
120
+ this.releaseCompletion?.();
121
+ };
122
+
123
+ private publish = (event: NcpEndpointEvent): void => {
124
+ for (const listener of this.listeners) {
125
+ listener(event);
126
+ }
127
+ };
16
128
  }
17
129
 
18
130
  describe("useNcpAgentRuntime", () => {
19
131
  beforeEach(() => {
20
- vi.useFakeTimers();
132
+ vi.clearAllMocks();
21
133
  });
22
134
 
23
- afterEach(() => {
24
- vi.useRealTimers();
25
- });
26
-
27
- it("batches streamed endpoint events before dispatching them to the manager", async () => {
28
- let subscriber: ((event: NcpEndpointEvent) => void) | null = null;
29
- const snapshot = {
30
- messages: [],
31
- streamingMessage: null,
32
- error: null,
33
- activeRun: null,
34
- };
35
- const client = {
36
- subscribe: vi.fn((callback: (event: NcpEndpointEvent) => void) => {
37
- subscriber = callback;
38
- return () => {
39
- subscriber = null;
40
- };
41
- }),
42
- stop: vi.fn().mockResolvedValue(undefined),
43
- send: vi.fn().mockResolvedValue(undefined),
44
- abort: vi.fn().mockResolvedValue(undefined),
45
- stream: vi.fn().mockResolvedValue(undefined),
135
+ it("keeps the active send stream alive when a new root chat materializes a session id", async () => {
136
+ const client = new DeferredSendClient();
137
+ const manager = new DefaultNcpAgentConversationStateManager();
138
+ const envelope: NcpAgentSendEnvelope = {
139
+ message: {
140
+ id: "user-1",
141
+ role: "user",
142
+ status: "final",
143
+ parts: [{ type: "text", text: "hello" }],
144
+ timestamp: now,
145
+ },
46
146
  };
47
- const manager = {
48
- getSnapshot: vi.fn(() => snapshot),
49
- subscribe: vi.fn(() => () => {}),
50
- dispatch: vi.fn().mockResolvedValue(undefined),
51
- dispatchBatch: vi.fn().mockResolvedValue(undefined),
52
- };
53
-
54
- renderHook(() =>
55
- useNcpAgentRuntime({
56
- sessionId: "session-1",
57
- client: client as never,
58
- manager: manager as never,
59
- }),
147
+ const { result, rerender } = renderHook(
148
+ ({ sessionId }: { sessionId?: string }) =>
149
+ useNcpAgentRuntime({ sessionId, client, manager: manager as never }),
150
+ { initialProps: { sessionId: undefined as string | undefined } },
60
151
  );
61
152
 
62
- expect(subscriber).not.toBeNull();
63
-
153
+ let sendPromise: Promise<void>;
64
154
  act(() => {
65
- subscriber?.(createEvent(NcpEventType.MessageToolCallArgsDelta, '{"path":"src/app.ts",'));
66
- subscriber?.(
67
- createEvent(
68
- NcpEventType.MessageToolCallArgsDelta,
69
- '"content":"console.log(1);"}',
70
- ),
71
- );
155
+ sendPromise = result.current.send(envelope);
156
+ });
157
+
158
+ await waitFor(() => {
159
+ expect(result.current.snapshot.activeRun?.sessionId).toBe("session-created");
72
160
  });
73
161
 
74
- expect(manager.dispatchBatch).not.toHaveBeenCalled();
162
+ rerender({ sessionId: "session-created" });
163
+
164
+ expect(client.stop).not.toHaveBeenCalled();
75
165
 
76
166
  await act(async () => {
77
- vi.advanceTimersByTime(16);
78
- await Promise.resolve();
167
+ client.release();
168
+ await sendPromise;
79
169
  });
80
170
 
81
- expect(manager.dispatchBatch).toHaveBeenCalledTimes(1);
82
- expect(manager.dispatchBatch).toHaveBeenCalledWith([
83
- createEvent(NcpEventType.MessageToolCallArgsDelta, '{"path":"src/app.ts",'),
84
- createEvent(
85
- NcpEventType.MessageToolCallArgsDelta,
86
- '"content":"console.log(1);"}',
87
- ),
171
+ await waitFor(() => {
172
+ expect(result.current.snapshot.activeRun).toBeNull();
173
+ });
174
+ expect(result.current.visibleMessages.map((message) => message.id)).toEqual([
175
+ "user-1",
176
+ "assistant-1",
88
177
  ]);
89
178
  });
179
+
180
+ it("aborts by session id even before a hydrated active run reaches local state", async () => {
181
+ const client = new DeferredSendClient();
182
+ const manager = new DefaultNcpAgentConversationStateManager();
183
+ const { result } = renderHook(() =>
184
+ useNcpAgentRuntime({ sessionId: "session-running", client, manager: manager as never }),
185
+ );
186
+
187
+ await act(async () => {
188
+ await result.current.abort();
189
+ });
190
+
191
+ expect(client.abort).toHaveBeenCalledWith({ sessionId: "session-running" });
192
+ });
90
193
  });
@@ -113,7 +113,7 @@ export function useNcpChatSnapshotSync(params: {
113
113
  sessionTypeUnavailableMessage: string | null;
114
114
  currentSessionTypeLabel: string;
115
115
  currentSessionTypeIcon: ChatSessionTypeOption['icon'];
116
- sessionKey: string;
116
+ sessionKey: string | null | undefined;
117
117
  currentAgentId: string;
118
118
  currentAgent: AgentProfileView | null;
119
119
  availableAgents: AgentProfileView[];
@@ -152,7 +152,7 @@ export function useNcpChatSnapshotSync(params: {
152
152
  sessionTypeUnavailableMessage: params.sessionTypeUnavailableMessage,
153
153
  sessionTypeLabel: params.currentSessionTypeLabel,
154
154
  sessionTypeIcon: params.currentSessionTypeIcon,
155
- sessionKey: params.sessionKey,
155
+ sessionKey: params.sessionKey ?? null,
156
156
  agentId: params.currentAgentId,
157
157
  agentDisplayName: params.currentAgent?.displayName ?? null,
158
158
  agentAvatarUrl: params.currentAgent?.avatarUrl ?? null,