@nextclaw/ui 0.15.21 → 0.15.22

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 (161) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/assets/{api-CHo2gekJ.js → api-B1yHQYs-.js} +3 -3
  3. package/dist/assets/app-presenter-provider-uP9waOzv.js +108 -0
  4. package/dist/assets/appearance-settings-page-C9Zq3hP9.js +1 -0
  5. package/dist/assets/{book-open-BrZL4o9L.js → book-open-oSmyVXea.js} +1 -1
  6. package/dist/assets/{card-B5rRLu_6.js → card-CGvJZTZp.js} +1 -1
  7. package/dist/assets/{channels-list-page-C1dnm68I.js → channels-list-page-5BTxpQHw.js} +2 -2
  8. package/dist/assets/chat-page-jTvli8Jp.js +1 -0
  9. package/dist/assets/config-split-page-BgBsUmp1.js +1 -0
  10. package/dist/assets/confirm-dialog-h849QRWO.js +5 -0
  11. package/dist/assets/desktop-update-config-BGGHxClR.js +1 -0
  12. package/dist/assets/{dist-C9EX46wM.js → dist-CxUhurq9.js} +1 -1
  13. package/dist/assets/doc-browser-0eL8AX42.js +1 -0
  14. package/dist/assets/{doc-browser-DH-3hDnI.js → doc-browser-DEqE9lsh.js} +1 -1
  15. package/dist/assets/{doc-browser-context-TU2heon0.js → doc-browser-context-DkzFIUoK.js} +1 -1
  16. package/dist/assets/{eye-B-3dyZNA.js → eye-gOWQGt8N.js} +1 -1
  17. package/dist/assets/{form-actions-DvvxCpL4.js → form-actions-CgjYZUzM.js} +1 -1
  18. package/dist/assets/{index-k_eWScvN.js → index-DSAUWCfl.js} +2 -2
  19. package/dist/assets/index-G7cBryJ4.css +1 -0
  20. package/dist/assets/loader-circle-BAuycGf6.js +1 -0
  21. package/dist/assets/{logo-badge-DB51c0cS.js → logo-badge-BNcsDsUM.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-DWtsIqml.js → mcp-marketplace-page-BX39MtPa.js} +3 -3
  23. package/dist/assets/mcp-marketplace-page-CkDtOEA4.js +1 -0
  24. package/dist/assets/{model-config-page-D6cGNnMM.js → model-config-page-7MIXg2fQ.js} +1 -1
  25. package/dist/assets/{navigation-link-BSJEUiwD.js → navigation-link-B3iFOdxx.js} +1 -1
  26. package/dist/assets/plus-yvhFgfal.js +1 -0
  27. package/dist/assets/{popover-Bp9IpE_N.js → popover-Cpep_rJe.js} +1 -1
  28. package/dist/assets/{provider-models-CXUgDxGR.js → provider-models-CUrptZ-o.js} +1 -1
  29. package/dist/assets/provider-scoped-model-input-CHmOzz7x.js +1 -0
  30. package/dist/assets/providers-config-page-A6J7Y0II.js +1 -0
  31. package/dist/assets/react-BCJGCjrP.js +8 -0
  32. package/dist/assets/{refresh-cw-C2e94pmp.js → refresh-cw-CkKwPztT.js} +1 -1
  33. package/dist/assets/remote-FD-ZfSIn.js +1 -0
  34. package/dist/assets/rotate-cw-DrJMfHhp.js +1 -0
  35. package/dist/assets/{runtime-config-page-C57OLdPm.js → runtime-config-page-DwjO-Vs6.js} +1 -1
  36. package/dist/assets/{save-CkwDOgvt.js → save-BINMWate.js} +1 -1
  37. package/dist/assets/{search-CLXM2reO.js → search-7M38azRp.js} +1 -1
  38. package/dist/assets/{search-config-page-tnJXaaSr.js → search-config-page-T6tMhXbP.js} +1 -1
  39. package/dist/assets/{secrets-config-page-DQ_Curaj.js → secrets-config-page-2mVejXf2.js} +2 -2
  40. package/dist/assets/security-config-DD5RrjDe.js +1 -0
  41. package/dist/assets/{select-CNFBf00l.js → select-BvidP5Oa.js} +1 -1
  42. package/dist/assets/{settings-2-B9IT2HGd.js → settings-2-DqZstREV.js} +1 -1
  43. package/dist/assets/skeleton-BEQW2Dva.js +1 -0
  44. package/dist/assets/{tabs-BuNr8LSO.js → tabs-BeLxVmkt.js} +1 -1
  45. package/dist/assets/{tag-chip-CDkB5smU.js → tag-chip-BoDaLnWt.js} +1 -1
  46. package/dist/assets/{trash-2-GchaJuEM.js → trash-2-CYUskMMN.js} +1 -1
  47. package/dist/assets/{use-config-C69rR15i.js → use-config-CxKRXXCl.js} +1 -1
  48. package/dist/assets/use-confirm-dialog-HASTvOP2.js +1 -0
  49. package/dist/assets/{use-viewport-layout-BACNZyMY.js → use-viewport-layout-CJxkjoRz.js} +1 -1
  50. package/dist/assets/x-DdUwjre5.js +1 -0
  51. package/dist/index.html +29 -29
  52. package/package.json +7 -7
  53. package/src/app/components/layout/sidebar-rail.styles.ts +0 -2
  54. package/src/app/components/layout/sidebar.tsx +2 -2
  55. package/src/app/styles/design-system.css +12 -0
  56. package/src/features/chat/components/chat-sidebar-list-mode-switch.tsx +2 -2
  57. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +19 -1
  58. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +1 -1
  59. package/src/features/chat/components/conversation/chat-conversation-content.tsx +5 -6
  60. package/src/features/chat/components/conversation/chat-conversation-header.tsx +1 -1
  61. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +1 -1
  62. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +52 -0
  63. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +11 -17
  64. package/src/features/chat/components/layout/chat-page-shell.tsx +2 -11
  65. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +32 -42
  66. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +13 -64
  67. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +1 -4
  68. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -1
  69. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +15 -4
  70. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +7 -7
  71. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -2
  72. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +1 -0
  73. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +1 -0
  74. package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +1 -0
  75. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +3 -0
  76. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +0 -1
  77. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +3 -0
  78. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +29 -0
  79. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +4 -1
  80. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +10 -10
  81. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  82. package/src/features/chat/features/session/components/session-header/chat-session-header-menu-item.tsx +1 -1
  83. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +1 -1
  84. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +1 -1
  85. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +1 -1
  86. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +26 -0
  87. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +4 -2
  88. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +48 -12
  89. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +13 -4
  90. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +6 -0
  91. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +10 -7
  92. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +1 -1
  93. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +3 -14
  94. package/src/features/chat/features/welcome/components/chat-welcome-prompt-suggestions.tsx +37 -0
  95. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +1 -1
  96. package/src/features/chat/features/welcome/components/chat-welcome.tsx +33 -31
  97. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -1
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -1
  99. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +15 -3
  100. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +3 -10
  101. package/src/features/chat/managers/chat-session-list.manager.ts +3 -23
  102. package/src/features/chat/managers/chat-thread.manager.ts +2 -5
  103. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +3 -9
  104. package/src/features/chat/pages/ncp-chat-page.tsx +0 -1
  105. package/src/features/chat/types/chat-input-bar.types.ts +1 -0
  106. package/src/features/cron/components/cron-task-discovery.tsx +1 -1
  107. package/src/features/marketplace/components/marketplace-page-parts.tsx +1 -1
  108. package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +3 -0
  109. package/src/features/notifications/components/app-notification-toast.tsx +1 -1
  110. package/src/features/panel-apps/components/panel-app-list-item.tsx +1 -1
  111. package/src/features/panel-apps/components/panel-app-toolbar.tsx +2 -2
  112. package/src/features/panel-apps/components/panel-apps-list.tsx +4 -4
  113. package/src/features/service-apps/components/service-apps-panel.tsx +1 -1
  114. package/src/features/settings/components/config/provider-auth-section.tsx +90 -59
  115. package/src/features/settings/components/config/provider-form.tsx +10 -17
  116. package/src/features/settings/components/config/provider-status-badge.tsx +4 -2
  117. package/src/features/settings/pages/__tests__/providers-config-page.test.tsx +12 -0
  118. package/src/features/settings/pages/providers-config-page.tsx +2 -1
  119. package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +13 -7
  120. package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +18 -20
  121. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +1 -1
  122. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +1 -1
  123. package/src/shared/components/path-picker/server-path-picker-locations.tsx +1 -1
  124. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +3 -3
  125. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +1 -1
  126. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -1
  127. package/src/shared/components/ui/button.tsx +4 -4
  128. package/src/shared/components/ui/dialog.tsx +1 -1
  129. package/src/shared/components/ui/input.tsx +1 -1
  130. package/src/shared/components/ui/scroll-area.tsx +2 -0
  131. package/src/shared/components/ui/select.tsx +1 -1
  132. package/src/shared/components/ui/switch.tsx +1 -1
  133. package/src/shared/components/ui/tabs.tsx +1 -1
  134. package/src/shared/components/ui/textarea.tsx +1 -1
  135. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  136. package/src/shared/lib/api/types.ts +2 -0
  137. package/src/shared/lib/i18n/locales/en-US/chat.json +13 -14
  138. package/src/shared/lib/i18n/locales/zh-CN/chat.json +13 -14
  139. package/src/shared/lib/provider-models/__tests__/anonymous-provider-configuration.test.ts +40 -0
  140. package/src/shared/lib/provider-models/index.ts +4 -3
  141. package/dist/assets/app-presenter-provider-2_DmWYT9.js +0 -108
  142. package/dist/assets/appearance-settings-page-BEy2UE7R.js +0 -1
  143. package/dist/assets/chat-page-Dz6tvZ91.js +0 -1
  144. package/dist/assets/config-split-page-6y6W6dz4.js +0 -1
  145. package/dist/assets/confirm-dialog-q1CpEmR8.js +0 -5
  146. package/dist/assets/desktop-update-config-DrdlwP4j.js +0 -1
  147. package/dist/assets/doc-browser-BfttDSti.js +0 -1
  148. package/dist/assets/index-CXDPLDFD.css +0 -1
  149. package/dist/assets/loader-circle-B35NlHTI.js +0 -1
  150. package/dist/assets/mcp-marketplace-page-CkPtwPBk.js +0 -1
  151. package/dist/assets/plus-BRVe5jSJ.js +0 -1
  152. package/dist/assets/provider-scoped-model-input-dk7fz9NL.js +0 -1
  153. package/dist/assets/providers-config-page-DTP2YfxA.js +0 -1
  154. package/dist/assets/react-D6ShNq95.js +0 -8
  155. package/dist/assets/remote-FllhXW3o.js +0 -1
  156. package/dist/assets/rotate-cw-CTC-Qszi.js +0 -1
  157. package/dist/assets/security-config-S00DjFfO.js +0 -1
  158. package/dist/assets/skeleton-Dc06gatK.js +0 -1
  159. package/dist/assets/use-confirm-dialog-DF_EgPYx.js +0 -1
  160. package/dist/assets/x-BXFquY9e.js +0 -1
  161. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +0 -59
@@ -3,9 +3,11 @@ import {
3
3
  useEffect,
4
4
  useMemo,
5
5
  useState,
6
+ type ReactElement,
6
7
  type MutableRefObject,
7
8
  } from 'react';
8
9
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
10
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
9
11
  import { afterEach, describe, expect, it, vi } from 'vitest';
10
12
  import {
11
13
  createChatComposerTextNode,
@@ -30,6 +32,15 @@ import { useChatMessageLayoutStore } from '@/features/chat/stores/chat-message-l
30
32
 
31
33
  const uploadNcpAssetsMock = vi.hoisted(() => vi.fn());
32
34
 
35
+ function renderInput(input: ReactElement) {
36
+ const queryClient = new QueryClient({
37
+ defaultOptions: { queries: { retry: false } },
38
+ });
39
+ return render(
40
+ <QueryClientProvider client={queryClient}>{input}</QueryClientProvider>,
41
+ );
42
+ }
43
+
33
44
  afterEach(() => {
34
45
  useChatMessageLayoutStore.getState().setLayout('card');
35
46
  });
@@ -254,7 +265,7 @@ describe('SessionConversationInput streaming stability', () => {
254
265
  useChatMessageLayoutStore.getState().setLayout('flat');
255
266
  const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
256
267
 
257
- render(<StreamingSessionConversationInputHarness controlRef={controlRef} />);
268
+ renderInput(<StreamingSessionConversationInputHarness controlRef={controlRef} />);
258
269
 
259
270
  const track = document.querySelector(
260
271
  '[data-chat-conversation-track="flat"][data-chat-conversation-track-width="composer"]',
@@ -266,7 +277,7 @@ describe('SessionConversationInput streaming stability', () => {
266
277
 
267
278
  it('keeps a numbered IME candidate commit stable while streamed output rerenders the owner', async () => {
268
279
  const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
269
- render(<StreamingSessionConversationInputHarness controlRef={controlRef} />);
280
+ renderInput(<StreamingSessionConversationInputHarness controlRef={controlRef} />);
270
281
 
271
282
  const textbox = screen.getByRole('textbox');
272
283
  fireEvent.focus(textbox);
@@ -300,7 +311,7 @@ describe('SessionConversationInput streaming stability', () => {
300
311
  const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
301
312
  const deleteQueuedInput = vi.fn();
302
313
  const editQueuedInput = vi.fn();
303
- render(
314
+ renderInput(
304
315
  <StreamingSessionConversationInputHarness
305
316
  controlRef={controlRef}
306
317
  controllerOverride={{
@@ -427,7 +438,7 @@ describe('SessionConversationInput attachment submit', () => {
427
438
  ]);
428
439
  const send = vi.fn<AttachmentSubmitAgentSend>(async () => createAttachmentRunHandle());
429
440
 
430
- render(<AttachmentSubmitHarness send={send} />);
441
+ renderInput(<AttachmentSubmitHarness send={send} />);
431
442
 
432
443
  const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null;
433
444
  expect(fileInput).toBeTruthy();
@@ -13,6 +13,7 @@ import {
13
13
  resolveNcpChatSendErrorMessage,
14
14
  } from "@/features/chat/features/runtime/utils/ncp-chat-runtime-availability.utils";
15
15
  import { buildChatContextWindowIndicator } from "@/features/chat/features/session/utils/chat-context-window-indicator.utils";
16
+ import { sessionActivityPreviewText } from "@/features/chat/features/session/utils/chat-session-display.utils";
16
17
  import { readNcpContextWindowValue } from "@/features/chat/features/session/utils/ncp-session-context-metadata.utils";
17
18
  import { ChatConversationWelcome } from "@/features/chat/features/welcome/components/chat-conversation-welcome";
18
19
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
@@ -326,12 +327,10 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
326
327
  }),
327
328
  [inputSnapshot, lastSendError],
328
329
  );
329
- const sessionFailurePreview = inputQuery.selectedSession?.activityPreview;
330
+ const { selectedSession } = inputQuery;
330
331
  const sessionFailureMessage =
331
- sessionFailurePreview?.state === "failed"
332
- ? sessionFailurePreview.statusText?.trim() ||
333
- sessionFailurePreview.replyText?.trim() ||
334
- null
332
+ selectedSession?.activityPreview?.state === "failed"
333
+ ? sessionActivityPreviewText(selectedSession)
335
334
  : null;
336
335
  const sessionFailureSlot = sessionFailureMessage ? (
337
336
  <div className="rounded-lg border border-red-200/80 bg-red-50/80 px-3 py-2.5 shadow-sm">
@@ -348,7 +347,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
348
347
  applyPromptSuggestion: inputActions.applyPromptSuggestion,
349
348
  });
350
349
  const renderInput = useCallback(
351
- (surface: "default" | "embedded") => (
350
+ (surface: "default" | "embedded", placeholder?: string) => (
352
351
  <SessionConversationInput
353
352
  contextWindow={contextWindow}
354
353
  controller={inputController}
@@ -356,6 +355,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
356
355
  inputQuery={inputQuery}
357
356
  inputSnapshot={displayInputSnapshot}
358
357
  onContextCompactingChange={handleContextCompactingChange}
358
+ placeholder={placeholder}
359
359
  surface={surface}
360
360
  />
361
361
  ),
@@ -396,7 +396,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
396
396
  onLoadPreviousMessages={agent.loadPreviousMessages}
397
397
  welcomeSlot={
398
398
  <ChatConversationWelcome
399
- inputSlot={renderInput("embedded")}
399
+ inputSlot={renderInput("embedded", t("chatWelcomeInputPlaceholder"))}
400
400
  pendingProjectRoot={inputSnapshot.pendingProjectRoot}
401
401
  pendingSessionType={inputSnapshot.pendingSessionType}
402
402
  selectedSessionTypeValue={inputSnapshot.selectedSessionType}
@@ -193,6 +193,7 @@ type SessionConversationInputProps = {
193
193
  readonly inputQuery: SessionConversationInputQuery;
194
194
  readonly inputSnapshot: SessionConversationInputSnapshot;
195
195
  readonly onContextCompactingChange?: (sessionId: string, isCompacting: boolean) => void;
196
+ readonly placeholder?: string;
196
197
  readonly surface?: 'default' | 'embedded';
197
198
  };
198
199
 
@@ -204,6 +205,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
204
205
  inputQuery,
205
206
  inputSnapshot,
206
207
  onContextCompactingChange,
208
+ placeholder,
207
209
  surface = 'default',
208
210
  } = props;
209
211
  const presenter = usePresenter();
@@ -277,7 +279,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
277
279
  const attachmentSupported = true;
278
280
  const textareaPlaceholder = isModelOptionsEmpty
279
281
  ? t('chatModelNoOptions')
280
- : t(isMobile ? 'chatInputPlaceholderCompact' : 'chatInputPlaceholder');
282
+ : placeholder ?? t(isMobile ? 'chatInputPlaceholderCompact' : 'chatInputPlaceholder');
281
283
  const selectedModelOption = modelRecords.find((option) => option.value === selectedModel);
282
284
  const thinkingSupportedLevels = selectedModelOption?.thinkingCapability?.supported ?? [];
283
285
  const { handleFilesAdd, handleFileInputChange } = useSessionConversationInputAttachments({
@@ -426,6 +428,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
426
428
  },
427
429
  })}
428
430
  toolbar={{
431
+ addMenuLabel: t('chatInputAdd'),
429
432
  selects: [],
430
433
  trailingSelects: toolbarSelects,
431
434
  accessories: [
@@ -433,7 +436,6 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
433
436
  key: 'attach',
434
437
  label: t('chatInputAttach'),
435
438
  icon: 'paperclip' as const,
436
- iconOnly: true,
437
439
  disabled: !attachmentSupported || inputDisabled,
438
440
  onClick: () => fileInputRef.current?.click(),
439
441
  },
@@ -46,6 +46,7 @@ export function useSessionConversationInputAttachments(params: UseSessionConvers
46
46
  insertedAttachments.map((attachment) => ({
47
47
  tokenKey: attachment.id,
48
48
  label: attachment.name,
49
+ previewUrl: attachment.mimeType.startsWith('image/') ? attachment.url : undefined,
49
50
  })),
50
51
  );
51
52
  }
@@ -82,6 +82,7 @@ export function buildSessionConversationToolbarSelects(params: ToolbarSelectBuil
82
82
  favoriteModelsLabel,
83
83
  favoriteModelLabel,
84
84
  unfavoriteModelLabel,
85
+ manageModelsLabel: t('chatManageModels'),
85
86
  recentModelsLabel,
86
87
  allModelsLabel,
87
88
  },
@@ -115,6 +115,7 @@ export function buildSessionQueuedInputComposerSnapshot(
115
115
  tokenKind: 'file',
116
116
  tokenKey: id,
117
117
  label: attachment.name,
118
+ previewUrl: attachment.mimeType.startsWith('image/') ? attachment.url : undefined,
118
119
  }));
119
120
  });
120
121
 
@@ -22,6 +22,7 @@ function createModelTexts() {
22
22
  favoriteModelsLabel: 'Favorites',
23
23
  favoriteModelLabel: 'Favorite model',
24
24
  unfavoriteModelLabel: 'Remove favorite',
25
+ manageModelsLabel: 'Manage models and providers',
25
26
  recentModelsLabel: 'Recent',
26
27
  allModelsLabel: 'All models'
27
28
  };
@@ -262,6 +263,8 @@ describe('buildModelToolbarSelect', () => {
262
263
  value: 'minimax/MiniMax-M2.7',
263
264
  label: 'MiniMax/MiniMax-M2.7'
264
265
  });
266
+ expect(select.manageLabel).toBe('Manage models and providers');
267
+ expect(select.manageHref).toBe('/providers');
265
268
  });
266
269
 
267
270
  it('keeps the full provider/model label in shared state while exposing a compact mobile label', () => {
@@ -278,7 +278,6 @@ export function buildSkillPickerModel(params: {
278
278
  const recentKeySet = new Set(groupedRecentSkillValues ?? []);
279
279
  return {
280
280
  title: texts.title,
281
- allGroupsLabel: texts.allSkillsLabel,
282
281
  searchPlaceholder: texts.searchPlaceholder,
283
282
  emptyLabel: texts.emptyLabel,
284
283
  loadingLabel: texts.loadingLabel,
@@ -86,6 +86,7 @@ export function buildModelToolbarSelect({
86
86
  | "favoriteModelsLabel"
87
87
  | "favoriteModelLabel"
88
88
  | "unfavoriteModelLabel"
89
+ | "manageModelsLabel"
89
90
  | "recentModelsLabel"
90
91
  | "allModelsLabel"
91
92
  >;
@@ -172,6 +173,8 @@ export function buildModelToolbarSelect({
172
173
  onToggle: onFavoriteToggle,
173
174
  }
174
175
  : undefined,
176
+ manageLabel: texts.manageModelsLabel,
177
+ manageHref: "/providers",
175
178
  onValueChange,
176
179
  };
177
180
  }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ NCP_INTERNAL_VISIBILITY_METADATA_KEY,
4
+ type NcpMessage,
5
+ } from "@nextclaw/ncp";
6
+ import { isVisibleChatMessage } from "@/features/chat/features/message/utils/chat-message-timeline.utils";
7
+
8
+ const visibleMessage = {
9
+ id: "assistant-visible",
10
+ sessionId: "session-1",
11
+ role: "assistant",
12
+ status: "final",
13
+ timestamp: "2026-08-07T00:00:00.000Z",
14
+ parts: [{ type: "text", text: "visible" }],
15
+ } satisfies NcpMessage;
16
+
17
+ describe("chat message timeline visibility", () => {
18
+ it("hides internal and legacy silent messages", () => {
19
+ expect(isVisibleChatMessage({
20
+ ...visibleMessage,
21
+ metadata: { [NCP_INTERNAL_VISIBILITY_METADATA_KEY]: "hidden" },
22
+ })).toBe(false);
23
+ expect(isVisibleChatMessage({
24
+ ...visibleMessage,
25
+ parts: [{ type: "text", text: "\\n\\n<noreply/>" }],
26
+ })).toBe(false);
27
+ expect(isVisibleChatMessage(visibleMessage)).toBe(true);
28
+ });
29
+ });
@@ -1,4 +1,5 @@
1
- import type { NcpMessage } from "@nextclaw/ncp";
1
+ import { isHiddenNcpMessage, type NcpMessage } from "@nextclaw/ncp";
2
+ import { isSilentReplyNcpMessage } from "@nextclaw/shared";
2
3
  import type { ChatMessageViewModel } from "@nextclaw/agent-chat-ui";
3
4
  import {
4
5
  readContextCompactionTimeline,
@@ -48,6 +49,8 @@ function readInheritedSourceSessionId(message: NcpMessage): string | null {
48
49
 
49
50
  export function isVisibleChatMessage(message: NcpMessage): boolean {
50
51
  return (
52
+ !isHiddenNcpMessage(message) &&
53
+ !isSilentReplyNcpMessage(message) &&
51
54
  !readContextCompactionTimeline(message) &&
52
55
  !readInheritedSourceSessionId(message)
53
56
  );
@@ -63,12 +63,12 @@ function ChatSidebarSessionEditingView({
63
63
  }
64
64
  }}
65
65
  placeholder={t('sessionsLabelPlaceholder')}
66
- className="h-8 rounded-lg border-gray-300 bg-white text-xs"
66
+ className="h-8 rounded-lg border-border bg-background text-xs"
67
67
  autoFocus
68
68
  disabled={isSaving}
69
69
  />
70
70
  <div className="flex items-center justify-between gap-2">
71
- <div className="min-w-0 text-[11px] text-gray-400 truncate">{sessionKey}</div>
71
+ <div className="min-w-0 truncate text-[11px] text-muted-foreground/65">{sessionKey}</div>
72
72
  <div className="flex items-center gap-1">
73
73
  <IconActionButton
74
74
  icon={<Check className="h-3.5 w-3.5" />}
@@ -134,8 +134,8 @@ function ChatSidebarSessionDisplayView({
134
134
  className={cn(
135
135
  'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
136
136
  active
137
- ? 'border-gray-300 bg-white/80 text-gray-700'
138
- : 'border-gray-200 bg-gray-100 text-gray-500'
137
+ ? 'border-border/70 bg-background/80 text-foreground/75'
138
+ : 'border-transparent bg-muted/70 text-muted-foreground'
139
139
  )}
140
140
  >
141
141
  {context.label}
@@ -143,12 +143,12 @@ function ChatSidebarSessionDisplayView({
143
143
  ) : null}
144
144
  {context.icon ? (
145
145
  <span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
146
- <SessionContextIconNode icon={context.icon} className={cn('h-[13px] w-[13px]', active ? 'text-gray-700' : 'text-gray-500')} />
146
+ <SessionContextIconNode icon={context.icon} className={cn('h-[13px] w-[13px]', active ? 'text-foreground/75' : 'text-muted-foreground')} />
147
147
  </span>
148
148
  ) : null}
149
149
  </span>
150
150
  </div>
151
- <div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
151
+ <div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground/65">
152
152
  <span className="min-w-0 truncate">
153
153
  {previewText}
154
154
  </span>
@@ -169,7 +169,7 @@ function ChatSidebarSessionDisplayView({
169
169
  event.stopPropagation();
170
170
  onOpenChildSessions();
171
171
  }}
172
- className="pointer-events-none absolute right-12 top-0 inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[10px] font-medium text-muted-foreground opacity-0 transition-colors hover:bg-black/10 hover:text-foreground group-hover/session:pointer-events-auto group-hover/session:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
172
+ className="pointer-events-none absolute right-12 top-0 inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[10px] font-medium text-muted-foreground opacity-0 transition-colors hover:bg-background/80 hover:text-foreground group-hover/session:pointer-events-auto group-hover/session:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
173
173
  aria-label={t('chatSessionOpenChildSessions')}
174
174
  title={t('chatSessionOpenChildSessions')}
175
175
  >
@@ -245,10 +245,10 @@ export function ChatSidebarSessionItem({
245
245
  return (
246
246
  <div
247
247
  className={cn(
248
- 'w-full rounded-xl px-3 py-2.5 text-left transition-all text-[13px]',
248
+ 'w-full rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors',
249
249
  active
250
- ? 'bg-gray-200/80 text-gray-900 font-semibold shadow-sm'
251
- : 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
250
+ ? 'bg-background/90 font-medium text-foreground'
251
+ : 'text-foreground/80 hover:bg-background/65 hover:text-foreground'
252
252
  )}
253
253
  >
254
254
  {isEditing ? (
@@ -61,10 +61,10 @@ export function ChatSidebarSessionList({
61
61
  }
62
62
 
63
63
  return (
64
- <div className="space-y-3">
64
+ <div className="space-y-2">
65
65
  {groups.map((group) => (
66
66
  <div key={group.label}>
67
- <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
67
+ <div className="px-2 pb-1 pt-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/65">
68
68
  {group.label}
69
69
  </div>
70
70
  <div className="space-y-0.5">{group.items.map(renderSessionItem)}</div>
@@ -23,7 +23,7 @@ export function ChatSessionHeaderMenuItem({
23
23
  'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
24
24
  destructive
25
25
  ? 'text-destructive hover:bg-destructive/10'
26
- : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
26
+ : 'text-muted-foreground hover:bg-[var(--interaction-hover)] hover:text-accent-foreground'
27
27
  )}
28
28
  onClick={onClick}
29
29
  disabled={disabled}
@@ -57,7 +57,7 @@ export function ChatSessionProjectBadge({
57
57
  <button
58
58
  type="button"
59
59
  title={projectRoot ?? undefined}
60
- className="min-w-0 max-w-[320px] shrink rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:border-primary/35 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-60"
60
+ className="min-w-0 max-w-[320px] shrink rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:border-primary/35 hover:bg-[var(--interaction-hover)] hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-60"
61
61
  aria-label={t('chatSessionSetProject')}
62
62
  disabled={isProjectPending}
63
63
  >
@@ -69,7 +69,7 @@ function ChatSessionSwitchItem({
69
69
  "flex w-full min-w-0 items-start gap-2 rounded-lg px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
70
70
  active
71
71
  ? "bg-accent text-accent-foreground"
72
- : "text-foreground hover:bg-accent/65 hover:text-accent-foreground",
72
+ : "text-foreground hover:bg-[var(--interaction-hover)] hover:text-accent-foreground",
73
73
  )}
74
74
  onClick={() => onSelect(session.key)}
75
75
  >
@@ -29,7 +29,7 @@ import { cn } from "@/shared/lib/utils";
29
29
  import { useProjects } from "@/shared/hooks/use-projects";
30
30
 
31
31
  const SWITCHER_TRIGGER_CLASS =
32
- "group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border";
32
+ "group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border";
33
33
 
34
34
  function ChatSessionTitle({ title }: { title: string }) {
35
35
  return (
@@ -73,6 +73,32 @@ describe('chat-session-display', () => {
73
73
  ).toBe('正在调用工具:shell');
74
74
  });
75
75
 
76
+ it('localizes structured tool activity previews', () => {
77
+ const session = createSession({
78
+ activityPreview: {
79
+ state: 'running',
80
+ statusKind: 'tool-completed',
81
+ statusText: 'gateway',
82
+ timestamp: '2026-05-16T01:00:00.000Z'
83
+ }
84
+ });
85
+
86
+ expect(sessionActivityPreviewText(session, 'zh')).toBe('工具调用 · 已完成 · gateway');
87
+ expect(sessionActivityPreviewText(session, 'en')).toBe('Tool Call · Completed · gateway');
88
+ });
89
+
90
+ it('localizes persisted legacy tool activity previews', () => {
91
+ const session = createSession({
92
+ activityPreview: {
93
+ state: 'running',
94
+ statusText: 'Tool call completed: gateway',
95
+ timestamp: '2026-05-16T01:00:00.000Z'
96
+ }
97
+ });
98
+
99
+ expect(sessionActivityPreviewText(session, 'zh')).toBe('工具调用 · 已完成 · gateway');
100
+ });
101
+
76
102
  it('shows the final assistant reply after completion', () => {
77
103
  expect(
78
104
  sessionActivityPreviewText(
@@ -106,7 +106,8 @@ describe('adaptNcpSessionSummary', () => {
106
106
  last_activity_preview: {
107
107
  state: 'completed',
108
108
  replyText: 'Plan is ready',
109
- statusText: 'Tool call completed',
109
+ statusKind: 'tool-completed',
110
+ statusText: 'read_file',
110
111
  timestamp: '2026-05-16T01:00:00.000Z',
111
112
  },
112
113
  },
@@ -116,7 +117,8 @@ describe('adaptNcpSessionSummary', () => {
116
117
  expect(adapted.activityPreview).toEqual({
117
118
  state: 'completed',
118
119
  replyText: 'Plan is ready',
119
- statusText: 'Tool call completed',
120
+ statusKind: 'tool-completed',
121
+ statusText: 'read_file',
120
122
  timestamp: '2026-05-16T01:00:00.000Z',
121
123
  });
122
124
  });
@@ -1,6 +1,17 @@
1
- import type { SessionEntryView } from '@/shared/lib/api';
1
+ import type { SessionActivityPreviewView, SessionEntryView } from '@/shared/lib/api';
2
2
  import { getLanguage, getLocale, t, type I18nLanguage } from '@/shared/lib/i18n';
3
3
 
4
+ const LEGACY_ACTIVITY_STATUS_LABELS: Array<[
5
+ string,
6
+ NonNullable<SessionActivityPreviewView['statusKind']>
7
+ ]> = [
8
+ ['Thinking', 'thinking'],
9
+ ['Calling tool', 'tool-running'],
10
+ ['Tool call completed', 'tool-completed'],
11
+ ['Run failed', 'run-failed'],
12
+ ['Run interrupted', 'run-interrupted']
13
+ ];
14
+
4
15
  export function sessionDisplayName(session: SessionEntryView): string {
5
16
  const label = session.label?.trim();
6
17
  if (label) {
@@ -10,21 +21,46 @@ export function sessionDisplayName(session: SessionEntryView): string {
10
21
  return chunks[chunks.length - 1] || session.key;
11
22
  }
12
23
 
13
- export function sessionActivityPreviewText(session: SessionEntryView): string | null {
14
- const preview = session.activityPreview;
15
- if (!preview) {
16
- return null;
24
+ function formatSessionActivityStatus(
25
+ preview: SessionActivityPreviewView,
26
+ lang: I18nLanguage
27
+ ): string | null {
28
+ const detail = preview.statusText?.trim();
29
+ const legacyStatus = preview.statusKind || !detail
30
+ ? undefined
31
+ : LEGACY_ACTIVITY_STATUS_LABELS.find(([label]) => detail === label || detail.startsWith(`${label}: `));
32
+ const statusKind = preview.statusKind ?? legacyStatus?.[1];
33
+ const localizedDetail = legacyStatus
34
+ ? detail?.slice(legacyStatus[0].length).replace(/^:\s*/, '') || undefined
35
+ : detail;
36
+ switch (statusKind) {
37
+ case 'thinking':
38
+ return t('chatTyping', lang);
39
+ case 'tool-running':
40
+ return [t('chatToolCall', lang), t('chatToolStatusRunning', lang), localizedDetail].filter(Boolean).join(' · ');
41
+ case 'tool-completed':
42
+ return [t('chatToolCall', lang), t('chatToolStatusCompleted', lang), localizedDetail].filter(Boolean).join(' · ');
43
+ case 'run-failed':
44
+ return [t('chatToolStatusFailed', lang), localizedDetail].filter(Boolean).join(' · ');
45
+ case 'run-interrupted':
46
+ return t('chatSessionActivityInterrupted', lang);
47
+ default:
48
+ return localizedDetail ?? null;
17
49
  }
18
- if (preview.state === 'cancelled') {
50
+ }
51
+
52
+ export function sessionActivityPreviewText(
53
+ session: SessionEntryView,
54
+ lang: I18nLanguage = getLanguage()
55
+ ): string | null {
56
+ const preview = session.activityPreview;
57
+ if (!preview || preview.state === 'cancelled') {
19
58
  return null;
20
59
  }
21
- if (preview.state === 'failed' || preview.state === 'running') {
22
- return preview.statusText ?? preview.replyText ?? null;
23
- }
24
- if (preview.state === 'completed') {
25
- return preview.replyText ?? preview.statusText ?? null;
60
+ if (preview.state === 'completed' && preview.replyText) {
61
+ return preview.replyText;
26
62
  }
27
- return preview.statusText ?? preview.replyText ?? null;
63
+ return formatSessionActivityStatus(preview, lang) ?? preview.replyText ?? null;
28
64
  }
29
65
 
30
66
  function startOfLocalDate(date: Date): number {
@@ -21,6 +21,13 @@ const SESSION_ACTIVITY_PREVIEW_STATE_SET = new Set<SessionActivityPreviewView['s
21
21
  'cancelled',
22
22
  'idle'
23
23
  ]);
24
+ const SESSION_ACTIVITY_PREVIEW_STATUS_KIND_SET = new Set<NonNullable<SessionActivityPreviewView['statusKind']>>([
25
+ 'thinking',
26
+ 'tool-running',
27
+ 'tool-completed',
28
+ 'run-failed',
29
+ 'run-interrupted'
30
+ ]);
24
31
 
25
32
  function stringifyUnknown(value: unknown): string {
26
33
  if (typeof value === 'string') {
@@ -66,13 +73,15 @@ function readNcpSessionActivityPreview(summary: NcpSessionSummaryView): SessionA
66
73
  if (!SESSION_ACTIVITY_PREVIEW_STATE_SET.has(state as SessionActivityPreviewView['state']) || !timestamp) {
67
74
  return null;
68
75
  }
69
- const statusText = readOptionalString(previewRecord.statusText);
70
- const replyText = readOptionalString(previewRecord.replyText);
76
+ const rawStatusKind = readOptionalString(previewRecord.statusKind);
71
77
  return {
72
78
  state: state as SessionActivityPreviewView['state'],
73
79
  timestamp,
74
- ...(statusText ? { statusText } : {}),
75
- ...(replyText ? { replyText } : {})
80
+ statusKind: SESSION_ACTIVITY_PREVIEW_STATUS_KIND_SET.has(rawStatusKind as NonNullable<SessionActivityPreviewView['statusKind']>)
81
+ ? rawStatusKind as NonNullable<SessionActivityPreviewView['statusKind']>
82
+ : undefined,
83
+ statusText: readOptionalString(previewRecord.statusText) ?? undefined,
84
+ replyText: readOptionalString(previewRecord.replyText) ?? undefined
76
85
  };
77
86
  }
78
87
 
@@ -79,6 +79,12 @@ vi.mock("@/shared/hooks/use-agents", () => ({
79
79
  }),
80
80
  }));
81
81
 
82
+ vi.mock("@/shared/hooks/use-projects", () => ({
83
+ useProjects: () => ({
84
+ data: { projects: [] },
85
+ }),
86
+ }));
87
+
82
88
  function createFetchedQuery<TData>(data: TData) {
83
89
  return {
84
90
  data,
@@ -50,21 +50,24 @@ describe('ChatWelcome', () => {
50
50
  expect(screen.queryByText('Current Agent:')).toBeNull();
51
51
  });
52
52
 
53
- it('uses an adaptive capability grid for narrow docked layouts', () => {
53
+ it('renders a single-purpose welcome surface with compact prompt suggestions', () => {
54
54
  renderWelcome({ agents: [{ id: 'main', displayName: 'Main' }] });
55
55
 
56
- expect(
57
- screen.getByRole('button', { name: /Smart Conversations/ }).parentElement
58
- ?.className,
59
- ).toContain('auto-fit');
56
+ expect(screen.getByRole('heading', { name: 'What would you like to get done?' })).toBeTruthy();
57
+ expect(screen.queryByText(/Describe the goal/)).toBeNull();
58
+ expect(screen.getByRole('group', { name: 'Task context' })).toBeTruthy();
59
+ const suggestion = screen.getByRole('button', { name: 'Review this project' });
60
+ expect(suggestion.className).toContain('rounded-full');
61
+ expect(suggestion.className).not.toContain('border');
62
+ expect(suggestion.parentElement?.className).toContain('flex-wrap');
60
63
  });
61
64
 
62
- it('fills the composer with a prompt suggestion when clicking a capability card', () => {
65
+ it('fills the composer with a prompt suggestion when clicking a suggestion', () => {
63
66
  const onSelectPrompt = vi.fn();
64
67
 
65
68
  renderWelcome({ onSelectPrompt });
66
69
 
67
- fireEvent.click(screen.getByRole('button', { name: /Smart Conversations/ }));
70
+ fireEvent.click(screen.getByRole('button', { name: 'Review this project' }));
68
71
 
69
72
  expect(onSelectPrompt).toHaveBeenCalledWith(
70
73
  expect.stringContaining('next three concrete things'),
@@ -28,7 +28,7 @@ export function ChatWelcomeAgentPicker({
28
28
  <Select value={selectedAgentId} onValueChange={onSelectAgent}>
29
29
  <SelectTrigger
30
30
  aria-label={t('chatDraftAgentTitle')}
31
- className="h-auto w-auto min-w-0 gap-1 rounded-lg border-0 bg-transparent px-2 py-1.5 text-muted-foreground shadow-none hover:bg-accent hover:text-accent-foreground focus:ring-0"
31
+ className="h-auto w-auto min-w-0 gap-1 rounded-lg border-0 bg-transparent px-2 py-1.5 text-muted-foreground shadow-none hover:bg-[var(--interaction-hover)] hover:text-accent-foreground focus:ring-0"
32
32
  >
33
33
  <span className="sr-only">{t('chatDraftAgentTitle')}</span>
34
34
  <div className="flex min-w-0 items-center gap-1.5">