@nextclaw/ui 0.15.0 → 0.15.2

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 (191) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{api-DLtsiBKm.js → api-B7Qigvtm.js} +2 -2
  3. package/dist/assets/{appearance-settings-page-CtkeYXux.js → appearance-settings-page-CuE0Iiqj.js} +1 -1
  4. package/dist/assets/{book-open-BQwWBDKb.js → book-open-Ba2f7YiG.js} +1 -1
  5. package/dist/assets/channels-list-page-CTsM6TcP.js +8 -0
  6. package/dist/assets/chat-page-C_5dcuE3.js +1 -0
  7. package/dist/assets/config-split-page-Fbb4PtTf.js +1 -0
  8. package/dist/assets/{confirm-dialog-iIJFFp2T.js → confirm-dialog-D2EG3nqE.js} +1 -1
  9. package/dist/assets/desktop-update-config-BGR3HUi1.js +1 -0
  10. package/dist/assets/{dist-D2eZC3OR.js → dist-7JTjycxE.js} +1 -1
  11. package/dist/assets/{dist-CBxWOazz.js → dist-wXLhk4hl.js} +1 -1
  12. package/dist/assets/doc-browser-bHyXnxlj.js +1 -0
  13. package/dist/assets/doc-browser-context-CYAGm5d5.js +1 -0
  14. package/dist/assets/doc-browser-gTrQ3jCF.js +1 -0
  15. package/dist/assets/{es2015-DhQo_oS6.js → es2015-j1SRCIX5.js} +1 -1
  16. package/dist/assets/{external-link-CM5dnUI6.js → external-link-DxuPaDoK.js} +1 -1
  17. package/dist/assets/form-actions-BztL7P_T.js +1 -0
  18. package/dist/assets/{i18n-provider-di1waTGX.js → i18n-provider-B4oMxOFx.js} +1 -1
  19. package/dist/assets/index-CvrvblgX.css +1 -0
  20. package/dist/assets/index-Obd4JYU2.js +104 -0
  21. package/dist/assets/loader-circle-CdzgK4eH.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-C_MVQUbT.js → mcp-marketplace-page-Bt7CuOoq.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-Ck3t78_n.js +1 -0
  24. package/dist/assets/model-config-page-DeKk9gvE.js +1 -0
  25. package/dist/assets/plus-Bx7wQbhG.js +1 -0
  26. package/dist/assets/{provider-scoped-model-input-g1WkGiJ8.js → provider-scoped-model-input-DsH2LF1q.js} +1 -1
  27. package/dist/assets/providers-config-page-BFnGp68F.js +1 -0
  28. package/dist/assets/{react-DjpzoZXB.js → react-UirzUuST.js} +1 -1
  29. package/dist/assets/{refresh-cw-DaTv3zxE.js → refresh-cw-eT8BF-8B.js} +1 -1
  30. package/dist/assets/remote-CDkHZtA8.js +1 -0
  31. package/dist/assets/rotate-cw-1JqxHTuw.js +1 -0
  32. package/dist/assets/runtime-config-page-BelAqiNG.js +1 -0
  33. package/dist/assets/{save-BZof1uie.js → save-Cb2g0BOi.js} +1 -1
  34. package/dist/assets/{search-MxAarS_g.js → search-BcHjwdsb.js} +1 -1
  35. package/dist/assets/search-config-page-zPm6--Ig.js +1 -0
  36. package/dist/assets/secrets-config-page-Cxyp-KuQ.js +3 -0
  37. package/dist/assets/security-config-B_gCq9fn.js +1 -0
  38. package/dist/assets/{select-D7GcNP0z.js → select-CdSEOwVM.js} +1 -1
  39. package/dist/assets/{setting-row-CuozlnJd.js → setting-row-CTAtGexr.js} +1 -1
  40. package/dist/assets/settings-2-2DWauOjY.js +1 -0
  41. package/dist/assets/{side-dock-BcLau5S_.js → side-dock-DPvakJ0a.js} +2 -2
  42. package/dist/assets/{skeleton-shfmXuYp.js → skeleton-P5oM14E1.js} +1 -1
  43. package/dist/assets/{switch-C1BWMU5c.js → switch-BU_UEULb.js} +1 -1
  44. package/dist/assets/{tag-chip-DCUrQUCP.js → tag-chip-D-WsKoT1.js} +1 -1
  45. package/dist/assets/{tooltip-DfAHHA76.js → tooltip-CjkOaHW7.js} +1 -1
  46. package/dist/assets/{use-config-DAdbT5QJ.js → use-config--hx5oxQn.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-eWcJ6V-s.js → use-confirm-dialog-CD9a_SK1.js} +1 -1
  48. package/dist/assets/x-DT5x45JU.js +1 -0
  49. package/dist/index.html +38 -30
  50. package/index.html +12 -4
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +29 -1
  53. package/src/app/components/layout/sidebar-items.tsx +43 -4
  54. package/src/app/components/layout/sidebar.tsx +83 -61
  55. package/src/app/configs/app-navigation.config.ts +49 -0
  56. package/src/app/styles/design-system.css +82 -0
  57. package/src/features/agents/components/__tests__/agents-page.test.tsx +135 -2
  58. package/src/features/agents/components/agent-advanced-config-fields.tsx +70 -0
  59. package/src/features/agents/components/agent-details-dialog.tsx +398 -0
  60. package/src/features/agents/components/agent-dialogs.tsx +20 -47
  61. package/src/features/agents/components/agents-page.tsx +76 -10
  62. package/src/features/agents/types/agent-form.types.ts +21 -0
  63. package/src/features/agents/utils/agent-form.utils.ts +39 -0
  64. package/src/features/channels/components/config/channel-form.tsx +14 -11
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +172 -1
  66. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +11 -1
  67. package/src/features/chat/components/conversation/chat-conversation-header.tsx +7 -3
  68. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -1
  69. package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +48 -0
  70. package/src/features/chat/components/layout/__tests__/chat-sidebar-utility-menu.test.tsx +55 -0
  71. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +0 -3
  72. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +3 -4
  73. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +29 -30
  74. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -7
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +18 -3
  76. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +2 -1
  77. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +22 -1
  78. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +1 -1
  79. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -5
  80. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -11
  81. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +23 -1
  82. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +74 -0
  83. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +3 -2
  84. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +116 -0
  85. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +108 -46
  86. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +6 -0
  87. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +48 -24
  88. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +0 -3
  89. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +0 -4
  90. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +294 -0
  91. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +15 -1
  92. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +24 -4
  93. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +3 -0
  94. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +25 -3
  95. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +1 -0
  96. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +9 -4
  97. package/src/features/chat/features/welcome/components/chat-welcome.tsx +0 -5
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +193 -56
  99. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -0
  100. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +144 -0
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +99 -0
  102. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -1
  103. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +67 -14
  104. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +44 -1
  106. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +45 -3
  107. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +46 -0
  108. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +33 -2
  109. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +47 -0
  110. package/src/features/chat/managers/chat-session-list.manager.ts +32 -0
  111. package/src/features/chat/managers/chat-thread.manager.ts +8 -2
  112. package/src/features/chat/stores/chat-session-list.store.ts +28 -5
  113. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +5 -12
  114. package/src/features/panel-apps/components/panel-app-list-item.tsx +6 -6
  115. package/src/features/panel-apps/index.ts +1 -0
  116. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -10
  117. package/src/features/panel-apps/utils/panel-app-iframe.utils.ts +6 -0
  118. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -3
  119. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +3 -1
  120. package/src/features/service-apps/components/service-apps-panel.tsx +145 -35
  121. package/src/features/settings/components/config/provider-auth-section.tsx +1 -1
  122. package/src/features/settings/components/config/provider-form.tsx +11 -8
  123. package/src/features/settings/components/config/provider-models-section.tsx +7 -7
  124. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  125. package/src/features/settings/components/config/secrets-config-form.tsx +5 -4
  126. package/src/features/settings/pages/model-config-page.tsx +26 -25
  127. package/src/features/settings/pages/providers-config-page.tsx +10 -13
  128. package/src/features/settings/pages/search-config-page.tsx +8 -5
  129. package/src/features/system-status/components/__tests__/desktop-update-config.test.tsx +97 -5
  130. package/src/features/system-status/components/config/runtime-config-editor.tsx +5 -4
  131. package/src/features/system-status/components/desktop-update-config.tsx +91 -2
  132. package/src/features/system-status/utils/update-release-notes.utils.ts +94 -0
  133. package/src/index.css +18 -0
  134. package/src/shared/components/config-split-page.tsx +6 -6
  135. package/src/shared/components/doc-browser/__tests__/doc-browser-url.utils.test.ts +73 -0
  136. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +48 -0
  137. package/src/shared/components/doc-browser/doc-browser-context.tsx +4 -0
  138. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +5 -1
  139. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +2 -1
  140. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +10 -10
  141. package/src/shared/components/doc-browser/doc-browser.tsx +6 -4
  142. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +62 -4
  143. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +18 -0
  144. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +28 -3
  145. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +36 -0
  146. package/src/shared/components/ui/actions/form-actions.tsx +25 -0
  147. package/src/shared/components/ui/actions/icon-action-button.tsx +25 -2
  148. package/src/shared/components/ui/card.tsx +12 -4
  149. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +2 -1
  150. package/src/shared/hooks/use-server-path-browse.ts +26 -4
  151. package/src/shared/hooks/use-server-path-read.ts +11 -1
  152. package/src/shared/lib/api/ncp-session.types.ts +1 -1
  153. package/src/shared/lib/api/types.ts +12 -3
  154. package/src/shared/lib/api/utils/server-path.utils.ts +1 -0
  155. package/src/shared/lib/i18n/locales/en-US/agents.json +41 -0
  156. package/src/shared/lib/i18n/locales/en-US/chat.json +19 -0
  157. package/src/shared/lib/i18n/locales/en-US/core.json +3 -0
  158. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +8 -0
  159. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +5 -0
  160. package/src/shared/lib/i18n/locales/zh-CN/agents.json +41 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/chat.json +19 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/core.json +3 -0
  163. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +8 -0
  164. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +5 -0
  165. package/src/shared/lib/session-project/workspace-file-breadcrumb.utils.ts +9 -5
  166. package/src/shared/lib/theme/index.test.ts +21 -0
  167. package/src/shared/lib/theme/index.ts +6 -4
  168. package/src/vite-env.d.ts +2 -0
  169. package/dist/assets/channels-list-page-Cv8o9Hxg.js +0 -8
  170. package/dist/assets/chat-page-BtA7H1Ea.js +0 -1
  171. package/dist/assets/config-split-page-EidRBpP2.js +0 -1
  172. package/dist/assets/desktop-update-config-C6n_TB1r.js +0 -1
  173. package/dist/assets/doc-browser-B76XOXmW.js +0 -1
  174. package/dist/assets/doc-browser-DBnj9UMB.js +0 -1
  175. package/dist/assets/doc-browser-context-Sv-044Cl.js +0 -1
  176. package/dist/assets/ellipsis-2uzhypw5.js +0 -1
  177. package/dist/assets/index-CBRP-7-a.css +0 -1
  178. package/dist/assets/index-Z1fYpbNd.js +0 -104
  179. package/dist/assets/loader-circle-BjmN5DeJ.js +0 -1
  180. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +0 -1
  181. package/dist/assets/model-config-page-Cdr9P6TQ.js +0 -1
  182. package/dist/assets/plus-3ztRZD_l.js +0 -1
  183. package/dist/assets/providers-config-page-CWlO7J4A.js +0 -1
  184. package/dist/assets/remote-CKWZfVVB.js +0 -1
  185. package/dist/assets/rotate-cw-CHcaRUDz.js +0 -1
  186. package/dist/assets/runtime-config-page-CfTqh1nv.js +0 -1
  187. package/dist/assets/search-config-page-Dm8daYny.js +0 -1
  188. package/dist/assets/secrets-config-page-D8AbcR05.js +0 -3
  189. package/dist/assets/security-config-Ds0LsJmo.js +0 -1
  190. package/dist/assets/x-DqL1mYLC.js +0 -1
  191. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-C_iP1zl_.js} +0 -0
@@ -50,7 +50,7 @@ const mocks = vi.hoisted(() => {
50
50
  modelOptions: [],
51
51
  selectedSession: null as null | {
52
52
  activityPreview?: {
53
- state: 'running' | 'completed' | 'failed' | 'idle';
53
+ state: 'running' | 'completed' | 'failed' | 'cancelled' | 'idle';
54
54
  statusText?: string;
55
55
  replyText?: string;
56
56
  };
@@ -260,7 +260,7 @@ describe('SessionConversationArea input boundary', () => {
260
260
  mocks.inputQuery.selectedSession = {
261
261
  activityPreview: {
262
262
  state: 'failed',
263
- statusText: '运行出错:Invalid API Key',
263
+ statusText: 'Run failed: Invalid API Key',
264
264
  },
265
265
  status: 'idle',
266
266
  };
@@ -269,6 +269,21 @@ describe('SessionConversationArea input boundary', () => {
269
269
 
270
270
  expect(screen.getByTestId('conversation-bottom-slot')).toBeTruthy();
271
271
  expect(screen.getByText(/出错了|Something went wrong/)).toBeTruthy();
272
- expect(screen.getByText('运行出错:Invalid API Key')).toBeTruthy();
272
+ expect(screen.getByText('Run failed: Invalid API Key')).toBeTruthy();
273
+ });
274
+
275
+ it('does not surface user-cancelled previews as conversation errors', () => {
276
+ mocks.inputQuery.selectedSession = {
277
+ activityPreview: {
278
+ state: 'cancelled',
279
+ statusText: 'Run interrupted: User stopped the current run.',
280
+ },
281
+ status: 'idle',
282
+ };
283
+
284
+ renderArea('session-1');
285
+
286
+ expect(screen.queryByTestId('conversation-bottom-slot')).toBeNull();
287
+ expect(screen.queryByText(/出错了|Something went wrong/)).toBeNull();
273
288
  });
274
289
  });
@@ -417,7 +417,8 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
417
417
  },
418
418
  })}
419
419
  toolbar={{
420
- selects: toolbarSelects,
420
+ selects: [],
421
+ trailingSelects: toolbarSelects,
421
422
  accessories: [
422
423
  {
423
424
  key: 'attach',
@@ -32,8 +32,13 @@ vi.mock('@/app/presenters/app.presenter', () => ({
32
32
  }));
33
33
 
34
34
  vi.mock('@/features/panel-apps', () => ({
35
+ PANEL_APP_IFRAME_SANDBOX: 'allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-downloads allow-pointer-lock allow-presentation',
35
36
  findPanelAppEntryByDisplayId: (entries: Array<{ appId: string; id: string; title: string }>, value: string) =>
36
37
  entries.find((entry) => [entry.appId, entry.id, entry.title].includes(value)) ?? null,
38
+ focusPanelAppIframe: (iframe: HTMLIFrameElement | null) => {
39
+ iframe?.focus({ preventScroll: true });
40
+ iframe?.contentWindow?.focus();
41
+ },
37
42
  usePanelApps: () => mocks.panelApps,
38
43
  }));
39
44
 
@@ -68,7 +73,6 @@ it('renders inline panel apps as bounded card-mode iframes with an expand action
68
73
  expect(iframe.getAttribute('src')).toBe(
69
74
  '/api/panel-apps/weather-card/content?nextclawDisplayMode=card&nextclawPlacement=inline',
70
75
  );
71
- expect(iframe.getAttribute('scrolling')).toBe('auto');
72
76
 
73
77
  fireEvent.click(screen.getByLabelText('chatPanelCardExpand'));
74
78
 
@@ -80,6 +84,23 @@ it('renders inline panel apps as bounded card-mode iframes with an expand action
80
84
  }));
81
85
  });
82
86
 
87
+ it('focuses the inline panel app iframe when the pointer enters it', () => {
88
+ render(<ChatInlinePanelAppCard panelApp={{
89
+ appId: 'weather-card',
90
+ title: 'Weather',
91
+ }} />);
92
+
93
+ const iframe = screen.getByTitle('Weather') as HTMLIFrameElement;
94
+ const iframeFocus = vi.spyOn(iframe, 'focus');
95
+ const contentWindowFocus = vi.spyOn(iframe.contentWindow!, 'focus').mockImplementation(() => undefined);
96
+
97
+ fireEvent.pointerOver(iframe);
98
+
99
+ expect(iframe.getAttribute('tabindex')).toBe('0');
100
+ expect(iframeFocus).toHaveBeenCalledWith({ preventScroll: true });
101
+ expect(contentWindowFocus).toHaveBeenCalled();
102
+ });
103
+
83
104
  it('can render inline panel apps without a side-panel expand action', () => {
84
105
  render(
85
106
  <ChatInlinePanelAppCard
@@ -497,7 +497,7 @@ it("renders context compaction as an in-flow divider instead of a chat message",
497
497
  updatedAt: "2026-05-05T12:00:00.000Z",
498
498
  },
499
499
  },
500
- parts: [{ type: "text", text: "较早上下文已自动压缩" }],
500
+ parts: [{ type: "text", text: "Earlier context was auto-compacted" }],
501
501
  } satisfies NcpMessage;
502
502
 
503
503
  const { getByText } = render(
@@ -2,13 +2,17 @@ import { useEffect, useId, useMemo, useRef } from 'react';
2
2
  import type { ChatPanelAppCardViewModel } from '@nextclaw/agent-chat-ui';
3
3
  import { AppWindow, Maximize2 } from 'lucide-react';
4
4
  import { getPresenter } from '@/app/presenters/app.presenter';
5
- import { findPanelAppEntryByDisplayId, usePanelApps } from '@/features/panel-apps';
5
+ import {
6
+ PANEL_APP_IFRAME_SANDBOX,
7
+ findPanelAppEntryByDisplayId,
8
+ focusPanelAppIframe,
9
+ usePanelApps,
10
+ } from '@/features/panel-apps';
6
11
  import { createPanelAppRightPanelResourceTarget } from '@/features/right-panel-resources';
7
12
  import {
8
13
  createInlinePanelAppCardUrl,
9
14
  createFallbackPanelAppContentPath,
10
15
  createInlinePanelAppTab,
11
- PANEL_APP_INLINE_IFRAME_SANDBOX,
12
16
  } from '@/features/chat/features/message/utils/chat-inline-panel-app-card.utils';
13
17
  import { useDocBrowser } from '@/shared/components/doc-browser';
14
18
  import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
@@ -98,7 +102,7 @@ export function ChatInlinePanelAppCard({
98
102
  </div>
99
103
  {showExpandAction ? (
100
104
  <IconActionButton
101
- className="h-7 w-7 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
105
+ size="md"
102
106
  icon={<Maximize2 className="h-3.5 w-3.5" />}
103
107
  label={t('chatPanelCardExpand')}
104
108
  onClick={openExpanded}
@@ -113,8 +117,9 @@ export function ChatInlinePanelAppCard({
113
117
  key={iframeInstanceId}
114
118
  src={cardUrl}
115
119
  title={title}
116
- sandbox={PANEL_APP_INLINE_IFRAME_SANDBOX}
117
- scrolling="auto"
120
+ sandbox={PANEL_APP_IFRAME_SANDBOX}
121
+ tabIndex={0}
122
+ onPointerOver={() => focusPanelAppIframe(iframeRef.current)}
118
123
  className="h-full w-full border-0 bg-card"
119
124
  />
120
125
  </div>
@@ -4,17 +4,6 @@ import {
4
4
  } from '@/features/right-panel-resources';
5
5
  import type { DocBrowserTab } from '@/shared/components/doc-browser/doc-browser-context';
6
6
 
7
- export const PANEL_APP_INLINE_IFRAME_SANDBOX = [
8
- 'allow-scripts',
9
- 'allow-forms',
10
- 'allow-modals',
11
- 'allow-popups',
12
- 'allow-popups-to-escape-sandbox',
13
- 'allow-downloads',
14
- 'allow-pointer-lock',
15
- 'allow-presentation',
16
- ].join(' ');
17
-
18
7
  const PANEL_APP_INLINE_CARD_SEARCH_PARAMS = {
19
8
  nextclawDisplayMode: 'card',
20
9
  nextclawPlacement: 'inline',
@@ -217,7 +217,15 @@ describe("useNcpAgentRuntime", () => {
217
217
  await result.current.abort();
218
218
  });
219
219
 
220
- expect(client.abort).toHaveBeenCalledWith({ sessionId: "session-running" });
220
+ expect(client.abort).toHaveBeenCalledWith({
221
+ sessionId: "session-running",
222
+ runId: undefined,
223
+ reason: {
224
+ code: "abort-error",
225
+ message: "User stopped the current run.",
226
+ details: { source: "chat-ui" },
227
+ },
228
+ });
221
229
  });
222
230
 
223
231
  it("clears the local running state when the live stream publishes message.abort", async () => {
@@ -243,6 +251,20 @@ describe("useNcpAgentRuntime", () => {
243
251
  expect(result.current.activeRunId).toBe("run-1");
244
252
  });
245
253
 
254
+ await act(async () => {
255
+ await result.current.abort();
256
+ });
257
+
258
+ expect(client.abort).toHaveBeenCalledWith({
259
+ sessionId: "session-running",
260
+ runId: "run-1",
261
+ reason: {
262
+ code: "abort-error",
263
+ message: "User stopped the current run.",
264
+ details: { source: "chat-ui" },
265
+ },
266
+ });
267
+
246
268
  await act(async () => {
247
269
  await client.emit({
248
270
  type: NcpEventType.MessageAbort,
@@ -0,0 +1,74 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useNcpSessionListView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
4
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
5
+ import type { NcpSessionSummaryView } from "@/shared/lib/api";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ sessions: [] as NcpSessionSummaryView[],
9
+ }));
10
+
11
+ vi.mock("@/features/chat/features/ncp/hooks/use-ncp-session-queries", () => ({
12
+ useNcpSessions: () => ({
13
+ data: {
14
+ sessions: mocks.sessions,
15
+ total: mocks.sessions.length,
16
+ },
17
+ isLoading: false,
18
+ }),
19
+ }));
20
+
21
+ function createSummary(
22
+ sessionId: string,
23
+ label: string,
24
+ ): NcpSessionSummaryView {
25
+ return {
26
+ sessionId,
27
+ status: "idle",
28
+ updatedAt: "2026-06-18T00:00:00.000Z",
29
+ lastMessageAt: "2026-06-18T00:00:00.000Z",
30
+ messageCount: 1,
31
+ metadata: {
32
+ label,
33
+ session_type: "native",
34
+ },
35
+ };
36
+ }
37
+
38
+ describe("useNcpSessionListView", () => {
39
+ beforeEach(() => {
40
+ mocks.sessions = [
41
+ createSummary("session:alpha", "Alpha Task"),
42
+ createSummary("session:beta", "Beta Task"),
43
+ ];
44
+ useChatSessionListStore.setState({
45
+ snapshot: {
46
+ ...useChatSessionListStore.getState().snapshot,
47
+ query: "",
48
+ },
49
+ });
50
+ });
51
+
52
+ it("uses the sidebar query by default", () => {
53
+ useChatSessionListStore.getState().setSnapshot({ query: "Alpha" });
54
+
55
+ const { result } = renderHook(() => useNcpSessionListView());
56
+
57
+ expect(result.current.items.map((item) => item.session.key)).toEqual([
58
+ "session:alpha",
59
+ ]);
60
+ });
61
+
62
+ it("can override the hidden sidebar query for header switching", () => {
63
+ useChatSessionListStore.getState().setSnapshot({ query: "Alpha" });
64
+
65
+ const { result } = renderHook(() =>
66
+ useNcpSessionListView({ query: "" }),
67
+ );
68
+
69
+ expect(result.current.items.map((item) => item.session.key)).toEqual([
70
+ "session:alpha",
71
+ "session:beta",
72
+ ]);
73
+ });
74
+ });
@@ -22,8 +22,9 @@ function shouldShowSessionInSidebar(session: SessionEntryView): boolean {
22
22
  return session.isPromotedChildSession === true;
23
23
  }
24
24
 
25
- export function useNcpSessionListView(params: { limit?: number } = {}) {
26
- const query = useChatSessionListStore((state) => state.snapshot.query);
25
+ export function useNcpSessionListView(params: { limit?: number; query?: string | null } = {}) {
26
+ const storedQuery = useChatSessionListStore((state) => state.snapshot.query);
27
+ const query = params.query ?? storedQuery;
27
28
  const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
28
29
 
29
30
  const items = useMemo<NcpSessionListItemView[]>(() => {
@@ -0,0 +1,116 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ChatSidebarProjectGroups } from "@/features/chat/features/session/components/chat-sidebar-project-groups";
4
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ createSession: vi.fn(),
8
+ toggleProjectCollapsed: vi.fn(),
9
+ toggleProjectPinned: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
13
+ usePresenter: () => ({
14
+ chatSessionListManager: mocks,
15
+ }),
16
+ }));
17
+
18
+ const projectGroup = {
19
+ projectRoot: "/tmp/analysis-project",
20
+ projectName: "analysis-project",
21
+ items: [
22
+ {
23
+ session: {
24
+ key: "session:analysis",
25
+ createdAt: "2026-07-12T00:00:00.000Z",
26
+ updatedAt: "2026-07-12T00:00:00.000Z",
27
+ sessionType: "native",
28
+ sessionTypeMutable: false,
29
+ messageCount: 1,
30
+ },
31
+ },
32
+ ],
33
+ latestUpdatedAt: 1,
34
+ isPinned: false,
35
+ };
36
+
37
+ function renderProjectGroups(isPinned = false) {
38
+ return render(
39
+ <ChatSidebarProjectGroups
40
+ groups={[{ ...projectGroup, isPinned }]}
41
+ defaultSessionType="native"
42
+ sessionTypeOptions={[
43
+ { value: "native", label: "Native", icon: null, ready: true },
44
+ ]}
45
+ renderSessionItem={() => <div>Project session</div>}
46
+ />,
47
+ );
48
+ }
49
+
50
+ describe("ChatSidebarProjectGroups", () => {
51
+ beforeEach(() => {
52
+ mocks.createSession.mockReset();
53
+ mocks.toggleProjectCollapsed.mockReset();
54
+ mocks.toggleProjectPinned.mockReset();
55
+ useChatSessionListStore.setState({
56
+ snapshot: {
57
+ ...useChatSessionListStore.getState().snapshot,
58
+ collapsedProjectRoots: [],
59
+ selectedSessionKey: "session:current",
60
+ },
61
+ });
62
+ mocks.toggleProjectCollapsed.mockImplementation((projectRoot: string) => {
63
+ useChatSessionListStore.getState().setSnapshot({
64
+ collapsedProjectRoots: [projectRoot],
65
+ });
66
+ });
67
+ });
68
+
69
+ it("keeps the project header as the full-width folder row and collapses its sessions", () => {
70
+ renderProjectGroups();
71
+
72
+ const header = screen.getByLabelText("Collapse project");
73
+ expect(header.parentElement?.className).toContain("h-10");
74
+ expect(header.parentElement?.className).toContain("hover:bg-gray-200/60");
75
+ expect(header.parentElement?.className).not.toContain("focus-within:bg");
76
+ expect(screen.getByText("analysis-project").nextElementSibling?.tagName).toBe("svg");
77
+
78
+ fireEvent.click(header);
79
+
80
+ expect(mocks.toggleProjectCollapsed).toHaveBeenCalledWith(
81
+ "/tmp/analysis-project",
82
+ );
83
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe(
84
+ "session:current",
85
+ );
86
+ expect(screen.queryByText("Project session")).toBeNull();
87
+ });
88
+
89
+ it("keeps project creation and pinning in one trailing action cluster", () => {
90
+ renderProjectGroups();
91
+
92
+ fireEvent.click(screen.getByLabelText("Pin project"));
93
+
94
+ expect(mocks.toggleProjectPinned).toHaveBeenCalledWith(
95
+ "/tmp/analysis-project",
96
+ );
97
+ expect(screen.getByLabelText("New Task · analysis-project")).not.toBeNull();
98
+ });
99
+
100
+ it("uses the same pin control to show and clear the project pin state", () => {
101
+ renderProjectGroups(true);
102
+
103
+ const unpinButton = screen.getByLabelText("Unpin project");
104
+ const pinIcon = unpinButton.querySelector("svg");
105
+
106
+ expect(pinIcon?.getAttribute("class")).toContain("fill-current");
107
+ expect(pinIcon?.getAttribute("class")).toContain("text-foreground");
108
+ expect(document.querySelectorAll("svg.fill-current")).toHaveLength(1);
109
+
110
+ fireEvent.click(unpinButton);
111
+
112
+ expect(mocks.toggleProjectPinned).toHaveBeenCalledWith(
113
+ "/tmp/analysis-project",
114
+ );
115
+ });
116
+ });
@@ -1,11 +1,13 @@
1
1
  import { useMemo, useState, type ReactNode } from 'react';
2
- import { Plus } from 'lucide-react';
2
+ import { ChevronDown, ChevronRight, Folder, Pin, Plus } from 'lucide-react';
3
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
3
4
  import { ChatSessionTypeMenu } from "@/features/chat/features/session-type/components/chat-session-type-menu";
4
5
  import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
5
6
  import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
6
7
  import type { ChatSessionTypeOption } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
7
8
  import type { NcpSessionListItemView } from '@/features/chat/features/ncp/hooks/use-ncp-session-list-view';
8
9
  import type { ChatSidebarProjectGroup } from '@/features/chat/features/session/utils/chat-sidebar-session-groups.utils';
10
+ import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
9
11
  import { t } from '@/shared/lib/i18n';
10
12
 
11
13
  export type { ChatSidebarProjectGroup };
@@ -17,7 +19,6 @@ type ChatSidebarProjectGroupsProps = {
17
19
  defaultSessionType: string;
18
20
  sessionTypeOptions: SessionTypeOption[];
19
21
  renderSessionItem: (item: NcpSessionListItemView) => ReactNode;
20
- onCreateSession: (sessionType: string, projectRoot: string) => void;
21
22
  };
22
23
 
23
24
  function resolveProjectGroupDefaultSessionType(
@@ -31,7 +32,11 @@ function resolveProjectGroupDefaultSessionType(
31
32
  }
32
33
 
33
34
  export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
34
- const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem, onCreateSession } = props;
35
+ const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem } = props;
36
+ const presenter = usePresenter();
37
+ const collapsedProjectRoots = useChatSessionListStore(
38
+ (state) => state.snapshot.collapsedProjectRoots,
39
+ );
35
40
  const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
36
41
  const preferredSessionType = useMemo(
37
42
  () => resolveProjectGroupDefaultSessionType(defaultSessionType, sessionTypeOptions),
@@ -43,62 +48,119 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
43
48
  <div className="space-y-3">
44
49
  {groups.map((group) => {
45
50
  const actionLabel = `${t('chatSidebarNewTask')} · ${group.projectName}`;
51
+ const isCollapsed = collapsedProjectRoots.includes(group.projectRoot);
52
+ const pinLabel = t(
53
+ group.isPinned ? 'chatSidebarUnpinProject' : 'chatSidebarPinProject',
54
+ );
46
55
 
47
56
  return (
48
57
  <div key={group.projectRoot}>
49
- <div className="flex items-center justify-between gap-2 px-2 py-0.5">
50
- <div className="flex min-w-0 items-center gap-1.5">
51
- <div
52
- className="truncate text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
58
+ <div className="group/project relative h-10 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900">
59
+ <button
60
+ type="button"
61
+ aria-expanded={!isCollapsed}
62
+ aria-label={t(
63
+ isCollapsed
64
+ ? 'chatSidebarExpandProject'
65
+ : 'chatSidebarCollapseProject',
66
+ )}
67
+ className="flex h-full w-full min-w-0 items-center gap-1.5 pr-14 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
68
+ onClick={() =>
69
+ presenter.chatSessionListManager.toggleProjectCollapsed(
70
+ group.projectRoot,
71
+ )
72
+ }
73
+ >
74
+ <Folder className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
75
+ <span
76
+ className="truncate text-[11px] font-medium uppercase tracking-wider"
53
77
  title={group.projectRoot}
54
78
  >
55
79
  {group.projectName}
56
- </div>
80
+ </span>
81
+ {isCollapsed ? (
82
+ <ChevronRight
83
+ className="h-3.5 w-3.5 shrink-0"
84
+ aria-hidden="true"
85
+ />
86
+ ) : (
87
+ <ChevronDown
88
+ className="h-3.5 w-3.5 shrink-0"
89
+ aria-hidden="true"
90
+ />
91
+ )}
57
92
  <span className="shrink-0 text-[10px] text-muted-foreground/70">
58
93
  {group.items.length}
59
94
  </span>
60
- </div>
61
- {supportsSessionTypeChoice ? (
62
- <Popover
63
- open={openProjectRoot === group.projectRoot}
64
- onOpenChange={(nextOpen) => {
65
- setOpenProjectRoot(nextOpen ? group.projectRoot : null);
66
- }}
67
- >
68
- <PopoverTrigger asChild>
69
- <IconActionButton
70
- icon={<Plus className="h-3.5 w-3.5" />}
71
- label={actionLabel}
72
- tooltip={false}
73
- className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground hover:bg-gray-200/60 hover:text-gray-900"
74
- />
75
- </PopoverTrigger>
76
- <PopoverContent
77
- align="end"
78
- className="w-56 rounded-2xl border border-border bg-popover p-1.5 text-popover-foreground shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
95
+ </button>
96
+ <div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
97
+ {supportsSessionTypeChoice ? (
98
+ <Popover
99
+ open={openProjectRoot === group.projectRoot}
100
+ onOpenChange={(nextOpen) => {
101
+ setOpenProjectRoot(nextOpen ? group.projectRoot : null);
102
+ }}
79
103
  >
80
- <ChatSessionTypeMenu
81
- options={sessionTypeOptions}
82
- selectedSessionType={preferredSessionType}
83
- onSelect={(sessionType) => {
84
- onCreateSession(sessionType, group.projectRoot);
85
- setOpenProjectRoot(null);
86
- }}
87
- />
88
- </PopoverContent>
89
- </Popover>
90
- ) : (
104
+ <PopoverTrigger asChild>
105
+ <IconActionButton
106
+ icon={<Plus className="h-3.5 w-3.5" />}
107
+ label={actionLabel}
108
+ />
109
+ </PopoverTrigger>
110
+ <PopoverContent
111
+ align="end"
112
+ className="w-56 rounded-2xl border border-border bg-popover p-1.5 text-popover-foreground shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
113
+ >
114
+ <ChatSessionTypeMenu
115
+ options={sessionTypeOptions}
116
+ selectedSessionType={preferredSessionType}
117
+ onSelect={(sessionType) => {
118
+ presenter.chatSessionListManager.createSession(
119
+ sessionType,
120
+ group.projectRoot,
121
+ );
122
+ setOpenProjectRoot(null);
123
+ }}
124
+ />
125
+ </PopoverContent>
126
+ </Popover>
127
+ ) : (
128
+ <IconActionButton
129
+ icon={<Plus className="h-3.5 w-3.5" />}
130
+ label={actionLabel}
131
+ onClick={() =>
132
+ presenter.chatSessionListManager.createSession(
133
+ preferredSessionType,
134
+ group.projectRoot,
135
+ )
136
+ }
137
+ />
138
+ )}
91
139
  <IconActionButton
92
- icon={<Plus className="h-3.5 w-3.5" />}
93
- label={actionLabel}
94
- className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground hover:bg-gray-200/60 hover:text-gray-900"
95
- onClick={() => onCreateSession(preferredSessionType, group.projectRoot)}
140
+ icon={
141
+ <Pin
142
+ className={
143
+ group.isPinned
144
+ ? 'h-3.5 w-3.5 fill-current text-foreground'
145
+ : 'h-3.5 w-3.5'
146
+ }
147
+ />
148
+ }
149
+ label={pinLabel}
150
+ tooltipSide="right"
151
+ onClick={() =>
152
+ presenter.chatSessionListManager.toggleProjectPinned(
153
+ group.projectRoot,
154
+ )
155
+ }
96
156
  />
97
- )}
98
- </div>
99
- <div className="space-y-0.5 pl-2">
100
- {group.items.map(renderSessionItem)}
157
+ </div>
101
158
  </div>
159
+ {isCollapsed ? null : (
160
+ <div className="mt-0.5 space-y-0.5 pl-2">
161
+ {group.items.map(renderSessionItem)}
162
+ </div>
163
+ )}
102
164
  </div>
103
165
  );
104
166
  })}
@@ -22,6 +22,7 @@ export function ChatSidebarSessionEntry(props: {
22
22
  draftLabel: string;
23
23
  savingSessionKey: string | null;
24
24
  sessionTypeOptions: ChatSessionTypeOption[];
25
+ isPinned: boolean;
25
26
  sessionTitle: (session: SessionEntryView) => string;
26
27
  onSelectSession: (sessionKey: string) => void;
27
28
  onOpenChildSessions: (
@@ -32,6 +33,7 @@ export function ChatSidebarSessionEntry(props: {
32
33
  onDraftLabelChange: (value: string) => void;
33
34
  onSaveSessionLabel: (session: SessionEntryView) => void;
34
35
  onCancelEditingSessionLabel: () => void;
36
+ onTogglePinned: () => void;
35
37
  }) {
36
38
  const {
37
39
  item,
@@ -43,6 +45,7 @@ export function ChatSidebarSessionEntry(props: {
43
45
  draftLabel,
44
46
  savingSessionKey,
45
47
  sessionTypeOptions,
48
+ isPinned,
46
49
  sessionTitle,
47
50
  onSelectSession,
48
51
  onOpenChildSessions,
@@ -50,6 +53,7 @@ export function ChatSidebarSessionEntry(props: {
50
53
  onDraftLabelChange,
51
54
  onSaveSessionLabel,
52
55
  onCancelEditingSessionLabel,
56
+ onTogglePinned,
53
57
  } = props;
54
58
  const { session, runStatus } = item;
55
59
  const active = selectedSessionKey === session.key;
@@ -80,6 +84,7 @@ export function ChatSidebarSessionEntry(props: {
80
84
  })}
81
85
  runStatus={runStatus}
82
86
  context={resolveSessionContextView(session, sessionTypeOptions)}
87
+ isPinned={isPinned}
83
88
  title={sessionTitle(session)}
84
89
  previewText={previewText}
85
90
  trailingText={formatSessionListTime(
@@ -104,6 +109,7 @@ export function ChatSidebarSessionEntry(props: {
104
109
  onDraftLabelChange={onDraftLabelChange}
105
110
  onSave={() => onSaveSessionLabel(session)}
106
111
  onCancel={onCancelEditingSessionLabel}
112
+ onTogglePinned={onTogglePinned}
107
113
  />
108
114
  );
109
115
  }