@nextclaw/ui 0.12.8 → 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 (227) hide show
  1. package/CHANGELOG.md +96 -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-BMxf9CIK.js → DocBrowser-DMfr0Oow.js} +1 -1
  5. package/dist/assets/{DocBrowserContext-Ce28gRXt.js → DocBrowserContext-BXydqby-.js} +1 -1
  6. package/dist/assets/{LogoBadge-o92MOA2L.js → LogoBadge-hO7tY7hE.js} +1 -1
  7. package/dist/assets/ModelConfig-CNIgLf0e.js +1 -0
  8. package/dist/assets/{ProviderScopedModelInput-CmTIzgI7.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-Ba1RPJaG.js → SecretsConfig-MEt6MjuD.js} +2 -2
  13. package/dist/assets/SessionsConfig-DifCiXwR.js +2 -0
  14. package/dist/assets/{app-query-client-DniXoIN5.js → app-query-client-9jNewezV.js} +1 -1
  15. package/dist/assets/{book-open-DocgeQtR.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-BvKvh1R8.js → chunk-JZWAC4HX-C5dEc8hV.js} +1 -1
  19. package/dist/assets/{client-CVqPF5ie.js → client-C-8fH7-c.js} +1 -1
  20. package/dist/assets/{config-Bop2oB18.js → config-CBScxsdV.js} +1 -1
  21. package/dist/assets/config-split-page-BUout_Ak.js +1 -0
  22. package/dist/assets/{createLucideIcon-DVv8taGY.js → createLucideIcon-dy5ie7Ox.js} +1 -1
  23. package/dist/assets/desktop-update-config-2BS6BMkW.js +1 -0
  24. package/dist/assets/{dist-DmAlInRu.js → dist-BruyLa92.js} +1 -1
  25. package/dist/assets/{dist-Da5Gm_pO.js → dist-Cy7_j6hA.js} +1 -1
  26. package/dist/assets/download-BD0ETkB-.js +1 -0
  27. package/dist/assets/{external-link-DFjw3x1B.js → external-link-kZSAO8nT.js} +1 -1
  28. package/dist/assets/{hash-DJtaCejM.js → hash-BHJC2Ovu.js} +1 -1
  29. package/dist/assets/i18n-CpTZLchQ.js +1 -0
  30. package/dist/assets/index-mW8W2FUu.css +1 -0
  31. package/dist/assets/index-zDZfXoI4.js +6 -0
  32. package/dist/assets/{infiniteQueryBehavior-DHSEQ3OH.js → infiniteQueryBehavior-CyER9hv0.js} +1 -1
  33. package/dist/assets/loader-circle-Bc2gCU33.js +1 -0
  34. package/dist/assets/{logos-DEFUIR12.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-Da3i3r6G.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-D6HkNtfz.js → refresh-ccw-COVhNHtN.js} +1 -1
  42. package/dist/assets/{refresh-cw-DRcvRrnc.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-BmDKfXtH.js → rotate-cw-oHMKJMC8.js} +1 -1
  45. package/dist/assets/{save-DHGmi2e9.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-DurKKSwA.js → status-dot-DpPtVzQT.js} +1 -1
  52. package/dist/assets/{switch-0rmPBRKI.js → switch-CM29eCAR.js} +1 -1
  53. package/dist/assets/{tabs-custom-5JLVL6v8.js → tabs-custom-YcZUWn3o.js} +1 -1
  54. package/dist/assets/tag-chip-DMXdnLcj.js +1 -0
  55. package/dist/assets/{trash-2-C6caKPoz.js → trash-2-mJT6oWa2.js} +1 -1
  56. package/dist/assets/{use-infinite-scroll-loader-dwnaa_qi.js → use-infinite-scroll-loader-DJ1L81Dz.js} +1 -1
  57. package/dist/assets/{useConfirmDialog-mMeWD_yo.js → useConfirmDialog-BsVuqu1x.js} +1 -1
  58. package/dist/assets/{useMutation-BmxxvCNf.js → useMutation-CNcz2fgt.js} +1 -1
  59. package/dist/assets/x-Czwxm82I.js +1 -0
  60. package/dist/index.html +95 -21
  61. package/dist/manifest.webmanifest +30 -0
  62. package/dist/offline.html +102 -0
  63. package/dist/pwa-192.png +0 -0
  64. package/dist/pwa-512.png +0 -0
  65. package/dist/runtime-icons/claude.ico +0 -0
  66. package/dist/runtime-icons/codex-openai.svg +6 -0
  67. package/dist/runtime-icons/hermes-agent.png +0 -0
  68. package/dist/sw.js +80 -0
  69. package/index.html +73 -1
  70. package/package.json +5 -5
  71. package/public/manifest.webmanifest +30 -0
  72. package/public/offline.html +102 -0
  73. package/public/pwa-192.png +0 -0
  74. package/public/pwa-512.png +0 -0
  75. package/public/runtime-icons/claude.ico +0 -0
  76. package/public/runtime-icons/codex-openai.svg +6 -0
  77. package/public/runtime-icons/hermes-agent.png +0 -0
  78. package/public/sw.js +80 -0
  79. package/src/account/components/account-panel.tsx +217 -97
  80. package/src/account/managers/account.manager.ts +3 -2
  81. package/src/api/chat-session-type.types.ts +7 -0
  82. package/src/api/runtime-control.types.ts +8 -0
  83. package/src/api/server-path.ts +27 -4
  84. package/src/api/types.ts +25 -10
  85. package/src/app.tsx +227 -54
  86. package/src/components/agents/agent-dialogs.tsx +499 -0
  87. package/src/components/agents/agents-page.test.tsx +238 -0
  88. package/src/components/agents/agents-page.tsx +435 -0
  89. package/src/components/chat/ChatSidebar.test.tsx +43 -1
  90. package/src/components/chat/ChatSidebar.tsx +35 -35
  91. package/src/components/chat/adapters/chat-message.summary-truncation.test.ts +66 -0
  92. package/src/components/chat/adapters/file-operation/card.ts +9 -0
  93. package/src/components/chat/adapters/file-operation/diff.ts +14 -0
  94. package/src/components/chat/{ChatConversationPanel.test.tsx → chat-conversation-panel.test.tsx} +127 -206
  95. package/src/components/chat/chat-conversation-panel.tsx +482 -0
  96. package/src/components/chat/chat-page-shell.tsx +19 -13
  97. package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
  98. package/src/components/chat/chat-session-type-option-item.tsx +68 -0
  99. package/src/components/chat/chat-session-workspace-file-preview.test.tsx +178 -0
  100. package/src/components/chat/chat-session-workspace-file-preview.tsx +278 -0
  101. package/src/components/chat/chat-session-workspace-panel-nav.tsx +203 -0
  102. package/src/components/chat/chat-session-workspace-panel.tsx +318 -0
  103. package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
  104. package/src/components/chat/chat-sidebar-session-item.tsx +32 -2
  105. package/src/components/chat/containers/chat-message-list.container.test.tsx +49 -0
  106. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  107. package/src/components/chat/managers/chat-session-list.manager.test.ts +12 -0
  108. package/src/components/chat/managers/chat-session-list.manager.ts +7 -0
  109. package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
  110. package/src/components/chat/ncp/ncp-chat-page.tsx +9 -7
  111. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +179 -41
  112. package/src/components/chat/ncp/ncp-session-adapter.test.ts +36 -1
  113. package/src/components/chat/ncp/ncp-session-adapter.ts +20 -0
  114. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +62 -13
  115. package/src/components/chat/ncp/tests/ncp-chat-thread.manager.test.ts +189 -0
  116. package/src/components/chat/presenter/chat-presenter-context.tsx +13 -2
  117. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +26 -0
  118. package/src/components/chat/session-header/chat-session-header-actions.tsx +19 -1
  119. package/src/components/chat/stores/chat-input.store.ts +2 -1
  120. package/src/components/chat/stores/chat-thread.store.ts +27 -1
  121. package/src/components/chat/useChatSessionTypeState.ts +10 -1
  122. package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
  123. package/src/components/common/BrandHeader.tsx +3 -1
  124. package/src/components/common/session-context-icon.tsx +15 -2
  125. package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
  126. package/src/components/config/ChannelForm.test.tsx +89 -3
  127. package/src/components/config/ChannelForm.tsx +157 -188
  128. package/src/components/config/ChannelsList.test.tsx +163 -119
  129. package/src/components/config/ChannelsList.tsx +90 -101
  130. package/src/components/config/ProviderForm.tsx +108 -146
  131. package/src/components/config/ProvidersList.tsx +100 -123
  132. package/src/components/config/RuntimeConfig.tsx +141 -2
  133. package/src/components/config/SearchConfig.tsx +423 -393
  134. package/src/components/config/channel-form-fields-section.tsx +70 -37
  135. package/src/components/config/config-split-page.tsx +109 -0
  136. package/src/components/config/provider-enabled-field.tsx +17 -10
  137. package/src/components/config/runtime-control-card.test.tsx +56 -0
  138. package/src/components/config/runtime-control-card.tsx +25 -0
  139. package/src/components/config/runtime-presence-card.tsx +93 -79
  140. package/src/components/layout/AppLayout.tsx +25 -37
  141. package/src/components/layout/app-layout.test.tsx +46 -14
  142. package/src/components/layout/runtime-status-entry.test.tsx +157 -0
  143. package/src/components/layout/runtime-status-entry.tsx +143 -0
  144. package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
  145. package/src/components/marketplace/marketplace-list-card.tsx +288 -0
  146. package/src/components/marketplace/marketplace-page-data.ts +129 -0
  147. package/src/components/marketplace/marketplace-page.test.tsx +339 -0
  148. package/src/components/marketplace/marketplace-page.tsx +596 -0
  149. package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
  150. package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
  151. package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
  152. package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
  153. package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
  154. package/src/components/providers/ThemeProvider.tsx +5 -0
  155. package/src/components/remote/remote-access-page.test.tsx +105 -0
  156. package/src/components/remote/remote-access-page.tsx +248 -0
  157. package/src/components/ui/notice-card.tsx +129 -0
  158. package/src/components/ui/setting-row.tsx +51 -0
  159. package/src/components/ui/tag-chip.tsx +39 -0
  160. package/src/components/ui/textarea.tsx +19 -0
  161. package/src/hooks/server-path/use-server-path-read.ts +20 -0
  162. package/src/hooks/useConfig.ts +2 -1
  163. package/src/index.css +24 -0
  164. package/src/lib/app-resource-uri.test.ts +20 -0
  165. package/src/lib/app-resource-uri.ts +29 -0
  166. package/src/lib/chat-message.ts +14 -3
  167. package/src/lib/i18n.chat.ts +12 -1
  168. package/src/lib/i18n.pwa.ts +62 -0
  169. package/src/lib/i18n.remote.ts +1 -1
  170. package/src/lib/i18n.runtime-control.ts +31 -0
  171. package/src/lib/i18n.ts +7 -10
  172. package/src/lib/session-context.utils.test.ts +71 -0
  173. package/src/lib/session-context.utils.ts +28 -3
  174. package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
  175. package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
  176. package/src/pwa/components/pwa-install-entry.test.tsx +110 -0
  177. package/src/pwa/components/pwa-install-entry.tsx +205 -0
  178. package/src/pwa/managers/pwa-install.manager.test.ts +160 -0
  179. package/src/pwa/managers/pwa-install.manager.ts +232 -0
  180. package/src/pwa/managers/pwa-runtime.manager.ts +196 -0
  181. package/src/pwa/managers/pwa-shell-theme.manager.test.ts +30 -0
  182. package/src/pwa/managers/pwa-shell-theme.manager.ts +46 -0
  183. package/src/pwa/pwa-install-banner.storage.ts +55 -0
  184. package/src/pwa/pwa.types.ts +22 -0
  185. package/src/pwa/register-pwa.ts +14 -0
  186. package/src/pwa/stores/pwa.store.ts +17 -0
  187. package/src/vite-env.d.ts +9 -0
  188. package/dist/assets/ChannelsList-KIQIxluX.js +0 -8
  189. package/dist/assets/DocBrowser-CyDgAtO9.js +0 -1
  190. package/dist/assets/MarketplacePage-BySqkYDh.js +0 -49
  191. package/dist/assets/MarketplacePage-C0olZaek.js +0 -1
  192. package/dist/assets/McpMarketplacePage-DqKaiXO9.js +0 -40
  193. package/dist/assets/ModelConfig-IrmzoslW.js +0 -1
  194. package/dist/assets/ProvidersList-8_Kalfwl.js +0 -1
  195. package/dist/assets/RemoteAccessPage-CyQlSjPf.js +0 -1
  196. package/dist/assets/RuntimeConfig-Bk0uYBhf.js +0 -1
  197. package/dist/assets/SearchConfig-DNBR-UbE.js +0 -1
  198. package/dist/assets/SessionsConfig-Doqp5ghH.js +0 -2
  199. package/dist/assets/chat-page-Bph8M5zo.js +0 -58
  200. package/dist/assets/chat-session-display-CoN3Wmn-.js +0 -1
  201. package/dist/assets/config-layout-DmlGaay2.js +0 -1
  202. package/dist/assets/desktop-update-config-1KBrqLBC.js +0 -1
  203. package/dist/assets/i18n-CwHZ-9vt.js +0 -1
  204. package/dist/assets/index-DafCdM4F.css +0 -1
  205. package/dist/assets/index-DdksE6U3.js +0 -6
  206. package/dist/assets/loader-circle-PsSP0H9n.js +0 -1
  207. package/dist/assets/play-DBQbBxTA.js +0 -1
  208. package/dist/assets/plus-DUOVbsyQ.js +0 -1
  209. package/dist/assets/popover-C_mWOFzI.js +0 -1
  210. package/dist/assets/search-MChQRYR1.js +0 -1
  211. package/dist/assets/security-config-CbXfPZzr.js +0 -1
  212. package/dist/assets/select-Caud8QvU.js +0 -41
  213. package/dist/assets/skeleton-B-4vRq_Z.js +0 -1
  214. package/dist/assets/x-DuMhMATD.js +0 -1
  215. package/src/components/agents/AgentDialogs.tsx +0 -400
  216. package/src/components/agents/AgentsPage.test.tsx +0 -217
  217. package/src/components/agents/AgentsPage.tsx +0 -352
  218. package/src/components/chat/ChatConversationPanel.tsx +0 -256
  219. package/src/components/chat/chat-child-session-panel.tsx +0 -270
  220. package/src/components/config/config-layout.ts +0 -10
  221. package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
  222. package/src/components/marketplace/MarketplacePage.tsx +0 -827
  223. package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
  224. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
  225. package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
  226. package/src/components/remote/RemoteAccessPage.tsx +0 -144
  227. /package/dist/assets/{config-hints-BZoDjXye.js → config-hints-BhTmc9P1.js} +0 -0
@@ -0,0 +1,178 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ChatSessionWorkspaceFilePreview } from "@/components/chat/chat-session-workspace-file-preview";
4
+ import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
5
+ import { t } from "@/lib/i18n";
6
+
7
+ const serverPathReadMock = vi.fn();
8
+
9
+ vi.mock("@/hooks/server-path/use-server-path-read", () => ({
10
+ useServerPathRead: (...args: unknown[]) => serverPathReadMock(...args),
11
+ }));
12
+
13
+ vi.mock("@nextclaw/agent-chat-ui", () => ({
14
+ ChatMessageMarkdown: ({ text }: { text: string }) => (
15
+ <div data-testid="markdown-preview">{text}</div>
16
+ ),
17
+ FileOperationCodeSurface: ({
18
+ layout,
19
+ }: {
20
+ layout?: "compact" | "workspace";
21
+ }) => <div data-testid="file-code-surface" data-layout={layout ?? "compact"} />,
22
+ }));
23
+
24
+ function buildWorkspaceFile(
25
+ overrides: Partial<ChatWorkspaceFileTab>,
26
+ ): ChatWorkspaceFileTab {
27
+ return {
28
+ key: "workspace-file",
29
+ parentSessionKey: null,
30
+ path: "/tmp/example.ts",
31
+ label: "example.ts",
32
+ viewMode: "preview",
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ describe("ChatSessionWorkspaceFilePreview", () => {
38
+ beforeEach(() => {
39
+ serverPathReadMock.mockReset();
40
+ });
41
+
42
+ it("renders preview files inside a full-height workspace code surface", () => {
43
+ serverPathReadMock.mockReturnValue({
44
+ isLoading: false,
45
+ error: null,
46
+ data: {
47
+ kind: "text",
48
+ resolvedPath: "/tmp/example.ts",
49
+ text: "const answer = 42;\n",
50
+ truncated: false,
51
+ },
52
+ });
53
+
54
+ render(
55
+ <ChatSessionWorkspaceFilePreview
56
+ file={buildWorkspaceFile({ viewMode: "preview" })}
57
+ sessionProjectRoot="/tmp"
58
+ onFileOpen={vi.fn()}
59
+ />,
60
+ );
61
+
62
+ expect(screen.getByTestId("file-code-surface").getAttribute("data-layout")).toBe(
63
+ "workspace",
64
+ );
65
+ });
66
+
67
+ it("renders diff files inside a full-height workspace code surface", () => {
68
+ serverPathReadMock.mockReturnValue({
69
+ isLoading: false,
70
+ error: null,
71
+ data: null,
72
+ });
73
+
74
+ render(
75
+ <ChatSessionWorkspaceFilePreview
76
+ file={buildWorkspaceFile({
77
+ viewMode: "diff",
78
+ beforeText: "const answer = 41;\n",
79
+ afterText: "const answer = 42;\n",
80
+ oldStartLine: 1,
81
+ newStartLine: 1,
82
+ })}
83
+ sessionProjectRoot="/tmp"
84
+ onFileOpen={vi.fn()}
85
+ />,
86
+ );
87
+
88
+ expect(screen.getByTestId("file-code-surface").getAttribute("data-layout")).toBe(
89
+ "workspace",
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
+ });
178
+ });
@@ -0,0 +1,278 @@
1
+ import { useMemo } from "react";
2
+ import type {
3
+ ChatFileOpenActionViewModel,
4
+ ChatFileOperationBlockViewModel,
5
+ } from "@nextclaw/agent-chat-ui";
6
+ import {
7
+ ChatMessageMarkdown,
8
+ FileOperationCodeSurface,
9
+ } from "@nextclaw/agent-chat-ui";
10
+ import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
11
+ import { ChatSessionWorkspaceFileBreadcrumbs } from "@/components/chat/workspace/chat-session-workspace-file-breadcrumbs";
12
+ import { useServerPathRead } from "@/hooks/server-path/use-server-path-read";
13
+ import {
14
+ buildLineDiff,
15
+ buildPreviewLines,
16
+ } from "@/components/chat/adapters/file-operation/line-builder";
17
+ import { t } from "@/lib/i18n";
18
+ import { buildWorkspaceFileBreadcrumb } from "@/lib/session-project/workspace-file-breadcrumb";
19
+ import { cn } from "@/lib/utils";
20
+
21
+ function inferPreviewKind(params: {
22
+ path: string;
23
+ serverKind?: "text" | "markdown" | "binary";
24
+ }): "text" | "markdown" | "binary" {
25
+ if (params.serverKind) {
26
+ return params.serverKind;
27
+ }
28
+ return /\.mdx?$/i.test(params.path) ? "markdown" : "text";
29
+ }
30
+
31
+ function buildPreviewBlock(params: {
32
+ path: string;
33
+ text: string;
34
+ line?: number | null;
35
+ }): ChatFileOperationBlockViewModel {
36
+ const { line, path, text } = params;
37
+ const startLine = line ?? 1;
38
+ return {
39
+ key: `preview:${path}`,
40
+ path,
41
+ display: "preview",
42
+ lines: buildPreviewLines({
43
+ text,
44
+ kind: "context",
45
+ oldStartLine: startLine,
46
+ newStartLine: startLine,
47
+ }),
48
+ rawText: text,
49
+ oldStartLine: startLine,
50
+ newStartLine: startLine,
51
+ };
52
+ }
53
+
54
+ function buildDiffBlock(
55
+ file: ChatWorkspaceFileTab,
56
+ ): ChatFileOperationBlockViewModel | null {
57
+ if (Array.isArray(file.fullLines) && file.fullLines.length > 0) {
58
+ return {
59
+ key: `diff:${file.key}`,
60
+ path: file.path,
61
+ display: "diff",
62
+ lines: file.fullLines,
63
+ fullLines: file.fullLines,
64
+ ...(file.beforeText ? { beforeText: file.beforeText } : {}),
65
+ ...(file.afterText ? { afterText: file.afterText } : {}),
66
+ ...(file.patchText ? { patchText: file.patchText } : {}),
67
+ ...(typeof file.oldStartLine === "number"
68
+ ? { oldStartLine: file.oldStartLine }
69
+ : {}),
70
+ ...(typeof file.newStartLine === "number"
71
+ ? { newStartLine: file.newStartLine }
72
+ : {}),
73
+ };
74
+ }
75
+
76
+ if (file.beforeText == null && file.afterText == null) {
77
+ return null;
78
+ }
79
+
80
+ const lines = buildLineDiff({
81
+ beforeText: file.beforeText ?? "",
82
+ afterText: file.afterText ?? "",
83
+ oldStartLine: file.oldStartLine ?? undefined,
84
+ newStartLine: file.newStartLine ?? undefined,
85
+ });
86
+
87
+ return {
88
+ key: `diff:${file.key}`,
89
+ path: file.path,
90
+ display: "diff",
91
+ lines,
92
+ fullLines: lines,
93
+ ...(file.beforeText ? { beforeText: file.beforeText } : {}),
94
+ ...(file.afterText ? { afterText: file.afterText } : {}),
95
+ ...(typeof file.oldStartLine === "number"
96
+ ? { oldStartLine: file.oldStartLine }
97
+ : {}),
98
+ ...(typeof file.newStartLine === "number"
99
+ ? { newStartLine: file.newStartLine }
100
+ : {}),
101
+ };
102
+ }
103
+
104
+ function WorkspaceFilePreviewStatus({
105
+ text,
106
+ tone = "muted",
107
+ }: {
108
+ text: string;
109
+ tone?: "muted" | "error";
110
+ }) {
111
+ return (
112
+ <div
113
+ className={cn(
114
+ "flex h-full items-center justify-center px-6 text-center text-sm",
115
+ tone === "error" ? "text-rose-600" : "text-gray-500",
116
+ )}
117
+ >
118
+ {text}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function WorkspaceDiffBody({
124
+ diffBlock,
125
+ }: {
126
+ diffBlock: ChatFileOperationBlockViewModel | null;
127
+ }) {
128
+ if (!diffBlock) {
129
+ return <WorkspaceFilePreviewStatus text={t("chatWorkspaceDiffEmpty")} />;
130
+ }
131
+
132
+ return (
133
+ <WorkspaceCodeSurface block={diffBlock} />
134
+ );
135
+ }
136
+
137
+ function WorkspaceCodeSurface({
138
+ block,
139
+ }: {
140
+ block: ChatFileOperationBlockViewModel;
141
+ }) {
142
+ return (
143
+ <div className="h-full overflow-auto custom-scrollbar bg-white">
144
+ <FileOperationCodeSurface block={block} layout="workspace" />
145
+ </div>
146
+ );
147
+ }
148
+
149
+ function WorkspacePreviewBody({
150
+ onFileOpen,
151
+ previewBlock,
152
+ previewKind,
153
+ previewQuery,
154
+ previewText,
155
+ }: {
156
+ onFileOpen: (action: ChatFileOpenActionViewModel) => void;
157
+ previewBlock: ChatFileOperationBlockViewModel | null;
158
+ previewKind: "text" | "markdown" | "binary";
159
+ previewQuery: ReturnType<typeof useServerPathRead>;
160
+ previewText: string | null;
161
+ }) {
162
+ if (previewQuery.isLoading && !previewBlock) {
163
+ return <WorkspaceFilePreviewStatus text={t("chatWorkspaceLoadingFile")} />;
164
+ }
165
+
166
+ if (previewQuery.data?.kind === "binary") {
167
+ return (
168
+ <WorkspaceFilePreviewStatus text={t("chatWorkspacePreviewUnsupported")} />
169
+ );
170
+ }
171
+
172
+ if (previewQuery.error && !previewBlock) {
173
+ return (
174
+ <WorkspaceFilePreviewStatus
175
+ tone="error"
176
+ text={
177
+ previewQuery.error instanceof Error
178
+ ? previewQuery.error.message
179
+ : String(previewQuery.error)
180
+ }
181
+ />
182
+ );
183
+ }
184
+
185
+ if (previewKind === "markdown" && previewText) {
186
+ return (
187
+ <div className="h-full overflow-auto custom-scrollbar px-5 py-4">
188
+ <ChatMessageMarkdown
189
+ text={previewText}
190
+ role="assistant"
191
+ texts={{
192
+ copyCodeLabel: t("chatCodeCopy"),
193
+ copiedCodeLabel: t("chatCodeCopied"),
194
+ }}
195
+ onFileOpen={onFileOpen}
196
+ />
197
+ </div>
198
+ );
199
+ }
200
+
201
+ if (previewBlock) {
202
+ return <WorkspaceCodeSurface block={previewBlock} />;
203
+ }
204
+
205
+ return <WorkspaceFilePreviewStatus text={t("chatWorkspacePreviewEmpty")} />;
206
+ }
207
+
208
+ type ChatSessionWorkspaceFilePreviewProps = {
209
+ file: ChatWorkspaceFileTab;
210
+ sessionProjectRoot: string | null;
211
+ onFileOpen: (action: ChatFileOpenActionViewModel) => void;
212
+ };
213
+
214
+ export function ChatSessionWorkspaceFilePreview({
215
+ file,
216
+ sessionProjectRoot,
217
+ onFileOpen,
218
+ }: ChatSessionWorkspaceFilePreviewProps) {
219
+ const isPreviewMode = file.viewMode === "preview";
220
+ const previewQuery = useServerPathRead({
221
+ path: file.path,
222
+ basePath: sessionProjectRoot,
223
+ enabled: isPreviewMode,
224
+ });
225
+ const diffBlock = useMemo(
226
+ () => (file.viewMode === "diff" ? buildDiffBlock(file) : null),
227
+ [file],
228
+ );
229
+ const previewText =
230
+ isPreviewMode ? previewQuery.data?.text ?? file.rawText ?? null : null;
231
+ const previewKind = inferPreviewKind({
232
+ path: previewQuery.data?.resolvedPath ?? file.path,
233
+ serverKind: previewQuery.data?.kind,
234
+ });
235
+ const previewBlock = useMemo(() => {
236
+ if (!isPreviewMode || !previewText) {
237
+ return null;
238
+ }
239
+ return buildPreviewBlock({
240
+ path: previewQuery.data?.resolvedPath ?? file.path,
241
+ text: previewText,
242
+ line: file.line,
243
+ });
244
+ }, [file.line, file.path, isPreviewMode, previewQuery.data?.resolvedPath, previewText]);
245
+ const resolvedPath = previewQuery.data?.resolvedPath ?? file.path;
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
+ );
258
+
259
+ return (
260
+ <div className="flex h-full min-h-0 flex-col bg-white">
261
+ <ChatSessionWorkspaceFileBreadcrumbs breadcrumb={breadcrumb} />
262
+
263
+ <div className="flex-1 min-h-0 overflow-hidden">
264
+ {file.viewMode === "diff" ? (
265
+ <WorkspaceDiffBody diffBlock={diffBlock} />
266
+ ) : (
267
+ <WorkspacePreviewBody
268
+ onFileOpen={onFileOpen}
269
+ previewBlock={previewBlock}
270
+ previewKind={previewKind}
271
+ previewQuery={previewQuery}
272
+ previewText={previewText}
273
+ />
274
+ )}
275
+ </div>
276
+ </div>
277
+ );
278
+ }
@@ -0,0 +1,203 @@
1
+ import { FileCode2, MessageSquareText, X } from "lucide-react";
2
+ import type { ResolvedChildSessionTab } from "@/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view";
3
+ import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
4
+ import { AgentIdentityAvatar } from "@/components/common/agent-identity";
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipProvider,
9
+ TooltipTrigger,
10
+ } from "@/components/ui/tooltip";
11
+ import { t } from "@/lib/i18n";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ export type WorkspaceSelection =
15
+ | {
16
+ kind: "child-session";
17
+ tab: ResolvedChildSessionTab;
18
+ }
19
+ | {
20
+ kind: "file";
21
+ file: ChatWorkspaceFileTab;
22
+ };
23
+
24
+ export type WorkspaceTabViewModel = {
25
+ key: string;
26
+ kind: "child-session" | "file";
27
+ title: string;
28
+ tooltip: string;
29
+ active: boolean;
30
+ agentId?: string | null;
31
+ showUnreadDot?: boolean;
32
+ viewMode?: "preview" | "diff";
33
+ onSelect: () => void;
34
+ onClose?: () => void;
35
+ };
36
+
37
+ export function readWorkspaceFileTitle(file: ChatWorkspaceFileTab): string {
38
+ const label = file.label?.trim();
39
+ if (label) {
40
+ return label;
41
+ }
42
+ return file.path.split("/").filter(Boolean).pop() || file.path;
43
+ }
44
+
45
+ export function resolveWorkspaceSelection(params: {
46
+ activeChildSessionKey: string | null;
47
+ activeWorkspaceFileKey: string | null;
48
+ childSessionTabs: ResolvedChildSessionTab[];
49
+ workspaceFileTabs: readonly ChatWorkspaceFileTab[];
50
+ }): WorkspaceSelection | null {
51
+ const {
52
+ activeChildSessionKey,
53
+ activeWorkspaceFileKey,
54
+ childSessionTabs,
55
+ workspaceFileTabs,
56
+ } = params;
57
+
58
+ if (activeWorkspaceFileKey) {
59
+ const activeFile = workspaceFileTabs.find(
60
+ (file) => file.key === activeWorkspaceFileKey,
61
+ );
62
+ if (activeFile) {
63
+ return {
64
+ kind: "file",
65
+ file: activeFile,
66
+ };
67
+ }
68
+ }
69
+
70
+ if (activeChildSessionKey) {
71
+ const activeChild = childSessionTabs.find(
72
+ (tab) => tab.sessionKey === activeChildSessionKey,
73
+ );
74
+ if (activeChild) {
75
+ return {
76
+ kind: "child-session",
77
+ tab: activeChild,
78
+ };
79
+ }
80
+ }
81
+
82
+ if (childSessionTabs[0]) {
83
+ return {
84
+ kind: "child-session",
85
+ tab: childSessionTabs[0],
86
+ };
87
+ }
88
+
89
+ if (workspaceFileTabs[0]) {
90
+ return {
91
+ kind: "file",
92
+ file: workspaceFileTabs[0],
93
+ };
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ function WorkspaceTabIcon({
100
+ agentId,
101
+ kind,
102
+ }: Pick<WorkspaceTabViewModel, "agentId" | "kind">) {
103
+ if (kind === "file") {
104
+ return <FileCode2 className="h-3.5 w-3.5 shrink-0 text-gray-400" />;
105
+ }
106
+
107
+ if (agentId) {
108
+ return (
109
+ <AgentIdentityAvatar
110
+ agentId={agentId}
111
+ className="h-3.5 w-3.5 shrink-0"
112
+ />
113
+ );
114
+ }
115
+
116
+ return <MessageSquareText className="h-3.5 w-3.5 shrink-0 text-gray-400" />;
117
+ }
118
+
119
+ function WorkspaceTabItem({
120
+ tab,
121
+ }: {
122
+ tab: WorkspaceTabViewModel;
123
+ }) {
124
+ return (
125
+ <TooltipProvider delayDuration={250}>
126
+ <Tooltip>
127
+ <TooltipTrigger asChild>
128
+ <div
129
+ className={cn(
130
+ "group flex max-w-[180px] min-w-0 items-center gap-1.5 border-r border-gray-200/70 border-b-2 px-2.5 py-2 transition-colors",
131
+ tab.active
132
+ ? "border-b-primary bg-white text-gray-900"
133
+ : "border-b-transparent bg-gray-50/85 text-gray-500 hover:bg-gray-100",
134
+ )}
135
+ >
136
+ <button
137
+ type="button"
138
+ onClick={tab.onSelect}
139
+ className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
140
+ >
141
+ <WorkspaceTabIcon kind={tab.kind} agentId={tab.agentId} />
142
+ <span className="min-w-0 truncate text-[12px] font-medium">
143
+ {tab.title}
144
+ </span>
145
+ {tab.kind === "file" && tab.viewMode === "diff" ? (
146
+ <span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1 py-0 text-[9px] font-medium uppercase tracking-[0.08em] text-amber-700">
147
+ {t("chatWorkspaceDiff")}
148
+ </span>
149
+ ) : null}
150
+ {tab.showUnreadDot ? (
151
+ <span
152
+ aria-label={t("chatSessionUnread")}
153
+ className="h-2 w-2 shrink-0 rounded-full bg-primary"
154
+ />
155
+ ) : null}
156
+ </button>
157
+ {tab.onClose ? (
158
+ <button
159
+ type="button"
160
+ onClick={(event) => {
161
+ event.stopPropagation();
162
+ tab.onClose?.();
163
+ }}
164
+ className={cn(
165
+ "rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700",
166
+ tab.active ? "opacity-100" : "opacity-0 group-hover:opacity-100",
167
+ )}
168
+ aria-label={t("chatWorkspaceCloseFile")}
169
+ >
170
+ <X className="h-3 w-3" />
171
+ </button>
172
+ ) : null}
173
+ </div>
174
+ </TooltipTrigger>
175
+ <TooltipContent side="bottom" className="max-w-[320px] text-xs">
176
+ {tab.tooltip}
177
+ </TooltipContent>
178
+ </Tooltip>
179
+ </TooltipProvider>
180
+ );
181
+ }
182
+
183
+ export function WorkspaceTabsBar({
184
+ tabs,
185
+ }: {
186
+ tabs: readonly WorkspaceTabViewModel[];
187
+ }) {
188
+ return (
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
+ >
197
+ {tabs.map((tab) => (
198
+ <WorkspaceTabItem key={tab.key} tab={tab} />
199
+ ))}
200
+ </div>
201
+ </div>
202
+ );
203
+ }