@nextclaw/ui 0.12.9 → 0.12.10

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 (178) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/assets/ChannelsList-M9FTK1Ak.js +8 -0
  3. package/dist/assets/DocBrowser-CH7-GxlL.js +1 -0
  4. package/dist/assets/{DocBrowser-6ReNjvzF.js → DocBrowser-DMfr0Oow.js} +1 -1
  5. package/dist/assets/{DocBrowserContext-B6SpA7Qs.js → DocBrowserContext-BXydqby-.js} +1 -1
  6. package/dist/assets/{LogoBadge-ByNLYg65.js → LogoBadge-hO7tY7hE.js} +1 -1
  7. package/dist/assets/ModelConfig-CNIgLf0e.js +1 -0
  8. package/dist/assets/{ProviderScopedModelInput-Da7khnBA.js → ProviderScopedModelInput-B3HWP4oz.js} +1 -1
  9. package/dist/assets/ProvidersList-CHjMnRhX.js +1 -0
  10. package/dist/assets/RuntimeConfig-psp8nMSG.js +1 -0
  11. package/dist/assets/SearchConfig-CSoKip1f.js +1 -0
  12. package/dist/assets/{SecretsConfig-D281Rotl.js → SecretsConfig-MEt6MjuD.js} +2 -2
  13. package/dist/assets/SessionsConfig-DifCiXwR.js +2 -0
  14. package/dist/assets/{app-query-client-VnFElj4E.js → app-query-client-9jNewezV.js} +1 -1
  15. package/dist/assets/{book-open-BdcxxoQu.js → book-open-DzdUViDm.js} +1 -1
  16. package/dist/assets/chat-page-CLp0UV0Y.js +58 -0
  17. package/dist/assets/chat-session-display-DsYHx0RZ.js +1 -0
  18. package/dist/assets/{chunk-JZWAC4HX-DK5HPmIK.js → chunk-JZWAC4HX-C5dEc8hV.js} +1 -1
  19. package/dist/assets/{client-_i4MU2bB.js → client-C-8fH7-c.js} +1 -1
  20. package/dist/assets/{config-DtIQwrHF.js → config-CBScxsdV.js} +1 -1
  21. package/dist/assets/config-split-page-BUout_Ak.js +1 -0
  22. package/dist/assets/{createLucideIcon-BSeTgkZW.js → createLucideIcon-dy5ie7Ox.js} +1 -1
  23. package/dist/assets/desktop-update-config-2BS6BMkW.js +1 -0
  24. package/dist/assets/{dist-ccBFUi-o.js → dist-BruyLa92.js} +1 -1
  25. package/dist/assets/{dist-6TrrnPCR.js → dist-Cy7_j6hA.js} +1 -1
  26. package/dist/assets/{download-BhDxnyvU.js → download-BD0ETkB-.js} +1 -1
  27. package/dist/assets/{external-link-BgErLCNT.js → external-link-kZSAO8nT.js} +1 -1
  28. package/dist/assets/{hash-Bl7dr_UG.js → hash-BHJC2Ovu.js} +1 -1
  29. package/dist/assets/{i18n-eDHeDY0n.js → i18n-CpTZLchQ.js} +1 -1
  30. package/dist/assets/index-mW8W2FUu.css +1 -0
  31. package/dist/assets/index-zDZfXoI4.js +6 -0
  32. package/dist/assets/{infiniteQueryBehavior-ZDS92Qpp.js → infiniteQueryBehavior-CyER9hv0.js} +1 -1
  33. package/dist/assets/loader-circle-Bc2gCU33.js +1 -0
  34. package/dist/assets/{logos-x89HbrZ4.js → logos-B7gRObP8.js} +1 -1
  35. package/dist/assets/marketplace-page-3qVMnF3d.js +1 -0
  36. package/dist/assets/marketplace-page-BhFIeQzI.js +49 -0
  37. package/dist/assets/mcp-marketplace-page-DYfteJ1D.js +40 -0
  38. package/dist/assets/{page-layout-vZnghcFy.js → page-layout-0UcO9H9Z.js} +1 -1
  39. package/dist/assets/play-CKDjSQFL.js +1 -0
  40. package/dist/assets/plus-CG0QrVY_.js +1 -0
  41. package/dist/assets/{refresh-ccw-DT98i__E.js → refresh-ccw-COVhNHtN.js} +1 -1
  42. package/dist/assets/{refresh-cw-C47QSEwg.js → refresh-cw-Bcv40SXy.js} +1 -1
  43. package/dist/assets/remote-access-page-CWHG-sug.js +1 -0
  44. package/dist/assets/{rotate-cw-JtFzpNn6.js → rotate-cw-oHMKJMC8.js} +1 -1
  45. package/dist/assets/{save-3S6-H3Xw.js → save-EqJPOF0G.js} +1 -1
  46. package/dist/assets/search-BCAlB8nz.js +1 -0
  47. package/dist/assets/security-config-Slh0Mayz.js +1 -0
  48. package/dist/assets/select-CVz0t7MF.js +41 -0
  49. package/dist/assets/setting-row-CbVHAuQt.js +1 -0
  50. package/dist/assets/skeleton-D5rdKvzy.js +1 -0
  51. package/dist/assets/{status-dot-vbanNPFU.js → status-dot-DpPtVzQT.js} +1 -1
  52. package/dist/assets/{switch-BsLtHOH-.js → switch-CM29eCAR.js} +1 -1
  53. package/dist/assets/{tabs-custom-D3HYMt6k.js → tabs-custom-YcZUWn3o.js} +1 -1
  54. package/dist/assets/tag-chip-DMXdnLcj.js +1 -0
  55. package/dist/assets/{trash-2-G48scll7.js → trash-2-mJT6oWa2.js} +1 -1
  56. package/dist/assets/{use-infinite-scroll-loader-DkNhD-42.js → use-infinite-scroll-loader-DJ1L81Dz.js} +1 -1
  57. package/dist/assets/{useConfirmDialog-BkvTN-vd.js → useConfirmDialog-BsVuqu1x.js} +1 -1
  58. package/dist/assets/{useMutation-CBWjE2uj.js → useMutation-CNcz2fgt.js} +1 -1
  59. package/dist/assets/x-Czwxm82I.js +1 -0
  60. package/dist/index.html +22 -22
  61. package/dist/runtime-icons/claude.ico +0 -0
  62. package/dist/runtime-icons/codex-openai.svg +6 -0
  63. package/dist/runtime-icons/hermes-agent.png +0 -0
  64. package/package.json +6 -6
  65. package/public/runtime-icons/claude.ico +0 -0
  66. package/public/runtime-icons/codex-openai.svg +6 -0
  67. package/public/runtime-icons/hermes-agent.png +0 -0
  68. package/src/account/components/account-panel.tsx +217 -97
  69. package/src/account/managers/account.manager.ts +3 -2
  70. package/src/api/chat-session-type.types.ts +7 -0
  71. package/src/api/runtime-control.types.ts +8 -0
  72. package/src/api/types.ts +8 -0
  73. package/src/app.tsx +221 -57
  74. package/src/components/agents/agent-dialogs.tsx +499 -0
  75. package/src/components/agents/agents-page.test.tsx +238 -0
  76. package/src/components/agents/agents-page.tsx +435 -0
  77. package/src/components/chat/ChatSidebar.tsx +11 -35
  78. package/src/components/chat/chat-conversation-panel.test.tsx +20 -0
  79. package/src/components/chat/chat-conversation-panel.tsx +83 -13
  80. package/src/components/chat/chat-page-shell.tsx +19 -13
  81. package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
  82. package/src/components/chat/chat-session-type-option-item.tsx +68 -0
  83. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +87 -0
  84. package/src/components/chat/chat-session-workspace-file-preview.tsx +14 -43
  85. package/src/components/chat/chat-session-workspace-panel-nav.tsx +8 -2
  86. package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
  87. package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
  88. package/src/components/chat/ncp/ncp-chat-page.tsx +2 -0
  89. package/src/components/chat/ncp/ncp-session-adapter.test.ts +1 -0
  90. package/src/components/chat/ncp/ncp-session-adapter.ts +3 -0
  91. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +10 -4
  92. package/src/components/chat/stores/chat-input.store.ts +2 -1
  93. package/src/components/chat/stores/chat-thread.store.ts +3 -1
  94. package/src/components/chat/useChatSessionTypeState.ts +10 -1
  95. package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
  96. package/src/components/common/BrandHeader.tsx +3 -1
  97. package/src/components/common/session-context-icon.tsx +15 -2
  98. package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
  99. package/src/components/config/ChannelForm.test.tsx +89 -3
  100. package/src/components/config/ChannelForm.tsx +157 -188
  101. package/src/components/config/ChannelsList.test.tsx +163 -119
  102. package/src/components/config/ChannelsList.tsx +90 -101
  103. package/src/components/config/ProviderForm.tsx +108 -146
  104. package/src/components/config/ProvidersList.tsx +100 -123
  105. package/src/components/config/SearchConfig.tsx +423 -393
  106. package/src/components/config/channel-form-fields-section.tsx +70 -37
  107. package/src/components/config/config-split-page.tsx +109 -0
  108. package/src/components/config/provider-enabled-field.tsx +17 -10
  109. package/src/components/config/runtime-control-card.test.tsx +56 -0
  110. package/src/components/config/runtime-control-card.tsx +25 -0
  111. package/src/components/config/runtime-presence-card.tsx +93 -79
  112. package/src/components/layout/AppLayout.tsx +25 -37
  113. package/src/components/layout/app-layout.test.tsx +46 -14
  114. package/src/components/layout/runtime-status-entry.test.tsx +157 -0
  115. package/src/components/layout/runtime-status-entry.tsx +143 -0
  116. package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
  117. package/src/components/marketplace/marketplace-list-card.tsx +288 -0
  118. package/src/components/marketplace/marketplace-page-data.ts +129 -0
  119. package/src/components/marketplace/marketplace-page.test.tsx +339 -0
  120. package/src/components/marketplace/marketplace-page.tsx +596 -0
  121. package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
  122. package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
  123. package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
  124. package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
  125. package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
  126. package/src/components/remote/remote-access-page.test.tsx +105 -0
  127. package/src/components/remote/remote-access-page.tsx +248 -0
  128. package/src/components/ui/notice-card.tsx +129 -0
  129. package/src/components/ui/setting-row.tsx +51 -0
  130. package/src/components/ui/tag-chip.tsx +39 -0
  131. package/src/components/ui/textarea.tsx +19 -0
  132. package/src/hooks/useConfig.ts +2 -1
  133. package/src/index.css +24 -0
  134. package/src/lib/app-resource-uri.test.ts +20 -0
  135. package/src/lib/app-resource-uri.ts +29 -0
  136. package/src/lib/i18n.remote.ts +1 -1
  137. package/src/lib/i18n.runtime-control.ts +31 -0
  138. package/src/lib/i18n.ts +5 -8
  139. package/src/lib/session-context.utils.test.ts +71 -0
  140. package/src/lib/session-context.utils.ts +28 -3
  141. package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
  142. package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
  143. package/dist/assets/ChannelsList-Ita2Zm1_.js +0 -8
  144. package/dist/assets/DocBrowser-BNwbPHf4.js +0 -1
  145. package/dist/assets/MarketplacePage-CjX2MWww.js +0 -1
  146. package/dist/assets/MarketplacePage-D0sDlYX4.js +0 -49
  147. package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +0 -40
  148. package/dist/assets/ModelConfig-BzZenCH-.js +0 -1
  149. package/dist/assets/ProvidersList-BbVzRxjY.js +0 -1
  150. package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +0 -1
  151. package/dist/assets/RuntimeConfig-F_XKGgLm.js +0 -1
  152. package/dist/assets/SearchConfig-BGkzXQP-.js +0 -1
  153. package/dist/assets/SessionsConfig-ChHQ7M5c.js +0 -2
  154. package/dist/assets/chat-page-Doe0yTtB.js +0 -58
  155. package/dist/assets/chat-session-display-cw78aiI_.js +0 -1
  156. package/dist/assets/config-layout-CHs0mAaR.js +0 -1
  157. package/dist/assets/desktop-update-config-Dpcf4BKG.js +0 -1
  158. package/dist/assets/index-CF9xve0E.js +0 -6
  159. package/dist/assets/index-FgA52VBt.css +0 -1
  160. package/dist/assets/loader-circle-ACM1s51e.js +0 -1
  161. package/dist/assets/play-CFUwCA2E.js +0 -1
  162. package/dist/assets/plus-rYsv72JG.js +0 -1
  163. package/dist/assets/popover-Bg1VoTZ6.js +0 -1
  164. package/dist/assets/search-3kFR_zh9.js +0 -1
  165. package/dist/assets/security-config-BWaiARNk.js +0 -1
  166. package/dist/assets/select-DJ2MUjBB.js +0 -41
  167. package/dist/assets/skeleton-ByQepn0M.js +0 -1
  168. package/dist/assets/x-ByDbItbq.js +0 -1
  169. package/src/components/agents/AgentDialogs.tsx +0 -400
  170. package/src/components/agents/AgentsPage.test.tsx +0 -217
  171. package/src/components/agents/AgentsPage.tsx +0 -352
  172. package/src/components/config/config-layout.ts +0 -10
  173. package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
  174. package/src/components/marketplace/MarketplacePage.tsx +0 -827
  175. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
  176. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
  177. package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
  178. package/src/components/remote/RemoteAccessPage.tsx +0 -144
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
7
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
8
8
  import { SelectItem } from '@/components/ui/select';
9
9
  import { ChatSidebarSessionItem } from '@/components/chat/chat-sidebar-session-item';
10
+ import { ChatSessionTypeOptionItem } from '@/components/chat/chat-session-type-option-item';
10
11
  import { ChatSidebarListModeSwitch } from '@/components/chat/chat-sidebar-list-mode-switch';
11
12
  import {
12
13
  ChatSidebarProjectGroups,
@@ -130,16 +131,6 @@ function sessionTitle(session: SessionEntryView): string {
130
131
  return chunks[chunks.length - 1] || session.key;
131
132
  }
132
133
 
133
- function resolveSessionTypeStatusText(option: {
134
- ready?: boolean;
135
- reasonMessage?: string | null;
136
- }): string {
137
- if (option.ready === false) {
138
- return option.reasonMessage?.trim() || t('statusSetup');
139
- }
140
- return t('statusReady');
141
- }
142
-
143
134
  const navItems = [
144
135
  { target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
145
136
  { target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
@@ -347,38 +338,23 @@ export function ChatSidebar() {
347
338
  <ChevronDown className="h-4 w-4" />
348
339
  </Button>
349
340
  </PopoverTrigger>
350
- <PopoverContent align="end" className="w-64 p-2">
351
- <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
341
+ <PopoverContent
342
+ align="end"
343
+ className="w-56 rounded-2xl border border-gray-200/80 bg-white p-1.5 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
344
+ >
345
+ <div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400">
352
346
  {t('chatSessionTypeLabel')}
353
347
  </div>
354
- <div className="mt-1 space-y-1">
348
+ <div className="space-y-1">
355
349
  {nonDefaultSessionTypeOptions.map((option) => (
356
- <button
350
+ <ChatSessionTypeOptionItem
357
351
  key={option.value}
358
- type="button"
359
- onClick={() => {
352
+ option={option}
353
+ onSelect={() => {
360
354
  presenter.chatSessionListManager.createSession(option.value);
361
355
  setIsCreateMenuOpen(false);
362
356
  }}
363
- className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
364
- >
365
- <div className="flex items-center justify-between gap-3">
366
- <div className="text-[13px] font-medium text-gray-900">{option.label}</div>
367
- <span
368
- className={cn(
369
- 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
370
- option.ready === false
371
- ? 'bg-amber-100 text-amber-800'
372
- : 'bg-emerald-100 text-emerald-700'
373
- )}
374
- >
375
- {option.ready === false ? t('statusSetup') : t('statusReady')}
376
- </span>
377
- </div>
378
- <div className="mt-0.5 text-[11px] text-gray-500">
379
- {resolveSessionTypeStatusText(option)}
380
- </div>
381
- </button>
357
+ />
382
358
  ))}
383
359
  </div>
384
360
  </PopoverContent>
@@ -269,6 +269,23 @@ describe("ChatConversationPanel", () => {
269
269
  expect(screen.queryByText("Engineer")).toBeNull();
270
270
  });
271
271
 
272
+ it("renders a fuller loading skeleton before provider state settles", () => {
273
+ useChatThreadStore.setState({
274
+ snapshot: {
275
+ ...useChatThreadStore.getState().snapshot,
276
+ isProviderStateResolved: false,
277
+ },
278
+ });
279
+
280
+ render(<ChatConversationPanel />);
281
+
282
+ expect(screen.getByTestId("chat-conversation-skeleton")).toBeTruthy();
283
+ expect(
284
+ screen.getAllByTestId("chat-conversation-skeleton-bubble"),
285
+ ).toHaveLength(4);
286
+ expect(screen.queryByTestId("chat-input-bar")).toBeNull();
287
+ });
288
+
272
289
  it("keeps the message area clean while a session history is hydrating", () => {
273
290
  useChatThreadStore.setState({
274
291
  snapshot: {
@@ -495,5 +512,8 @@ describe("ChatSessionWorkspacePanel", () => {
495
512
  expect(screen.getByTestId("workspace-file-preview").textContent).toBe(
496
513
  "README.md",
497
514
  );
515
+ expect(screen.getByTestId("workspace-tabs-bar").className).toContain(
516
+ "workspace-horizontal-scrollbar",
517
+ );
498
518
  });
499
519
  });
@@ -9,35 +9,93 @@ import {
9
9
  import { ChatWelcome } from "@/components/chat/ChatWelcome";
10
10
  import { ChatSessionWorkspacePanel } from "@/components/chat/chat-session-workspace-panel";
11
11
  import { AgentAvatar } from "@/components/common/AgentAvatar";
12
+ import { SessionContextIconNode } from "@/components/common/session-context-icon";
12
13
  import { usePresenter } from "@/components/chat/presenter/chat-presenter-context";
13
14
  import { ChatSessionHeaderActions } from "@/components/chat/session-header/chat-session-header-actions";
14
15
  import { ChatSessionProjectBadge } from "@/components/chat/session-header/chat-session-project-badge";
15
16
  import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
16
17
  import { useChatThreadStore } from "@/components/chat/stores/chat-thread.store";
18
+ import { Skeleton } from "@/components/ui/skeleton";
17
19
  import { resolveAgentRuntimeSessionType } from "@/components/chat/useChatSessionTypeState";
18
20
  import { t } from "@/lib/i18n";
19
21
  import { cn } from "@/lib/utils";
20
22
 
23
+ const CHAT_CONVERSATION_SKELETON_BUBBLES = [
24
+ {
25
+ key: "hero",
26
+ alignmentClassName: "justify-start",
27
+ bubbleClassName: "max-w-[78%] h-32 rounded-[30px]",
28
+ },
29
+ {
30
+ key: "follow-up",
31
+ alignmentClassName: "justify-start",
32
+ bubbleClassName: "max-w-[62%] h-24 rounded-[28px]",
33
+ },
34
+ {
35
+ key: "reply",
36
+ alignmentClassName: "justify-end",
37
+ bubbleClassName: "max-w-[70%] h-24 rounded-[28px]",
38
+ },
39
+ {
40
+ key: "detail",
41
+ alignmentClassName: "justify-start",
42
+ bubbleClassName: "max-w-[88%] h-36 rounded-[30px]",
43
+ },
44
+ ] as const;
45
+
21
46
  function ChatConversationSkeleton() {
22
47
  return (
23
- <section className="flex-1 min-h-0 flex flex-col overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
48
+ <section
49
+ data-testid="chat-conversation-skeleton"
50
+ className="flex-1 min-h-0 flex flex-col overflow-hidden bg-gradient-to-b from-gray-50/60 to-white"
51
+ >
24
52
  <div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
25
- <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
26
- <div className="space-y-4">
27
- <div className="h-6 w-48 animate-pulse rounded bg-gray-200" />
28
- <div className="h-24 w-[78%] animate-pulse rounded-2xl bg-gray-200/80" />
29
- <div className="h-20 w-[62%] animate-pulse rounded-2xl bg-gray-200/80" />
30
- <div className="h-28 w-[84%] animate-pulse rounded-2xl bg-gray-200/80" />
53
+ <div className="mx-auto flex min-h-full w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
54
+ <div className="flex flex-1 flex-col gap-8">
55
+ <div className="space-y-6">
56
+ <Skeleton className="h-6 w-52 rounded-lg bg-gray-200/90" />
57
+ <div className="space-y-5">
58
+ {CHAT_CONVERSATION_SKELETON_BUBBLES.map((bubble) => (
59
+ <div
60
+ key={bubble.key}
61
+ className={cn("flex w-full", bubble.alignmentClassName)}
62
+ >
63
+ <Skeleton
64
+ data-testid="chat-conversation-skeleton-bubble"
65
+ className={cn(
66
+ "w-full bg-gray-200/80",
67
+ bubble.bubbleClassName,
68
+ )}
69
+ />
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ <div className="mt-auto grid gap-4 pb-2 sm:grid-cols-[minmax(0,1fr)_minmax(180px,240px)] sm:items-end">
75
+ <div className="space-y-3">
76
+ <Skeleton className="h-4 w-40 rounded-full bg-gray-200/70" />
77
+ <Skeleton className="h-[112px] w-full rounded-[30px] bg-gray-200/70" />
78
+ </div>
79
+ <div className="hidden justify-end sm:flex">
80
+ <Skeleton className="h-10 w-36 rounded-full bg-gray-200/75" />
81
+ </div>
82
+ </div>
31
83
  </div>
32
84
  </div>
33
85
  </div>
34
86
  <div className="border-t border-gray-200/80 bg-white p-4">
35
87
  <div className="mx-auto w-full max-w-[min(1120px,100%)]">
36
- <div className="rounded-2xl border border-gray-200 bg-white shadow-card p-4">
37
- <div className="h-16 w-full animate-pulse rounded-xl bg-gray-200/80" />
38
- <div className="mt-3 flex items-center justify-between">
39
- <div className="h-8 w-36 animate-pulse rounded-lg bg-gray-200/80" />
40
- <div className="h-8 w-20 animate-pulse rounded-lg bg-gray-200/80" />
88
+ <div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-card">
89
+ <div className="px-4 py-2.5">
90
+ <Skeleton className="h-[84px] w-full rounded-[28px] bg-gray-200/80" />
91
+ </div>
92
+ <div className="flex items-center justify-between gap-3 px-3 pb-3">
93
+ <div className="flex items-center gap-2">
94
+ <Skeleton className="h-8 w-20 rounded-full bg-gray-200/75" />
95
+ <Skeleton className="h-8 w-28 rounded-full bg-gray-200/75" />
96
+ <Skeleton className="hidden h-8 w-24 rounded-full bg-gray-200/70 sm:block" />
97
+ </div>
98
+ <Skeleton className="h-8 w-8 rounded-full bg-gray-200/85" />
41
99
  </div>
42
100
  </div>
43
101
  </div>
@@ -130,7 +188,19 @@ function ChatConversationHeader({
130
188
  {sessionHeaderTitle}
131
189
  </span>
132
190
  {snapshot.sessionTypeLabel ? (
133
- <span className="shrink-0 rounded-full border border-gray-200 bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
191
+ <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-gray-200 bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
192
+ {snapshot.sessionTypeIcon?.src ? (
193
+ <span className="inline-flex h-[1.125rem] w-[1.125rem] items-center justify-center">
194
+ <SessionContextIconNode
195
+ icon={{
196
+ kind: "runtime-image",
197
+ src: snapshot.sessionTypeIcon.src,
198
+ alt: snapshot.sessionTypeIcon.alt ?? null,
199
+ name: snapshot.sessionTypeLabel
200
+ }}
201
+ />
202
+ </span>
203
+ ) : null}
134
204
  {snapshot.sessionTypeLabel}
135
205
  </span>
136
206
  ) : null}
@@ -1,12 +1,12 @@
1
- import { useEffect } from 'react';
2
- import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
3
- import { ChatSidebar } from '@/components/chat/ChatSidebar';
4
- import { ChatConversationPanel } from '@/components/chat/chat-conversation-panel';
5
- import { AgentsPage } from '@/components/agents/AgentsPage';
6
- import { CronConfig } from '@/components/config/CronConfig';
7
- import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
1
+ import { useEffect } from "react";
2
+ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
3
+ import { ChatSidebar } from "@/components/chat/ChatSidebar";
4
+ import { ChatConversationPanel } from "@/components/chat/chat-conversation-panel";
5
+ import { AgentsPage } from "@/components/agents/agents-page";
6
+ import { CronConfig } from "@/components/config/CronConfig";
7
+ import { MarketplacePage } from "@/components/marketplace/marketplace-page";
8
8
 
9
- export type MainPanelView = 'chat' | 'cron' | 'skills' | 'agents';
9
+ export type MainPanelView = "chat" | "cron" | "skills" | "agents";
10
10
 
11
11
  export type ChatPageProps = {
12
12
  view: MainPanelView;
@@ -32,7 +32,7 @@ export function useChatSessionSync(params: UseChatSessionSyncParams): void {
32
32
  } = params;
33
33
 
34
34
  useEffect(() => {
35
- if (view !== 'chat') {
35
+ if (view !== "chat") {
36
36
  return;
37
37
  }
38
38
  if (routeSessionKey) {
@@ -45,7 +45,13 @@ export function useChatSessionSync(params: UseChatSessionSyncParams): void {
45
45
  setSelectedSessionKey(null);
46
46
  resetStreamState();
47
47
  }
48
- }, [resetStreamState, routeSessionKey, selectedSessionKey, setSelectedSessionKey, view]);
48
+ }, [
49
+ resetStreamState,
50
+ routeSessionKey,
51
+ selectedSessionKey,
52
+ setSelectedSessionKey,
53
+ view,
54
+ ]);
49
55
 
50
56
  useEffect(() => {
51
57
  selectedSessionKeyRef.current = selectedSessionKey;
@@ -62,17 +68,17 @@ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
62
68
  <div className="h-full flex">
63
69
  <ChatSidebar />
64
70
 
65
- {view === 'chat' ? (
71
+ {view === "chat" ? (
66
72
  <ChatConversationPanel />
67
73
  ) : (
68
74
  <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
69
- {view === 'cron' ? (
75
+ {view === "cron" ? (
70
76
  <div className="h-full overflow-auto custom-scrollbar">
71
77
  <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
72
78
  <CronConfig />
73
79
  </div>
74
80
  </div>
75
- ) : view === 'agents' ? (
81
+ ) : view === "agents" ? (
76
82
  <div className="h-full overflow-auto custom-scrollbar">
77
83
  <div className="mx-auto w-full max-w-[min(1180px,100%)] px-6 py-5">
78
84
  <AgentsPage />
@@ -0,0 +1,46 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ChatSessionTypeOptionItem } from "@/components/chat/chat-session-type-option-item";
4
+
5
+ describe("ChatSessionTypeOptionItem", () => {
6
+ it("renders a runtime icon image when the session type option provides an app resource URI", () => {
7
+ render(
8
+ <ChatSessionTypeOptionItem
9
+ option={{
10
+ value: "codex",
11
+ label: "Codex",
12
+ icon: {
13
+ kind: "image",
14
+ src: "app://runtime-icons/codex-openai.svg",
15
+ alt: "Codex",
16
+ },
17
+ ready: true,
18
+ }}
19
+ onSelect={vi.fn()}
20
+ />,
21
+ );
22
+
23
+ const runtimeIcon = screen.getByRole("img", { name: "Codex logo" });
24
+ expect(runtimeIcon.getAttribute("src")).toBe("/runtime-icons/codex-openai.svg");
25
+ });
26
+
27
+ it("keeps ready options visually compact without repeating helper copy", () => {
28
+ render(
29
+ <ChatSessionTypeOptionItem
30
+ option={{
31
+ value: "claude",
32
+ label: "Claude",
33
+ icon: {
34
+ kind: "image",
35
+ src: "app://runtime-icons/claude.ico",
36
+ alt: "Claude",
37
+ },
38
+ ready: true,
39
+ }}
40
+ onSelect={vi.fn()}
41
+ />,
42
+ );
43
+
44
+ expect(screen.getAllByText("Ready")).toHaveLength(1);
45
+ });
46
+ });
@@ -0,0 +1,68 @@
1
+ import type { ChatInputSnapshot } from "@/components/chat/stores/chat-input.store";
2
+ import { SessionContextIconNode } from "@/components/common/session-context-icon";
3
+ import { t } from "@/lib/i18n";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
7
+
8
+ export function ChatSessionTypeOptionItem(props: {
9
+ option: SessionTypeOption;
10
+ onSelect: () => void;
11
+ }) {
12
+ const { option, onSelect } = props;
13
+ const helperText =
14
+ option.ready === false
15
+ ? option.reasonMessage?.trim() || t("statusSetup")
16
+ : null;
17
+
18
+ return (
19
+ <button
20
+ type="button"
21
+ onClick={onSelect}
22
+ className="w-full rounded-2xl px-3 py-2.5 text-left transition-colors hover:bg-gray-50"
23
+ >
24
+ <div className="flex items-start gap-3">
25
+ <div className="flex min-w-0 flex-1 items-start gap-2.5">
26
+ {option.icon?.src ? (
27
+ <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center pt-0.5">
28
+ <SessionContextIconNode
29
+ icon={{
30
+ kind: "runtime-image",
31
+ src: option.icon.src,
32
+ alt: option.icon.alt ?? null,
33
+ name: option.label,
34
+ }}
35
+ />
36
+ </span>
37
+ ) : null}
38
+ <div className="min-w-0 flex-1">
39
+ <div className="flex items-center justify-between gap-3">
40
+ <div className="truncate text-[13px] font-semibold text-gray-900">
41
+ {option.label}
42
+ </div>
43
+ <span
44
+ className={cn(
45
+ "inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium",
46
+ option.ready === false ? "text-amber-700" : "text-emerald-600",
47
+ )}
48
+ >
49
+ <span
50
+ className={cn(
51
+ "h-1.5 w-1.5 rounded-full",
52
+ option.ready === false ? "bg-amber-500" : "bg-emerald-500",
53
+ )}
54
+ />
55
+ {option.ready === false ? t("statusSetup") : t("statusReady")}
56
+ </span>
57
+ </div>
58
+ {helperText ? (
59
+ <div className="mt-1 pr-4 text-[11px] leading-4 text-gray-500">
60
+ {helperText}
61
+ </div>
62
+ ) : null}
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </button>
67
+ );
68
+ }
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { ChatSessionWorkspaceFilePreview } from "@/components/chat/chat-session-workspace-file-preview";
4
4
  import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
5
+ import { t } from "@/lib/i18n";
5
6
 
6
7
  const serverPathReadMock = vi.fn();
7
8
 
@@ -88,4 +89,90 @@ describe("ChatSessionWorkspaceFilePreview", () => {
88
89
  "workspace",
89
90
  );
90
91
  });
92
+
93
+ it("does not repeat the preview badge inside the workspace header", () => {
94
+ serverPathReadMock.mockReturnValue({
95
+ isLoading: false,
96
+ error: null,
97
+ data: {
98
+ kind: "text",
99
+ resolvedPath: "/tmp/example.ts",
100
+ text: "const answer = 42;\n",
101
+ truncated: false,
102
+ },
103
+ });
104
+
105
+ render(
106
+ <ChatSessionWorkspaceFilePreview
107
+ file={buildWorkspaceFile({ viewMode: "preview" })}
108
+ sessionProjectRoot="/tmp"
109
+ onFileOpen={vi.fn()}
110
+ />,
111
+ );
112
+
113
+ expect(screen.queryByText(t("chatWorkspacePreview"))).toBeNull();
114
+ expect(screen.getByTitle("/tmp/example.ts")).toBeTruthy();
115
+ expect(screen.getByText("tmp")).toBeTruthy();
116
+ expect(screen.getByText("example.ts")).toBeTruthy();
117
+ });
118
+
119
+ it("renders project-relative breadcrumbs when the file is inside the workspace", () => {
120
+ serverPathReadMock.mockReturnValue({
121
+ isLoading: false,
122
+ error: null,
123
+ data: {
124
+ kind: "text",
125
+ resolvedPath: "/tmp/workspace/src/example.ts",
126
+ text: "const answer = 42;\n",
127
+ truncated: false,
128
+ },
129
+ });
130
+
131
+ render(
132
+ <ChatSessionWorkspaceFilePreview
133
+ file={buildWorkspaceFile({ viewMode: "preview" })}
134
+ sessionProjectRoot="/tmp/workspace"
135
+ onFileOpen={vi.fn()}
136
+ />,
137
+ );
138
+
139
+ expect(screen.getByText("workspace")).toBeTruthy();
140
+ expect(screen.getByText("src")).toBeTruthy();
141
+ expect(screen.getByText("example.ts")).toBeTruthy();
142
+ expect(
143
+ screen.getByTestId("workspace-file-breadcrumb-scroll").className,
144
+ ).toContain("py-1.5");
145
+ expect(screen.getByTestId("workspace-file-breadcrumbs").className).toContain(
146
+ "workspace-horizontal-scrollbar",
147
+ );
148
+ });
149
+
150
+ it("keeps line and truncation metadata without the duplicated type badge", () => {
151
+ serverPathReadMock.mockReturnValue({
152
+ isLoading: false,
153
+ error: null,
154
+ data: {
155
+ kind: "text",
156
+ resolvedPath: "/tmp/example.ts",
157
+ text: "const answer = 42;\n",
158
+ truncated: true,
159
+ },
160
+ });
161
+
162
+ render(
163
+ <ChatSessionWorkspaceFilePreview
164
+ file={buildWorkspaceFile({
165
+ viewMode: "preview",
166
+ line: 12,
167
+ column: 4,
168
+ })}
169
+ sessionProjectRoot="/tmp"
170
+ onFileOpen={vi.fn()}
171
+ />,
172
+ );
173
+
174
+ expect(screen.getByText("L12:4")).toBeTruthy();
175
+ expect(screen.getByText(t("chatWorkspacePreviewTruncated"))).toBeTruthy();
176
+ expect(screen.queryByText(t("chatWorkspacePreview"))).toBeNull();
177
+ });
91
178
  });
@@ -8,12 +8,14 @@ import {
8
8
  FileOperationCodeSurface,
9
9
  } from "@nextclaw/agent-chat-ui";
10
10
  import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
11
+ import { ChatSessionWorkspaceFileBreadcrumbs } from "@/components/chat/workspace/chat-session-workspace-file-breadcrumbs";
11
12
  import { useServerPathRead } from "@/hooks/server-path/use-server-path-read";
12
13
  import {
13
14
  buildLineDiff,
14
15
  buildPreviewLines,
15
16
  } from "@/components/chat/adapters/file-operation/line-builder";
16
17
  import { t } from "@/lib/i18n";
18
+ import { buildWorkspaceFileBreadcrumb } from "@/lib/session-project/workspace-file-breadcrumb";
17
19
  import { cn } from "@/lib/utils";
18
20
 
19
21
  function inferPreviewKind(params: {
@@ -118,44 +120,6 @@ function WorkspaceFilePreviewStatus({
118
120
  );
119
121
  }
120
122
 
121
- function WorkspaceFileHeader({
122
- file,
123
- resolvedPath,
124
- truncated,
125
- }: {
126
- file: ChatWorkspaceFileTab;
127
- resolvedPath: string;
128
- truncated: boolean;
129
- }) {
130
- return (
131
- <div className="border-b border-gray-200/80 px-4 py-3">
132
- <div
133
- title={resolvedPath}
134
- className="truncate font-mono text-[12px] font-medium text-gray-700"
135
- >
136
- {resolvedPath}
137
- </div>
138
- <div className="mt-2 flex flex-wrap items-center gap-2">
139
- <span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-gray-500">
140
- {file.viewMode === "diff"
141
- ? t("chatWorkspaceDiff")
142
- : t("chatWorkspacePreview")}
143
- </span>
144
- {typeof file.line === "number" ? (
145
- <span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-500">
146
- {`L${file.line}${typeof file.column === "number" ? `:${file.column}` : ""}`}
147
- </span>
148
- ) : null}
149
- {truncated ? (
150
- <span className="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
151
- {t("chatWorkspacePreviewTruncated")}
152
- </span>
153
- ) : null}
154
- </div>
155
- </div>
156
- );
157
- }
158
-
159
123
  function WorkspaceDiffBody({
160
124
  diffBlock,
161
125
  }: {
@@ -280,14 +244,21 @@ export function ChatSessionWorkspaceFilePreview({
280
244
  }, [file.line, file.path, isPreviewMode, previewQuery.data?.resolvedPath, previewText]);
281
245
  const resolvedPath = previewQuery.data?.resolvedPath ?? file.path;
282
246
  const isTruncated = Boolean(previewQuery.data?.truncated);
247
+ const breadcrumb = useMemo(
248
+ () =>
249
+ buildWorkspaceFileBreadcrumb({
250
+ path: resolvedPath,
251
+ sessionProjectRoot,
252
+ line: file.line,
253
+ column: file.column,
254
+ truncated: isTruncated,
255
+ }),
256
+ [file.column, file.line, isTruncated, resolvedPath, sessionProjectRoot],
257
+ );
283
258
 
284
259
  return (
285
260
  <div className="flex h-full min-h-0 flex-col bg-white">
286
- <WorkspaceFileHeader
287
- file={file}
288
- resolvedPath={resolvedPath}
289
- truncated={isTruncated}
290
- />
261
+ <ChatSessionWorkspaceFileBreadcrumbs breadcrumb={breadcrumb} />
291
262
 
292
263
  <div className="flex-1 min-h-0 overflow-hidden">
293
264
  {file.viewMode === "diff" ? (
@@ -186,8 +186,14 @@ export function WorkspaceTabsBar({
186
186
  tabs: readonly WorkspaceTabViewModel[];
187
187
  }) {
188
188
  return (
189
- <div className="overflow-x-auto border-b border-gray-200/70 bg-gray-50/85 custom-scrollbar">
190
- <div className="flex min-w-max items-stretch">
189
+ <div
190
+ data-testid="workspace-tabs-bar"
191
+ className="workspace-horizontal-scrollbar overflow-x-auto overflow-y-hidden border-b border-gray-200/70 bg-gray-50/85"
192
+ >
193
+ <div
194
+ data-testid="workspace-tabs-scroll"
195
+ className="flex min-w-max items-stretch"
196
+ >
191
197
  {tabs.map((tab) => (
192
198
  <WorkspaceTabItem key={tab.key} tab={tab} />
193
199
  ))}