@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
@@ -5,6 +5,7 @@ import { BookOpen, ChevronDown } from 'lucide-react';
5
5
  import { toast } from 'sonner';
6
6
  import { LogoBadge } from '@/shared/components/common/logo-badge';
7
7
  import { Button } from '@/shared/components/ui/button';
8
+ import { FormActions } from '@/shared/components/ui/actions/form-actions';
8
9
  import { StatusDot } from '@/shared/components/status/status-dot';
9
10
  import { nextclawClient } from '@/shared/lib/api';
10
11
  import { useConfig, useConfigMeta, useConfigSchema, useExecuteConfigAction, useUpdateChannel } from '@/shared/hooks/use-config';
@@ -363,17 +364,19 @@ function ChannelFormEditor({
363
364
  />
364
365
  </ConfigSplitPaneBody>
365
366
 
366
- <ConfigSplitPaneFooter className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
367
- <div className="flex flex-wrap items-center gap-2">
368
- {actions.filter((action) => action.trigger === 'manual').map((action) => (
369
- <Button key={action.id} type="button" onClick={() => void handleManualAction(action)} disabled={disabled} variant="secondary">
370
- {runningActionId === action.id ? t('connecting') : action.title}
371
- </Button>
372
- ))}
373
- </div>
374
- <Button type="submit" disabled={disabled}>
375
- {updateChannel.isPending ? t('saving') : t('save')}
376
- </Button>
367
+ <ConfigSplitPaneFooter>
368
+ <FormActions align="between">
369
+ <div className="flex flex-wrap items-center gap-2">
370
+ {actions.filter((action) => action.trigger === 'manual').map((action) => (
371
+ <Button key={action.id} type="button" size="sm" onClick={() => void handleManualAction(action)} disabled={disabled} variant="secondary">
372
+ {runningActionId === action.id ? t('connecting') : action.title}
373
+ </Button>
374
+ ))}
375
+ </div>
376
+ <Button type="submit" size="sm" disabled={disabled}>
377
+ {updateChannel.isPending ? t('saving') : t('save')}
378
+ </Button>
379
+ </FormActions>
377
380
  </ConfigSplitPaneFooter>
378
381
  </form>
379
382
  </ConfigSplitDetailPane>
@@ -7,12 +7,17 @@ import { ChatConversationHeaderSection } from "@/features/chat/components/conver
7
7
  import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
8
8
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
9
9
  import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
10
- import type { NcpSessionSummaryView } from "@/shared/lib/api";
10
+ import { viewportLayoutManager } from "@/app/managers/viewport-layout.manager";
11
+ import type { NcpSessionListItemView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
12
+ import type { NcpSessionSummaryView, SessionEntryView } from "@/shared/lib/api";
11
13
 
12
14
  const mocks = vi.hoisted(() => ({
13
15
  deleteSession: vi.fn(),
14
16
  openChildSessionPanel: vi.fn(),
15
17
  openSessionCronPanel: vi.fn(),
18
+ selectSession: vi.fn(),
19
+ sessionItems: [] as NcpSessionListItemView[],
20
+ isSessionListLoading: false,
16
21
  }));
17
22
 
18
23
  vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
@@ -22,9 +27,35 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
22
27
  openChildSessionPanel: mocks.openChildSessionPanel,
23
28
  openSessionCronPanel: mocks.openSessionCronPanel,
24
29
  },
30
+ chatSessionListManager: {
31
+ selectSession: mocks.selectSession,
32
+ },
25
33
  }),
26
34
  }));
27
35
 
36
+ vi.mock("@/features/chat/features/ncp/hooks/use-ncp-session-list-view", () => ({
37
+ useNcpSessionListView: (params?: { query?: string | null }) => {
38
+ const query = params?.query?.trim().toLowerCase() ?? "";
39
+ const items = query
40
+ ? mocks.sessionItems.filter(({ session }) =>
41
+ [
42
+ session.key,
43
+ session.label ?? "",
44
+ session.projectRoot ?? "",
45
+ session.projectName ?? "",
46
+ ]
47
+ .join(" ")
48
+ .toLowerCase()
49
+ .includes(query),
50
+ )
51
+ : mocks.sessionItems;
52
+ return {
53
+ isLoading: mocks.isSessionListLoading,
54
+ items,
55
+ };
56
+ },
57
+ }));
58
+
28
59
  vi.mock("@/features/cron", () => ({
29
60
  useCronJobs: () => ({ data: { jobs: [] } }),
30
61
  }));
@@ -54,6 +85,24 @@ function createSummary(
54
85
  };
55
86
  }
56
87
 
88
+ function createSessionListItem(
89
+ overrides: Partial<SessionEntryView> & Pick<SessionEntryView, "key">,
90
+ ): NcpSessionListItemView {
91
+ return {
92
+ session: {
93
+ createdAt: "2026-06-18T00:00:00.000Z",
94
+ updatedAt: "2026-06-18T00:00:00.000Z",
95
+ lastMessageAt: "2026-06-18T00:00:00.000Z",
96
+ readAt: "2026-06-18T00:00:00.000Z",
97
+ sessionType: "native",
98
+ sessionTypeMutable: false,
99
+ messageCount: 1,
100
+ ...overrides,
101
+ },
102
+ runStatus: overrides.status === "running" ? "running" : undefined,
103
+ };
104
+ }
105
+
57
106
  function renderHeaderSection() {
58
107
  const queryClient = new QueryClient({
59
108
  defaultOptions: {
@@ -73,6 +122,9 @@ describe("ChatConversationHeaderSection", () => {
73
122
  mocks.deleteSession.mockReset();
74
123
  mocks.openChildSessionPanel.mockReset();
75
124
  mocks.openSessionCronPanel.mockReset();
125
+ mocks.selectSession.mockReset();
126
+ mocks.isSessionListLoading = false;
127
+ viewportLayoutManager.resetForTests();
76
128
  useChatSessionListStore.persist.setOptions({
77
129
  storage: new MemoryPersistStorage(),
78
130
  });
@@ -129,6 +181,18 @@ describe("ChatConversationHeaderSection", () => {
129
181
  } as never,
130
182
  },
131
183
  });
184
+ mocks.sessionItems = [
185
+ createSessionListItem({
186
+ key: "parent-session-1",
187
+ label: "Parent Task",
188
+ }),
189
+ createSessionListItem({
190
+ key: "session:ncp-2",
191
+ label: "Background Task",
192
+ lastMessageAt: "2026-06-18T01:00:00.000Z",
193
+ readAt: "2026-06-18T01:00:00.000Z",
194
+ }),
195
+ ];
132
196
  });
133
197
 
134
198
  it("shows the child-session entry from session query state before a tool card opens it", async () => {
@@ -142,4 +206,111 @@ describe("ChatConversationHeaderSection", () => {
142
206
  activeChildSessionKey: "child-session-1",
143
207
  });
144
208
  });
209
+
210
+ it("uses the collapsed desktop title as a session switcher", async () => {
211
+ const user = userEvent.setup();
212
+ viewportLayoutManager.setSidebarCollapsed(true);
213
+
214
+ renderHeaderSection();
215
+
216
+ await user.click(screen.getByRole("button", { name: /Switch session/ }));
217
+ await user.click(screen.getByRole("button", { name: /Background Task/ }));
218
+
219
+ expect(mocks.selectSession).toHaveBeenCalledWith("session:ncp-2");
220
+ });
221
+
222
+ it("keeps the desktop title plain while the sidebar is expanded", () => {
223
+ renderHeaderSection();
224
+
225
+ expect(screen.queryByRole("button", { name: /Switch session/ })).toBeNull();
226
+ expect(screen.getByText("Parent Task")).toBeTruthy();
227
+ expect(screen.queryByText("Native")).toBeNull();
228
+ });
229
+
230
+ it("keeps the runtime badge for a non-native session", () => {
231
+ useChatQueryStore.setState({
232
+ snapshot: {
233
+ ...useChatQueryStore.getState().snapshot,
234
+ sessionsQuery: {
235
+ data: {
236
+ sessions: [
237
+ createSummary({
238
+ sessionId: "parent-session-1",
239
+ metadata: {
240
+ label: "Parent Task",
241
+ session_type: "codex",
242
+ },
243
+ }),
244
+ ],
245
+ total: 1,
246
+ },
247
+ } as never,
248
+ sessionTypesQuery: {
249
+ data: {
250
+ defaultType: "native",
251
+ options: [{ value: "codex", label: "Codex", ready: true }],
252
+ },
253
+ } as never,
254
+ },
255
+ });
256
+ mocks.sessionItems = [
257
+ createSessionListItem({
258
+ key: "parent-session-1",
259
+ label: "Parent Task",
260
+ sessionType: "codex",
261
+ }),
262
+ ];
263
+
264
+ renderHeaderSection();
265
+
266
+ expect(screen.getByText("Codex")).toBeTruthy();
267
+ });
268
+
269
+ it("keeps session switching available from a collapsed new-session header", async () => {
270
+ const user = userEvent.setup();
271
+ viewportLayoutManager.setSidebarCollapsed(true);
272
+ useChatSessionListStore.setState({
273
+ snapshot: {
274
+ ...useChatSessionListStore.getState().snapshot,
275
+ selectedSessionKey: null,
276
+ },
277
+ });
278
+ useChatThreadStore.setState({
279
+ snapshot: {
280
+ ...useChatThreadStore.getState().snapshot,
281
+ sessionKey: null,
282
+ },
283
+ });
284
+
285
+ renderHeaderSection();
286
+
287
+ await user.click(screen.getByRole("button", { name: /Switch session/ }));
288
+ await user.click(screen.getByRole("button", { name: /Background Task/ }));
289
+
290
+ expect(mocks.selectSession).toHaveBeenCalledWith("session:ncp-2");
291
+ });
292
+
293
+ it("filters the collapsed title switcher with a local search query", async () => {
294
+ const user = userEvent.setup();
295
+ viewportLayoutManager.setSidebarCollapsed(true);
296
+
297
+ renderHeaderSection();
298
+
299
+ await user.click(screen.getByRole("button", { name: /Switch session/ }));
300
+ const searchInput = screen.getByRole("textbox", {
301
+ name: "Search session key / label",
302
+ });
303
+ await user.type(searchInput, "Background");
304
+ await user.click(screen.getByRole("button", { name: /Background Task/ }));
305
+
306
+ expect(mocks.selectSession).toHaveBeenCalledWith("session:ncp-2");
307
+
308
+ await user.click(screen.getByRole("button", { name: /Switch session/ }));
309
+ await user.type(
310
+ screen.getByRole("textbox", { name: "Search session key / label" }),
311
+ "missing",
312
+ );
313
+
314
+ expect(screen.getByText("No matching sessions")).toBeTruthy();
315
+ });
145
316
  });
@@ -2,6 +2,7 @@ import { useMemo } from "react";
2
2
  import { ChatConversationHeader } from "@/features/chat/components/conversation/chat-conversation-header";
3
3
  import { ChatSessionHeaderActions } from "@/features/chat/features/session/components/session-header/chat-session-header-actions";
4
4
  import { ChatSessionProjectBadge } from "@/features/chat/features/session/components/session-header/chat-session-project-badge";
5
+ import { ChatSessionTitleSwitcher } from "@/features/chat/features/session/components/session-header/chat-session-title-switcher";
5
6
  import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
6
7
  import { SessionContextIconNode } from "@/features/chat/features/session/components/session-context-icon";
7
8
  import { useChatConversationWorkspaceState } from "@/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state";
@@ -52,6 +53,8 @@ export function ChatConversationHeaderSection({
52
53
  sessionTypeOptions.find((option) => option.value === sessionType) ?? null;
53
54
  const sessionTypeLabel =
54
55
  sessionTypeOption?.label ?? resolveSessionTypeLabel(sessionType);
56
+ const shouldShowSessionTypeBadge =
57
+ sessionType !== DEFAULT_SESSION_TYPE && Boolean(sessionTypeLabel);
55
58
  const sessionProjectRoot = selectedSession?.projectRoot ?? null;
56
59
  const sessionProjectName =
57
60
  selectedSession?.projectName ?? getSessionProjectName(sessionProjectRoot);
@@ -89,6 +92,13 @@ export function ChatConversationHeaderSection({
89
92
  <ChatConversationHeader
90
93
  layoutMode={layoutMode}
91
94
  title={sessionHeaderTitle}
95
+ titleContent={
96
+ <ChatSessionTitleSwitcher
97
+ layoutMode={layoutMode}
98
+ selectedSessionKey={sessionKey}
99
+ title={sessionHeaderTitle}
100
+ />
101
+ }
92
102
  shouldShow={shouldShowSessionHeader}
93
103
  onBackToList={onBackToList}
94
104
  leading={
@@ -102,7 +112,7 @@ export function ChatConversationHeaderSection({
102
112
  ) : null
103
113
  }
104
114
  sessionTypeBadge={
105
- sessionTypeLabel ? (
115
+ shouldShowSessionTypeBadge ? (
106
116
  <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
107
117
  {sessionTypeOption?.icon?.src ? (
108
118
  <span className="inline-flex h-[1.125rem] w-[1.125rem] items-center justify-center">
@@ -41,6 +41,7 @@ export function ChatConversationHeader({
41
41
  sessionTypeBadge,
42
42
  shouldShow,
43
43
  title,
44
+ titleContent,
44
45
  }: {
45
46
  layoutMode: "desktop" | "mobile";
46
47
  actions?: ReactNode;
@@ -50,6 +51,7 @@ export function ChatConversationHeader({
50
51
  sessionTypeBadge?: ReactNode;
51
52
  shouldShow: boolean;
52
53
  title: string;
54
+ titleContent?: ReactNode;
53
55
  }) {
54
56
  const isMobileLayout = layoutMode === "mobile";
55
57
 
@@ -78,9 +80,11 @@ export function ChatConversationHeader({
78
80
  />
79
81
  ) : null}
80
82
  {leading}
81
- <span className="text-sm font-medium text-foreground truncate">
82
- {title}
83
- </span>
83
+ {titleContent ?? (
84
+ <span className="text-sm font-medium text-foreground truncate">
85
+ {title}
86
+ </span>
87
+ )}
84
88
  {sessionTypeBadge}
85
89
  {projectBadge}
86
90
  </div>
@@ -34,7 +34,7 @@ export function ChatConversationPanel({
34
34
  }
35
35
 
36
36
  return (
37
- <section className="flex-1 min-h-0 flex overflow-hidden bg-background">
37
+ <section className="relative flex-1 min-h-0 flex overflow-hidden bg-background">
38
38
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
39
39
  <ChatConversationParentBanner />
40
40
  <ChatConversationHeaderSection
@@ -0,0 +1,48 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ ChatSidebarDesktopToolbar,
5
+ ChatSidebarMobileToolbar,
6
+ } from "@/features/chat/components/layout/chat-sidebar-toolbar";
7
+ import type { ChatSessionTypeOption } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
8
+
9
+ const sessionTypeOptions: ChatSessionTypeOption[] = [
10
+ { value: "native", label: "Native", icon: null, ready: true },
11
+ { value: "codex", label: "Codex", icon: null, ready: true },
12
+ ];
13
+
14
+ const toolbarProps = {
15
+ query: "",
16
+ defaultSessionType: "native",
17
+ sessionTypeOptions,
18
+ nonDefaultSessionTypeOptions: [sessionTypeOptions[1]],
19
+ selectedNewSessionType: "native",
20
+ selectedNewSessionTypeOption: sessionTypeOptions[0],
21
+ isCreateMenuOpen: false,
22
+ onCreateMenuOpenChange: vi.fn(),
23
+ onCreateSession: vi.fn(),
24
+ onSelectNewSessionType: vi.fn(),
25
+ onQueryChange: vi.fn(),
26
+ };
27
+
28
+ function searchIconClassName() {
29
+ return (
30
+ screen
31
+ .getByPlaceholderText("Search conversations...")
32
+ .previousElementSibling?.getAttribute("class") ?? ""
33
+ );
34
+ }
35
+
36
+ describe("ChatSidebarToolbar", () => {
37
+ it("keeps desktop search icon transparent to pointer input", () => {
38
+ render(<ChatSidebarDesktopToolbar {...toolbarProps} />);
39
+
40
+ expect(searchIconClassName()).toContain("pointer-events-none");
41
+ });
42
+
43
+ it("keeps mobile search icon transparent to pointer input", () => {
44
+ render(<ChatSidebarMobileToolbar {...toolbarProps} />);
45
+
46
+ expect(searchIconClassName()).toContain("pointer-events-none");
47
+ });
48
+ });
@@ -0,0 +1,55 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { MemoryRouter } from "react-router-dom";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { ChatSidebarUtilityMenu } from "@/features/chat/components/layout/chat-sidebar-utility-menu";
5
+
6
+ function renderUtilityMenu() {
7
+ return render(
8
+ <MemoryRouter>
9
+ <ChatSidebarUtilityMenu
10
+ isOpen
11
+ onOpenChange={vi.fn()}
12
+ currentTheme="warm"
13
+ currentThemeLabel="Warm"
14
+ themeOptions={[{ value: "warm", label: "Warm" }]}
15
+ onSelectTheme={vi.fn()}
16
+ currentLanguage="en"
17
+ currentLanguageLabel="English"
18
+ languageOptions={[{ value: "en", label: "English" }]}
19
+ onSelectLanguage={vi.fn()}
20
+ onOpenDocs={vi.fn()}
21
+ onOpenApps={vi.fn()}
22
+ />
23
+ </MemoryRouter>,
24
+ );
25
+ }
26
+
27
+ function appendComposerFocusTarget(): HTMLElement {
28
+ const composerShell = document.createElement("div");
29
+ composerShell.className = "nextclaw-chat-input-bar-shell";
30
+ const composer = document.createElement("div");
31
+ composer.setAttribute("contenteditable", "true");
32
+ composer.setAttribute("role", "textbox");
33
+ composer.tabIndex = 0;
34
+ composerShell.appendChild(composer);
35
+ document.body.appendChild(composerShell);
36
+ return composer;
37
+ }
38
+
39
+ describe("ChatSidebarUtilityMenu", () => {
40
+ it("stays open when the streaming composer restores focus", () => {
41
+ const composer = appendComposerFocusTarget();
42
+
43
+ try {
44
+ renderUtilityMenu();
45
+
46
+ expect(screen.getByRole("button", { name: "Help Docs" })).toBeTruthy();
47
+
48
+ fireEvent.focusIn(composer);
49
+
50
+ expect(screen.getByRole("button", { name: "Help Docs" })).toBeTruthy();
51
+ } finally {
52
+ composer.closest(".nextclaw-chat-input-bar-shell")?.remove();
53
+ }
54
+ });
55
+ });
@@ -134,7 +134,6 @@ export function ChatSidebarSessionArea({
134
134
  isCollapsed,
135
135
  isLoading,
136
136
  isProjectFirstView,
137
- onCreateSession,
138
137
  onSelectMode,
139
138
  projectGroups,
140
139
  renderSessionItem,
@@ -145,7 +144,6 @@ export function ChatSidebarSessionArea({
145
144
  isCollapsed: boolean;
146
145
  isLoading: boolean;
147
146
  isProjectFirstView: boolean;
148
- onCreateSession: (sessionType: string, projectRoot?: string | null) => void;
149
147
  onSelectMode: (mode: "time-first" | "project-first") => void;
150
148
  projectGroups: ReturnType<typeof groupSessionsByProject>;
151
149
  renderSessionItem: (item: NcpSessionListItemView) => JSX.Element;
@@ -183,7 +181,6 @@ export function ChatSidebarSessionArea({
183
181
  defaultSessionType={defaultSessionType}
184
182
  sessionTypeOptions={sessionTypeOptions}
185
183
  renderSessionItem={renderSessionItem}
186
- onCreateSession={onCreateSession}
187
184
  />
188
185
  </div>
189
186
  </>
@@ -16,7 +16,6 @@ import {
16
16
  SIDEBAR_RAIL_CONTROL_CLASS,
17
17
  SIDEBAR_RAIL_ICON_CLASS,
18
18
  SIDEBAR_RAIL_ITEM_GAP_CLASS,
19
- SIDEBAR_RAIL_PRIMARY_SURFACE_CLASS,
20
19
  SIDEBAR_RAIL_SURFACE_CLASS,
21
20
  } from "@/app/components/layout/sidebar-rail.styles";
22
21
 
@@ -150,7 +149,7 @@ export function ChatSidebarDesktopToolbar(props: ChatSidebarToolbarProps) {
150
149
  label={t("chatSidebarNewTask")}
151
150
  className={cn(
152
151
  SIDEBAR_RAIL_CONTROL_CLASS,
153
- SIDEBAR_RAIL_PRIMARY_SURFACE_CLASS,
152
+ SIDEBAR_RAIL_SURFACE_CLASS,
154
153
  )}
155
154
  onClick={() => {
156
155
  onCreateMenuOpenChange(false);
@@ -259,7 +258,7 @@ export function ChatSidebarDesktopToolbar(props: ChatSidebarToolbarProps) {
259
258
 
260
259
  <div className="px-4 pb-3">
261
260
  <div className="relative">
262
- <Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground/70" />
261
+ <Search className="pointer-events-none absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground/70" />
263
262
  <Input
264
263
  value={query}
265
264
  onChange={(event) => onQueryChange(event.target.value)}
@@ -294,7 +293,7 @@ export function ChatSidebarMobileToolbar(props: ChatSidebarToolbarProps) {
294
293
  <div className="px-4 pb-2 pt-1">
295
294
  <div className="flex items-center gap-2">
296
295
  <div className="relative min-w-0 flex-1">
297
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
296
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
298
297
  <Input
299
298
  value={query}
300
299
  onChange={(event) => onQueryChange(event.target.value)}
@@ -34,10 +34,7 @@ import {
34
34
  type LucideIcon,
35
35
  } from "lucide-react";
36
36
 
37
- type ChatSidebarUtilityOption<Value extends string> = {
38
- value: Value;
39
- label: string;
40
- };
37
+ type ChatSidebarUtilityOption<Value extends string> = { value: Value; label: string };
41
38
 
42
39
  type ChatSidebarUtilityMenuProps = {
43
40
  isOpen: boolean;
@@ -55,6 +52,12 @@ type ChatSidebarUtilityMenuProps = {
55
52
  collapsed?: boolean;
56
53
  };
57
54
 
55
+ function isChatComposerFocusTarget(target: EventTarget | null): boolean {
56
+ return target instanceof HTMLElement && Boolean(
57
+ target.closest('.nextclaw-chat-input-bar-shell [role="textbox"][contenteditable="true"]'),
58
+ );
59
+ }
60
+
58
61
  export function ChatSidebarUtilityMenu({
59
62
  isOpen,
60
63
  onOpenChange,
@@ -70,16 +73,6 @@ export function ChatSidebarUtilityMenu({
70
73
  onOpenApps,
71
74
  collapsed = false,
72
75
  }: ChatSidebarUtilityMenuProps) {
73
- const handleOpenDocs = () => {
74
- onOpenDocs();
75
- onOpenChange(false);
76
- };
77
-
78
- const handleOpenApps = () => {
79
- onOpenApps();
80
- onOpenChange(false);
81
- };
82
-
83
76
  return (
84
77
  <Popover open={isOpen} onOpenChange={onOpenChange}>
85
78
  {collapsed ? (
@@ -129,6 +122,11 @@ export function ChatSidebarUtilityMenu({
129
122
  side={collapsed ? "right" : "top"}
130
123
  align={collapsed ? "end" : "start"}
131
124
  className="w-64 p-2"
125
+ onFocusOutside={(event) => {
126
+ if (isChatComposerFocusTarget(event.detail.originalEvent.target)) {
127
+ event.preventDefault();
128
+ }
129
+ }}
132
130
  >
133
131
  <div className="space-y-1">
134
132
  <NavLink
@@ -139,22 +137,23 @@ export function ChatSidebarUtilityMenu({
139
137
  <Settings className="h-4 w-4 text-muted-foreground/70" />
140
138
  <span className="flex-1 text-left">{t("settings")}</span>
141
139
  </NavLink>
142
- <button
143
- type="button"
144
- onClick={handleOpenDocs}
145
- className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900"
146
- >
147
- <BookOpen className="h-4 w-4 text-muted-foreground/70" />
148
- <span className="flex-1 text-left">{t("docBrowserHelp")}</span>
149
- </button>
150
- <button
151
- type="button"
152
- onClick={handleOpenApps}
153
- className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900"
154
- >
155
- <Boxes className="h-4 w-4 text-muted-foreground/70" />
156
- <span className="flex-1 text-left">{t("appsTitle")}</span>
157
- </button>
140
+ {[
141
+ { key: "docs", icon: BookOpen, label: t("docBrowserHelp"), action: onOpenDocs },
142
+ { key: "apps", icon: Boxes, label: t("appsTitle"), action: onOpenApps },
143
+ ].map(({ key, icon: Icon, label, action }) => (
144
+ <button
145
+ key={key}
146
+ type="button"
147
+ onClick={() => {
148
+ action();
149
+ onOpenChange(false);
150
+ }}
151
+ className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900"
152
+ >
153
+ <Icon className="h-4 w-4 text-muted-foreground/70" />
154
+ <span className="flex-1 text-left">{label}</span>
155
+ </button>
156
+ ))}
158
157
  </div>
159
158
 
160
159
  <div className="my-2 h-px bg-border" />
@@ -105,7 +105,7 @@ export function ChatSidebar({
105
105
  const { language, setLanguage } = useI18n();
106
106
  const { theme, setTheme } = useTheme();
107
107
  const currentThemeLabel = t(
108
- THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? "themeNatural",
108
+ THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? "themeWork",
109
109
  );
110
110
  const currentLanguageLabel =
111
111
  LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
@@ -132,6 +132,14 @@ export function ChatSidebar({
132
132
  ),
133
133
  [agentsQuery.data?.agents],
134
134
  );
135
+ const pinnedSessionKeys = useMemo(
136
+ () => new Set(listSnapshot.pinnedSessionKeys),
137
+ [listSnapshot.pinnedSessionKeys],
138
+ );
139
+ const pinnedProjectRoots = useMemo(
140
+ () => new Set(listSnapshot.pinnedProjectRoots),
141
+ [listSnapshot.pinnedProjectRoots],
142
+ );
135
143
  const sortedItems = useMemo(
136
144
  () => sortSessionItemsByActivityAtDesc(items),
137
145
  [items],
@@ -140,10 +148,13 @@ export function ChatSidebar({
140
148
  () => groupChildSessionsByParentKey(items),
141
149
  [items],
142
150
  );
143
- const groups = useMemo(() => groupSessionsByDate(sortedItems), [sortedItems]);
151
+ const groups = useMemo(
152
+ () => groupSessionsByDate(sortedItems, pinnedSessionKeys),
153
+ [pinnedSessionKeys, sortedItems],
154
+ );
144
155
  const projectGroups = useMemo(
145
- () => groupSessionsByProject(sortedItems),
146
- [sortedItems],
156
+ () => groupSessionsByProject(sortedItems, pinnedSessionKeys, pinnedProjectRoots),
157
+ [pinnedProjectRoots, pinnedSessionKeys, sortedItems],
147
158
  );
148
159
  const sessionTypeOptions = useMemo(
149
160
  () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
@@ -200,6 +211,7 @@ export function ChatSidebar({
200
211
  draftLabel={draftLabel}
201
212
  savingSessionKey={savingSessionKey}
202
213
  sessionTypeOptions={sessionTypeOptions}
214
+ isPinned={pinnedSessionKeys.has(item.session.key)}
203
215
  sessionTitle={getSessionTitle}
204
216
  onSelectSession={presenter.chatSessionListManager.selectSession}
205
217
  onOpenChildSessions={(parentSessionKey, activeChildSessionKey) =>
@@ -212,6 +224,9 @@ export function ChatSidebar({
212
224
  onDraftLabelChange={setDraftLabel}
213
225
  onSaveSessionLabel={saveSessionLabel}
214
226
  onCancelEditingSessionLabel={cancelEditingSessionLabel}
227
+ onTogglePinned={() =>
228
+ presenter.chatSessionListManager.toggleSessionPinned(item.session.key)
229
+ }
215
230
  />
216
231
  );
217
232
  const createSessionAndOpenIfNeeded = (
@@ -233,9 +248,9 @@ export function ChatSidebar({
233
248
  : shouldCollapse
234
249
  ? cn(
235
250
  SIDEBAR_RAIL_WIDTH_CLASS,
236
- "shrink-0 border-r border-border/70",
251
+ "shrink-0",
237
252
  )
238
- : "w-[280px] shrink-0 border-r border-border/70",
253
+ : "w-[280px] shrink-0",
239
254
  )}
240
255
  data-sidebar-collapsed={shouldCollapse ? "true" : "false"}
241
256
  >
@@ -295,7 +310,6 @@ export function ChatSidebar({
295
310
  isCollapsed={shouldCollapse}
296
311
  isLoading={isLoading}
297
312
  isProjectFirstView={isProjectFirstView}
298
- onCreateSession={createSessionAndOpenIfNeeded}
299
313
  onSelectMode={presenter.chatSessionListManager.setListMode}
300
314
  projectGroups={projectGroups}
301
315
  renderSessionItem={renderSessionItem}