@nextclaw/ui 0.15.1 → 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 (158) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assets/{api-b1R9VdeH.js → api-B7Qigvtm.js} +2 -2
  3. package/dist/assets/{appearance-settings-page-D2O9pch9.js → appearance-settings-page-CuE0Iiqj.js} +1 -1
  4. package/dist/assets/{book-open-B77lQ2Tg.js → book-open-Ba2f7YiG.js} +1 -1
  5. package/dist/assets/channels-list-page-CTsM6TcP.js +8 -0
  6. package/dist/assets/{chat-page-CZ7WxPir.js → chat-page-C_5dcuE3.js} +1 -1
  7. package/dist/assets/config-split-page-Fbb4PtTf.js +1 -0
  8. package/dist/assets/{confirm-dialog-C-NXSioX.js → confirm-dialog-D2EG3nqE.js} +1 -1
  9. package/dist/assets/{desktop-update-config-usHpXugP.js → desktop-update-config-BGR3HUi1.js} +1 -1
  10. package/dist/assets/{dist-jdGmbb6w.js → dist-7JTjycxE.js} +1 -1
  11. package/dist/assets/{dist-YtoqU-AT.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-bNpL2LKy.js → es2015-j1SRCIX5.js} +1 -1
  16. package/dist/assets/{external-link-BM6fk8q-.js → external-link-DxuPaDoK.js} +1 -1
  17. package/dist/assets/form-actions-BztL7P_T.js +1 -0
  18. package/dist/assets/{i18n-provider-CntWolRA.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-CLmUstLN.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-Cm_a4GHf.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-BN8uzPnp.js → react-UirzUuST.js} +1 -1
  29. package/dist/assets/{refresh-cw-BpVcFPmJ.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-BpFW5fpk.js → save-Cb2g0BOi.js} +1 -1
  34. package/dist/assets/{search-D0uO7enf.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-Bh-CrA4p.js → select-CdSEOwVM.js} +1 -1
  39. package/dist/assets/{setting-row-BJfCHRcb.js → setting-row-CTAtGexr.js} +1 -1
  40. package/dist/assets/{settings-2-Blzi1vr6.js → settings-2-2DWauOjY.js} +1 -1
  41. package/dist/assets/{side-dock-ZdaSrQVB.js → side-dock-DPvakJ0a.js} +2 -2
  42. package/dist/assets/{skeleton-BLlEzebZ.js → skeleton-P5oM14E1.js} +1 -1
  43. package/dist/assets/{switch-CFLJFNSd.js → switch-BU_UEULb.js} +1 -1
  44. package/dist/assets/{tag-chip-CzGihiQx.js → tag-chip-D-WsKoT1.js} +1 -1
  45. package/dist/assets/{tooltip-CdcrBjrC.js → tooltip-CjkOaHW7.js} +1 -1
  46. package/dist/assets/{use-config-CVPgsBmU.js → use-config--hx5oxQn.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-CSeBk9SF.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/channels/components/config/channel-form.tsx +14 -11
  58. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +40 -0
  59. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +3 -1
  60. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -1
  61. package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +48 -0
  62. package/src/features/chat/components/layout/__tests__/chat-sidebar-utility-menu.test.tsx +55 -0
  63. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +0 -3
  64. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +3 -4
  65. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +29 -30
  66. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -7
  67. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +2 -1
  68. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +22 -1
  69. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -5
  70. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -11
  71. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +116 -0
  72. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +108 -46
  73. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +6 -0
  74. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +48 -24
  75. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +0 -3
  76. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +0 -4
  77. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +25 -3
  78. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +9 -4
  79. package/src/features/chat/features/welcome/components/chat-welcome.tsx +0 -5
  80. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +159 -56
  81. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -0
  82. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +144 -0
  83. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +99 -0
  84. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -1
  85. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +66 -7
  86. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -0
  87. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +44 -1
  88. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +45 -3
  89. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +33 -2
  90. package/src/features/chat/managers/chat-session-list.manager.ts +32 -0
  91. package/src/features/chat/stores/chat-session-list.store.ts +28 -5
  92. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +5 -12
  93. package/src/features/panel-apps/components/panel-app-list-item.tsx +6 -6
  94. package/src/features/panel-apps/index.ts +1 -0
  95. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -10
  96. package/src/features/panel-apps/utils/panel-app-iframe.utils.ts +6 -0
  97. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -3
  98. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +3 -1
  99. package/src/features/service-apps/components/service-apps-panel.tsx +145 -35
  100. package/src/features/settings/components/config/provider-auth-section.tsx +1 -1
  101. package/src/features/settings/components/config/provider-form.tsx +11 -8
  102. package/src/features/settings/components/config/provider-models-section.tsx +7 -7
  103. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  104. package/src/features/settings/components/config/secrets-config-form.tsx +5 -4
  105. package/src/features/settings/pages/model-config-page.tsx +26 -25
  106. package/src/features/settings/pages/providers-config-page.tsx +10 -13
  107. package/src/features/settings/pages/search-config-page.tsx +8 -5
  108. package/src/features/system-status/components/config/runtime-config-editor.tsx +5 -4
  109. package/src/index.css +18 -0
  110. package/src/shared/components/config-split-page.tsx +6 -6
  111. package/src/shared/components/doc-browser/__tests__/doc-browser-url.utils.test.ts +73 -0
  112. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +48 -0
  113. package/src/shared/components/doc-browser/doc-browser-context.tsx +4 -0
  114. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +5 -1
  115. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +2 -1
  116. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +10 -10
  117. package/src/shared/components/doc-browser/doc-browser.tsx +6 -4
  118. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +62 -4
  119. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +18 -0
  120. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +28 -3
  121. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +36 -0
  122. package/src/shared/components/ui/actions/form-actions.tsx +25 -0
  123. package/src/shared/components/ui/actions/icon-action-button.tsx +25 -2
  124. package/src/shared/components/ui/card.tsx +12 -4
  125. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +2 -1
  126. package/src/shared/hooks/use-server-path-browse.ts +26 -4
  127. package/src/shared/hooks/use-server-path-read.ts +11 -1
  128. package/src/shared/lib/api/utils/server-path.utils.ts +1 -0
  129. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -0
  130. package/src/shared/lib/i18n/locales/en-US/core.json +3 -0
  131. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +5 -0
  132. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -0
  133. package/src/shared/lib/i18n/locales/zh-CN/core.json +3 -0
  134. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +5 -0
  135. package/src/shared/lib/session-project/workspace-file-breadcrumb.utils.ts +9 -5
  136. package/src/shared/lib/theme/index.test.ts +21 -0
  137. package/src/shared/lib/theme/index.ts +6 -4
  138. package/src/vite-env.d.ts +2 -0
  139. package/dist/assets/channels-list-page-DcsIQc6A.js +0 -8
  140. package/dist/assets/config-split-page-BWQ6_Raq.js +0 -1
  141. package/dist/assets/doc-browser-B_Tf9n70.js +0 -1
  142. package/dist/assets/doc-browser-BdZPFOVa.js +0 -1
  143. package/dist/assets/doc-browser-context-DwokemUn.js +0 -1
  144. package/dist/assets/index-BIcEw90u.js +0 -104
  145. package/dist/assets/index-BiNSTKTU.css +0 -1
  146. package/dist/assets/loader-circle-MBgNSVIH.js +0 -1
  147. package/dist/assets/mcp-marketplace-page-rZkmWPMy.js +0 -1
  148. package/dist/assets/model-config-page-Di0ySJD2.js +0 -1
  149. package/dist/assets/plus-vDqW5Kxp.js +0 -1
  150. package/dist/assets/providers-config-page-BAf2W0MV.js +0 -1
  151. package/dist/assets/remote-CLQ0IBM8.js +0 -1
  152. package/dist/assets/rotate-cw-CvNBv7_w.js +0 -1
  153. package/dist/assets/runtime-config-page-CflvvoDc.js +0 -1
  154. package/dist/assets/search-config-page-D0klFUXd.js +0 -1
  155. package/dist/assets/secrets-config-page-dVZ631Wp.js +0 -3
  156. package/dist/assets/security-config-C5gnlkuj.js +0 -1
  157. package/dist/assets/x-BF2KQ--V.js +0 -1
  158. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-C_iP1zl_.js} +0 -0
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
2
2
  import {
3
3
  Bot,
4
4
  BrainCircuit,
5
+ AlarmClock,
5
6
  Cpu,
6
7
  Download,
7
8
  KeyRound,
@@ -25,6 +26,11 @@ export type AppNavigationItem = {
25
26
  icon: LucideIcon;
26
27
  };
27
28
 
29
+ export type AppNavigationSection = {
30
+ label: string;
31
+ items: AppNavigationItem[];
32
+ };
33
+
28
34
  function matchesRouteTarget(pathname: string, target: string): boolean {
29
35
  const normalizedPath = pathname.toLowerCase();
30
36
  const normalizedTarget = target.toLowerCase();
@@ -80,6 +86,33 @@ export function getMobileBottomNavItems(
80
86
  ];
81
87
  }
82
88
 
89
+ export function getMainSidebarNavItems(
90
+ translate: Translate,
91
+ ): AppNavigationItem[] {
92
+ return [
93
+ {
94
+ target: "/chat",
95
+ label: translate("chat"),
96
+ icon: MessageCircle,
97
+ },
98
+ {
99
+ target: "/chat/cron",
100
+ label: translate("cron"),
101
+ icon: AlarmClock,
102
+ },
103
+ {
104
+ target: "/chat/skills",
105
+ label: translate("marketplaceFilterSkills"),
106
+ icon: BrainCircuit,
107
+ },
108
+ {
109
+ target: "/agents",
110
+ label: translate("agentsPageTitle"),
111
+ icon: Bot,
112
+ },
113
+ ];
114
+ }
115
+
83
116
  export function getSettingsNavItems(
84
117
  translate: Translate,
85
118
  ): AppNavigationItem[] {
@@ -142,6 +175,22 @@ export function getSettingsNavItems(
142
175
  ];
143
176
  }
144
177
 
178
+ export function getSettingsNavSections(
179
+ translate: Translate,
180
+ ): AppNavigationSection[] {
181
+ const items = getSettingsNavItems(translate);
182
+ return [
183
+ {
184
+ label: translate("settingsGroupBasic"),
185
+ items: items.slice(0, 3),
186
+ },
187
+ {
188
+ label: translate("settingsGroupAdvanced"),
189
+ items: items.slice(3),
190
+ },
191
+ ];
192
+ }
193
+
145
194
  export function getMobileSettingsNavItems(
146
195
  translate: Translate,
147
196
  ): AppNavigationItem[] {
@@ -284,6 +284,88 @@
284
284
  }
285
285
  }
286
286
 
287
+ @layer base {
288
+ :root[data-theme="work"] {
289
+ /* ========================================
290
+ BRAND COLORS (Work White Ink)
291
+ ======================================== */
292
+ --brand-50: 0 0% 97%;
293
+ --brand-100: 0 0% 94%;
294
+ --brand-200: 0 0% 88%;
295
+ --brand-300: 0 0% 78%;
296
+ --brand-400: 0 0% 58%;
297
+ --brand-500: 0 0% 23%;
298
+ --brand-600: 0 0% 18%;
299
+ --brand-700: 0 0% 12%;
300
+ --brand-800: 0 0% 7%;
301
+ --brand-900: 0 0% 4%;
302
+
303
+ /* ========================================
304
+ NEUTRAL COLORS (White Canvas / Soft Gray Rail)
305
+ ======================================== */
306
+ --gray-50: 0 0% 100%;
307
+ --gray-100: 0 0% 95%;
308
+ --gray-200: 0 0% 90%;
309
+ --gray-300: 0 0% 84%;
310
+ --gray-400: 0 0% 70%;
311
+ --gray-500: 0 0% 46%;
312
+ --gray-600: 0 0% 34%;
313
+ --gray-700: 0 0% 28%;
314
+ --gray-800: 0 0% 13%;
315
+ --gray-900: 0 0% 7%;
316
+
317
+ /* Semantic Colors */
318
+ --background: var(--gray-50);
319
+ --background-secondary: var(--gray-100);
320
+ --background-tertiary: var(--gray-200);
321
+ --foreground: var(--gray-800);
322
+ --foreground-secondary: var(--gray-700);
323
+ --foreground-tertiary: var(--gray-500);
324
+ --foreground-muted: var(--gray-400);
325
+ --primary: var(--brand-500);
326
+ --primary-hover: var(--brand-600);
327
+ --primary-active: var(--brand-700);
328
+ --primary-foreground: 0 0% 100%;
329
+ --secondary: var(--gray-100);
330
+ --secondary-hover: var(--gray-200);
331
+ --secondary-foreground: var(--gray-800);
332
+ --accent: var(--gray-200);
333
+ --accent-foreground: var(--gray-800);
334
+ --muted: var(--gray-100);
335
+ --muted-foreground: var(--gray-500);
336
+ --card: 0 0% 100%;
337
+ --card-foreground: var(--gray-800);
338
+ --card-border: var(--gray-200);
339
+ --popover: 0 0% 100%;
340
+ --popover-foreground: var(--gray-800);
341
+ --border: var(--gray-200);
342
+ --border-hover: var(--gray-300);
343
+ --border-active: var(--gray-600);
344
+ --input: 0 0% 100%;
345
+ --input-border: var(--gray-200);
346
+ --input-focus: var(--gray-600);
347
+ --ring: var(--gray-600);
348
+ --ring-offset: var(--gray-50);
349
+
350
+ /* Shadows (bright neutral tint) */
351
+ --shadow-xs: 0 1px 2px 0 rgb(20 24 28 / 0.03);
352
+ --shadow-sm: 0 1px 3px 0 rgb(20 24 28 / 0.05), 0 1px 2px -1px rgb(20 24 28 / 0.04);
353
+ --shadow-md: 0 4px 6px -1px rgb(20 24 28 / 0.05), 0 2px 4px -2px rgb(20 24 28 / 0.04);
354
+ --shadow-lg: 0 10px 15px -3px rgb(20 24 28 / 0.06), 0 4px 6px -4px rgb(20 24 28 / 0.04);
355
+ --shadow-xl: 0 20px 25px -5px rgb(20 24 28 / 0.08), 0 8px 10px -6px rgb(20 24 28 / 0.04);
356
+ --shadow-2xl: 0 25px 50px -12px rgb(20 24 28 / 0.14);
357
+ --shadow-card: 0 0 8px 0 rgba(20, 24, 28, 0.04);
358
+ --shadow-card-hover: 0 0 16px 0 rgba(20, 24, 28, 0.06);
359
+ }
360
+
361
+ :root[data-theme="work"] .nextclaw-chat-message-user {
362
+ background-color: hsl(var(--gray-100));
363
+ border-color: transparent;
364
+ color: hsl(var(--gray-800));
365
+ }
366
+
367
+ }
368
+
287
369
  @layer base {
288
370
  :root[data-theme="warm"] {
289
371
  /* ========================================
@@ -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>
@@ -224,6 +224,46 @@ describe("ChatConversationHeaderSection", () => {
224
224
 
225
225
  expect(screen.queryByRole("button", { name: /Switch session/ })).toBeNull();
226
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();
227
267
  });
228
268
 
229
269
  it("keeps session switching available from a collapsed new-session header", async () => {
@@ -53,6 +53,8 @@ export function ChatConversationHeaderSection({
53
53
  sessionTypeOptions.find((option) => option.value === sessionType) ?? null;
54
54
  const sessionTypeLabel =
55
55
  sessionTypeOption?.label ?? resolveSessionTypeLabel(sessionType);
56
+ const shouldShowSessionTypeBadge =
57
+ sessionType !== DEFAULT_SESSION_TYPE && Boolean(sessionTypeLabel);
56
58
  const sessionProjectRoot = selectedSession?.projectRoot ?? null;
57
59
  const sessionProjectName =
58
60
  selectedSession?.projectName ?? getSessionProjectName(sessionProjectRoot);
@@ -110,7 +112,7 @@ export function ChatConversationHeaderSection({
110
112
  ) : null
111
113
  }
112
114
  sessionTypeBadge={
113
- sessionTypeLabel ? (
115
+ shouldShowSessionTypeBadge ? (
114
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">
115
117
  {sessionTypeOption?.icon?.src ? (
116
118
  <span className="inline-flex h-[1.125rem] w-[1.125rem] items-center justify-center">
@@ -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}
@@ -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