@nextclaw/ui 0.14.4 → 0.15.0

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 (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/assets/api-DLtsiBKm.js +13 -0
  3. package/dist/assets/appearance-settings-page-CtkeYXux.js +1 -0
  4. package/dist/assets/book-open-BQwWBDKb.js +1 -0
  5. package/dist/assets/channels-list-page-Cv8o9Hxg.js +8 -0
  6. package/dist/assets/chat-page-BtA7H1Ea.js +1 -0
  7. package/dist/assets/{config-split-page-CRhlZ_pH.js → config-split-page-EidRBpP2.js} +1 -1
  8. package/dist/assets/confirm-dialog-iIJFFp2T.js +5 -0
  9. package/dist/assets/desktop-update-config-C6n_TB1r.js +1 -0
  10. package/dist/assets/{dist-CikkhOlq.js → dist-CBxWOazz.js} +1 -1
  11. package/dist/assets/{dist-PVA_xL1D.js → dist-D2eZC3OR.js} +1 -1
  12. package/dist/assets/doc-browser-B76XOXmW.js +1 -0
  13. package/dist/assets/doc-browser-DBnj9UMB.js +1 -0
  14. package/dist/assets/{doc-browser-context-MTw4EvKd.js → doc-browser-context-Sv-044Cl.js} +1 -1
  15. package/dist/assets/{ellipsis-y6vDtq4M.js → ellipsis-2uzhypw5.js} +1 -1
  16. package/dist/assets/es2015-DhQo_oS6.js +41 -0
  17. package/dist/assets/{external-link-DVJIQf_G.js → external-link-CM5dnUI6.js} +1 -1
  18. package/dist/assets/host-capabilities-9bB_998Z.js +1 -0
  19. package/dist/assets/i18n-provider-di1waTGX.js +1 -0
  20. package/dist/assets/index-CBRP-7-a.css +1 -0
  21. package/dist/assets/index-Z1fYpbNd.js +104 -0
  22. package/dist/assets/loader-circle-BjmN5DeJ.js +1 -0
  23. package/dist/assets/mcp-marketplace-page-C_MVQUbT.js +9 -0
  24. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +1 -0
  25. package/dist/assets/model-config-page-Cdr9P6TQ.js +1 -0
  26. package/dist/assets/plus-3ztRZD_l.js +1 -0
  27. package/dist/assets/provider-scoped-model-input-g1WkGiJ8.js +1 -0
  28. package/dist/assets/providers-config-page-CWlO7J4A.js +1 -0
  29. package/dist/assets/{react-VNqT5Zp7.js → react-DjpzoZXB.js} +1 -1
  30. package/dist/assets/refresh-cw-DaTv3zxE.js +1 -0
  31. package/dist/assets/remote-CKWZfVVB.js +1 -0
  32. package/dist/assets/rotate-cw-CHcaRUDz.js +1 -0
  33. package/dist/assets/runtime-config-page-CfTqh1nv.js +1 -0
  34. package/dist/assets/{save-BOJ477H-.js → save-BZof1uie.js} +1 -1
  35. package/dist/assets/{search-BEti5beA.js → search-MxAarS_g.js} +1 -1
  36. package/dist/assets/search-config-page-Dm8daYny.js +1 -0
  37. package/dist/assets/{secrets-config-page--IzrIOid.js → secrets-config-page-D8AbcR05.js} +2 -2
  38. package/dist/assets/security-config-Ds0LsJmo.js +1 -0
  39. package/dist/assets/select-D7GcNP0z.js +1 -0
  40. package/dist/assets/setting-row-CuozlnJd.js +1 -0
  41. package/dist/assets/side-dock-BcLau5S_.js +3 -0
  42. package/dist/assets/skeleton-shfmXuYp.js +1 -0
  43. package/dist/assets/switch-C1BWMU5c.js +1 -0
  44. package/dist/assets/tag-chip-DCUrQUCP.js +1 -0
  45. package/dist/assets/tooltip-DfAHHA76.js +1 -0
  46. package/dist/assets/use-config-DAdbT5QJ.js +1 -0
  47. package/dist/assets/use-confirm-dialog-eWcJ6V-s.js +1 -0
  48. package/dist/assets/x-DqL1mYLC.js +1 -0
  49. package/dist/index.html +53 -17
  50. package/index.html +25 -2
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/app-layout.test.tsx +27 -0
  53. package/src/app/components/layout/__tests__/settings-entry-page.test.tsx +9 -1
  54. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +6 -3
  55. package/src/app/components/layout/app-layout.tsx +10 -3
  56. package/src/app/components/layout/sidebar-items.tsx +8 -8
  57. package/src/app/components/layout/sidebar-rail.styles.ts +4 -2
  58. package/src/app/components/layout/sidebar.tsx +21 -28
  59. package/src/app/configs/app-navigation.config.ts +11 -5
  60. package/src/app/index.tsx +8 -0
  61. package/src/app/managers/__tests__/viewport-layout.manager.test.ts +59 -0
  62. package/src/app/managers/viewport-layout.manager.ts +39 -0
  63. package/src/app/presenters/app.presenter.ts +25 -2
  64. package/src/app/styles/design-system.css +91 -4
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +86 -0
  66. package/src/features/chat/components/conversation/chat-conversation-content.tsx +47 -17
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +7 -0
  68. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +9 -5
  69. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +5 -5
  70. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +51 -2
  71. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +170 -1
  72. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +23 -1
  73. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +24 -7
  74. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +96 -0
  75. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +181 -0
  76. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +351 -89
  77. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +29 -4
  78. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +28 -3
  79. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -8
  80. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +50 -14
  81. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +77 -6
  82. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +21 -12
  83. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +166 -0
  84. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +19 -11
  85. package/src/features/chat/features/message/components/chat-message-list.container.tsx +38 -64
  86. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +59 -0
  87. package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +56 -1
  88. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -2
  89. package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +80 -0
  90. package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +12 -3
  91. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +60 -0
  92. package/src/features/chat/features/message/utils/chat-message.utils.ts +3 -0
  93. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +39 -0
  94. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  95. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +1 -1
  96. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +3 -1
  97. package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +270 -115
  99. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumb-browser.tsx +121 -0
  100. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +75 -20
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +70 -5
  102. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +83 -0
  103. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +79 -1
  104. package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +2 -0
  105. package/src/features/chat/managers/chat-thread.manager.ts +9 -3
  106. package/src/features/chat/managers/chat-ui.manager.ts +5 -1
  107. package/src/features/chat/presenters/chat.presenter.ts +2 -1
  108. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
  109. package/src/features/chat/stores/chat-thread.store.ts +6 -64
  110. package/src/features/chat/types/chat-message.types.ts +4 -0
  111. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +33 -26
  112. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +1 -0
  113. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  114. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.tsx +115 -0
  115. package/src/features/marketplace/components/marketplace-detail-doc.tsx +441 -0
  116. package/src/features/marketplace/components/mcp/__tests__/mcp-marketplace-page.test.tsx +3 -3
  117. package/src/features/marketplace/components/mcp/mcp-marketplace-doc.ts +0 -60
  118. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +64 -29
  119. package/src/features/marketplace/hooks/use-marketplace-item-detail.ts +42 -31
  120. package/src/features/marketplace/index.ts +3 -0
  121. package/src/features/marketplace/stores/marketplace-detail-doc.store.ts +38 -0
  122. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +32 -2
  123. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -0
  124. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +8 -3
  125. package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +25 -0
  126. package/src/features/settings/pages/appearance-settings-page.tsx +40 -0
  127. package/src/features/side-dock/components/__tests__/side-dock.test.tsx +44 -2
  128. package/src/features/side-dock/components/side-dock.tsx +100 -13
  129. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +10 -0
  130. package/src/features/side-dock/managers/__tests__/side-dock.manager.test.ts +32 -2
  131. package/src/features/side-dock/managers/side-dock.manager.ts +17 -7
  132. package/src/features/side-dock/stores/side-dock.store.ts +9 -0
  133. package/src/features/side-dock/types/side-dock.types.ts +10 -3
  134. package/src/features/side-dock/utils/side-dock-item.utils.ts +15 -2
  135. package/src/index.css +501 -27
  136. package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -1
  137. package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +30 -1
  138. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +206 -38
  139. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +34 -11
  140. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -1
  141. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +23 -5
  142. package/src/shared/components/doc-browser/doc-browser.tsx +128 -75
  143. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +9 -1
  144. package/src/shared/components/doc-browser/utils/doc-browser-floating-panel.utils.ts +83 -0
  145. package/src/shared/components/ui/__tests__/dialog.test.tsx +27 -0
  146. package/src/shared/components/ui/actions/icon-action-button.tsx +14 -2
  147. package/src/shared/components/ui/dialog.tsx +2 -2
  148. package/src/shared/components/ui/popover.tsx +1 -1
  149. package/src/shared/components/ui/select.tsx +1 -1
  150. package/src/shared/components/ui/switch.tsx +4 -3
  151. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +54 -0
  152. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +4 -5
  153. package/src/shared/components/ui/tooltip.tsx +1 -1
  154. package/src/shared/lib/api/utils/server-path.utils.ts +27 -1
  155. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -6
  156. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  157. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +11 -0
  158. package/src/shared/lib/i18n/locales/en-US/marketplace.json +5 -0
  159. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -6
  160. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +11 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +5 -0
  163. package/src/shared/lib/session-project/__tests__/workspace-file-breadcrumb.test.ts +14 -0
  164. package/src/shared/lib/session-project/index.ts +1 -1
  165. package/src/shared/lib/session-project/{workspace-file-breadcrumb.ts → workspace-file-breadcrumb.utils.ts} +62 -8
  166. package/src/shared/lib/theme/index.ts +51 -13
  167. package/dist/assets/api-BP_8IaxB.js +0 -13
  168. package/dist/assets/channels-list-page-DEh5Jjqx.js +0 -8
  169. package/dist/assets/chat-page-BRB0N5WW.js +0 -105
  170. package/dist/assets/confirm-dialog-DsivFJ4r.js +0 -5
  171. package/dist/assets/desktop-update-config-B0w9p2ep.js +0 -1
  172. package/dist/assets/doc-browser-BoNJ7-tE.js +0 -1
  173. package/dist/assets/doc-browser-CdJ_d4ik.js +0 -1
  174. package/dist/assets/index-BiE-K8-q.css +0 -1
  175. package/dist/assets/index-D9BAE_L_.js +0 -105
  176. package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +0 -40
  177. package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +0 -1
  178. package/dist/assets/model-config-page-DotaTUHb.js +0 -1
  179. package/dist/assets/plus-BbZqquJe.js +0 -1
  180. package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +0 -1
  181. package/dist/assets/providers-config-page-DW4tQSLN.js +0 -1
  182. package/dist/assets/remote-B8TiUfwW.js +0 -1
  183. package/dist/assets/rotate-cw-DjHDTkCv.js +0 -1
  184. package/dist/assets/runtime-config-page-S6ErRHR8.js +0 -1
  185. package/dist/assets/search-config-page-DGj5am03.js +0 -1
  186. package/dist/assets/select-hoj8dK4K.js +0 -41
  187. package/dist/assets/tag-chip-T8-h-die.js +0 -1
  188. package/dist/assets/use-config-CjRV2hwL.js +0 -1
  189. package/dist/assets/x-D9PXs_xP.js +0 -1
  190. package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.ts +0 -40
  191. package/src/features/marketplace/components/detail-doc/marketplace-detail-doc-renderer.ts +0 -201
  192. package/src/features/marketplace/components/marketplace-detail-doc.ts +0 -142
  193. /package/dist/assets/{config-hints-BkYBc7z0.js → config-hints-fGnUjDe9.js} +0 -0
  194. /package/dist/assets/{middleware-CkFT3R4v.js → middleware-BILWbJE4.js} +0 -0
@@ -92,7 +92,9 @@ describe('ChatSessionHeaderActions', () => {
92
92
  await user.click(screen.getByRole('button', { name: 'View Metadata' }));
93
93
 
94
94
  expect(screen.getByRole('dialog', { name: 'Session Metadata' })).toBeTruthy();
95
- expect(screen.getByText(/codex_thread_id/)).toBeTruthy();
95
+ const metadataBlock = screen.getByText(/codex_thread_id/);
96
+ expect(metadataBlock.className).toContain('bg-muted/60');
97
+ expect(metadataBlock.className).toContain('text-foreground');
96
98
  expect(screen.getByText(/thread-123/)).toBeTruthy();
97
99
  });
98
100
 
@@ -36,7 +36,7 @@ export function ChatSessionMetadataDialog({
36
36
  </DialogDescription>
37
37
  </DialogHeader>
38
38
  <div className="px-5 pb-5 pt-4">
39
- <pre className="max-h-[60vh] overflow-auto rounded-lg border border-gray-200 bg-gray-950 p-4 text-xs leading-relaxed text-gray-100">
39
+ <pre className="max-h-[60vh] overflow-auto rounded-lg border border-border bg-muted/60 p-4 font-mono text-xs leading-6 text-foreground shadow-inner custom-scrollbar">
40
40
  {formatSessionMetadata(metadata)}
41
41
  </pre>
42
42
  </div>
@@ -1,24 +1,55 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type {
4
+ ChatFileOpenActionViewModel,
5
+ ChatFileOperationBlockViewModel,
6
+ } from "@nextclaw/agent-chat-ui";
3
7
  import { ChatSessionWorkspaceFilePreview } from "@/features/chat/features/workspace/components/chat-session-workspace-file-preview";
4
8
  import type { ChatWorkspaceFileTab } from "@/features/chat/stores/chat-thread.store";
5
9
  import { t } from "@/shared/lib/i18n";
6
10
 
7
11
  const serverPathReadMock = vi.fn();
12
+ const serverPathBrowseMock = vi.fn();
13
+
14
+ type RenderWorkspaceFilePreviewOptions = {
15
+ file?: Partial<ChatWorkspaceFileTab>;
16
+ onFileOpen?: (action: ChatFileOpenActionViewModel) => void;
17
+ sessionProjectRoot?: string | null;
18
+ sessionWorkingDir?: string | null;
19
+ };
20
+
21
+ type TextReadDataOverrides = {
22
+ languageHint?: string;
23
+ resolvedPath?: string;
24
+ text?: string;
25
+ truncated?: boolean;
26
+ };
8
27
 
9
28
  vi.mock("@/shared/hooks/use-server-path-read", () => ({
10
29
  useServerPathRead: (...args: unknown[]) => serverPathReadMock(...args),
11
30
  }));
12
31
 
32
+ vi.mock("@/shared/hooks/use-server-path-browse", () => ({
33
+ useServerPathBrowse: (...args: unknown[]) => serverPathBrowseMock(...args),
34
+ }));
35
+
13
36
  vi.mock("@nextclaw/agent-chat-ui", () => ({
14
37
  ChatMessageMarkdown: ({ text }: { text: string }) => (
15
38
  <div data-testid="markdown-preview">{text}</div>
16
39
  ),
17
40
  FileOperationCodeSurface: ({
41
+ block,
18
42
  layout,
19
43
  }: {
44
+ block: ChatFileOperationBlockViewModel;
20
45
  layout?: "compact" | "workspace";
21
- }) => <div data-testid="file-code-surface" data-layout={layout ?? "compact"} />,
46
+ }) => (
47
+ <div
48
+ data-testid="file-code-surface"
49
+ data-language-hint={block.languageHint ?? ""}
50
+ data-layout={layout ?? "compact"}
51
+ />
52
+ ),
22
53
  }));
23
54
 
24
55
  function buildWorkspaceFile(
@@ -34,37 +65,167 @@ function buildWorkspaceFile(
34
65
  };
35
66
  }
36
67
 
37
- describe("ChatSessionWorkspaceFilePreview", () => {
38
- beforeEach(() => {
39
- serverPathReadMock.mockReset();
68
+ function mockTextRead(overrides: TextReadDataOverrides = {}) {
69
+ serverPathReadMock.mockReturnValue({
70
+ isLoading: false,
71
+ error: null,
72
+ data: {
73
+ kind: "text",
74
+ resolvedPath: "/tmp/example.ts",
75
+ text: "const answer = 42;\n",
76
+ truncated: false,
77
+ ...overrides,
78
+ },
40
79
  });
80
+ }
41
81
 
42
- it("renders preview files inside a full-height workspace code surface", () => {
43
- serverPathReadMock.mockReturnValue({
82
+ function renderWorkspaceFilePreview({
83
+ file,
84
+ onFileOpen = vi.fn(),
85
+ sessionProjectRoot = "/tmp",
86
+ sessionWorkingDir = "/tmp",
87
+ }: RenderWorkspaceFilePreviewOptions = {}) {
88
+ render(
89
+ <ChatSessionWorkspaceFilePreview
90
+ file={buildWorkspaceFile(file ?? {})}
91
+ sessionProjectRoot={sessionProjectRoot}
92
+ sessionWorkingDir={sessionWorkingDir}
93
+ onFileOpen={onFileOpen}
94
+ />,
95
+ );
96
+ return { onFileOpen };
97
+ }
98
+
99
+ function mockWorkspaceBreadcrumbBrowseTree() {
100
+ serverPathBrowseMock.mockImplementation(
101
+ ({ path }: { path?: string | null }) => ({
44
102
  isLoading: false,
45
103
  error: null,
46
- data: {
47
- kind: "text",
48
- resolvedPath: "/tmp/example.ts",
49
- text: "const answer = 42;\n",
50
- truncated: false,
51
- },
52
- });
104
+ data:
105
+ path === "/tmp/workspace/src/components"
106
+ ? {
107
+ currentPath: "/tmp/workspace/src/components",
108
+ parentPath: "/tmp/workspace/src",
109
+ homePath: "/Users/demo",
110
+ breadcrumbs: [
111
+ { label: "workspace", path: "/tmp/workspace" },
112
+ { label: "src", path: "/tmp/workspace/src" },
113
+ { label: "components", path: "/tmp/workspace/src/components" },
114
+ ],
115
+ entries: [
116
+ {
117
+ name: "button.tsx",
118
+ path: "/tmp/workspace/src/components/button.tsx",
119
+ kind: "file",
120
+ hidden: false,
121
+ },
122
+ ],
123
+ }
124
+ : {
125
+ currentPath: "/tmp/workspace/src",
126
+ parentPath: "/tmp/workspace",
127
+ homePath: "/Users/demo",
128
+ breadcrumbs: [
129
+ { label: "workspace", path: "/tmp/workspace" },
130
+ { label: "src", path: "/tmp/workspace/src" },
131
+ ],
132
+ entries: [
133
+ {
134
+ name: "components",
135
+ path: "/tmp/workspace/src/components",
136
+ kind: "directory",
137
+ hidden: false,
138
+ },
139
+ {
140
+ name: "index.ts",
141
+ path: "/tmp/workspace/src/index.ts",
142
+ kind: "file",
143
+ hidden: false,
144
+ },
145
+ ],
146
+ },
147
+ }),
148
+ );
149
+ }
53
150
 
54
- render(
55
- <ChatSessionWorkspaceFilePreview
56
- file={buildWorkspaceFile({ viewMode: "preview" })}
57
- sessionProjectRoot="/tmp"
58
- sessionWorkingDir="/tmp"
59
- onFileOpen={vi.fn()}
60
- />,
61
- );
151
+ beforeEach(() => {
152
+ serverPathReadMock.mockReset();
153
+ serverPathBrowseMock.mockReset();
154
+ serverPathBrowseMock.mockReturnValue({
155
+ isLoading: false,
156
+ error: null,
157
+ data: {
158
+ currentPath: "/tmp",
159
+ parentPath: null,
160
+ homePath: "/Users/demo",
161
+ breadcrumbs: [],
162
+ entries: [],
163
+ },
164
+ });
165
+ });
166
+
167
+ describe("ChatSessionWorkspaceFilePreview rendering", () => {
168
+ it("renders preview files inside a full-height workspace code surface", () => {
169
+ mockTextRead();
170
+ renderWorkspaceFilePreview();
62
171
 
63
172
  expect(screen.getByTestId("file-code-surface").getAttribute("data-layout")).toBe(
64
173
  "workspace",
65
174
  );
66
175
  });
67
176
 
177
+ it("passes server language hints to the workspace code surface", () => {
178
+ mockTextRead({ languageHint: "js", resolvedPath: "/tmp/example.js" });
179
+ renderWorkspaceFilePreview({
180
+ file: {
181
+ label: "example.js",
182
+ path: "/tmp/example.js",
183
+ viewMode: "preview",
184
+ },
185
+ });
186
+
187
+ expect(
188
+ screen.getByTestId("file-code-surface").getAttribute("data-language-hint"),
189
+ ).toBe("js");
190
+ });
191
+
192
+ it("renders HTML files through an unrestricted server content iframe when rendered preview is requested", () => {
193
+ mockTextRead({
194
+ resolvedPath: "/tmp/example.html",
195
+ text: "<!doctype html><h1>Hello</h1>",
196
+ });
197
+ renderWorkspaceFilePreview({
198
+ file: {
199
+ label: "example.html",
200
+ path: "/tmp/example.html",
201
+ previewViewer: "rendered",
202
+ viewMode: "preview",
203
+ },
204
+ });
205
+
206
+ const frame = screen.getByTestId("workspace-html-preview");
207
+ expect(frame.getAttribute("sandbox")).toBeNull();
208
+ expect(frame.getAttribute("srcdoc")).toBeNull();
209
+ expect(frame.getAttribute("src")).toContain(
210
+ "/api/server-paths/content/__abs__/tmp/example.html",
211
+ );
212
+ expect(screen.queryByTestId("file-code-surface")).toBeNull();
213
+ });
214
+
215
+ it("falls back to the source preview when rendered is requested for a non-HTML file", () => {
216
+ mockTextRead({ resolvedPath: "/tmp/example.txt", text: "plain text" });
217
+ renderWorkspaceFilePreview({
218
+ file: {
219
+ path: "/tmp/example.txt",
220
+ previewViewer: "rendered",
221
+ viewMode: "preview",
222
+ },
223
+ });
224
+
225
+ expect(screen.queryByTestId("workspace-html-preview")).toBeNull();
226
+ expect(screen.getByTestId("file-code-surface")).toBeTruthy();
227
+ });
228
+
68
229
  it("renders diff files inside a full-height workspace code surface", () => {
69
230
  serverPathReadMock.mockReturnValue({
70
231
  isLoading: false,
@@ -72,20 +233,15 @@ describe("ChatSessionWorkspaceFilePreview", () => {
72
233
  data: null,
73
234
  });
74
235
 
75
- render(
76
- <ChatSessionWorkspaceFilePreview
77
- file={buildWorkspaceFile({
78
- viewMode: "diff",
79
- beforeText: "const answer = 41;\n",
80
- afterText: "const answer = 42;\n",
81
- oldStartLine: 1,
82
- newStartLine: 1,
83
- })}
84
- sessionProjectRoot="/tmp"
85
- sessionWorkingDir="/tmp"
86
- onFileOpen={vi.fn()}
87
- />,
88
- );
236
+ renderWorkspaceFilePreview({
237
+ file: {
238
+ viewMode: "diff",
239
+ beforeText: "const answer = 41;\n",
240
+ afterText: "const answer = 42;\n",
241
+ oldStartLine: 1,
242
+ newStartLine: 1,
243
+ },
244
+ });
89
245
 
90
246
  expect(screen.getByTestId("file-code-surface").getAttribute("data-layout")).toBe(
91
247
  "workspace",
@@ -93,53 +249,24 @@ describe("ChatSessionWorkspaceFilePreview", () => {
93
249
  });
94
250
 
95
251
  it("does not repeat the preview badge inside the workspace header", () => {
96
- serverPathReadMock.mockReturnValue({
97
- isLoading: false,
98
- error: null,
99
- data: {
100
- kind: "text",
101
- resolvedPath: "/tmp/example.ts",
102
- text: "const answer = 42;\n",
103
- truncated: false,
104
- },
105
- });
106
-
107
- render(
108
- <ChatSessionWorkspaceFilePreview
109
- file={buildWorkspaceFile({ viewMode: "preview" })}
110
- sessionProjectRoot="/tmp"
111
- sessionWorkingDir="/tmp"
112
- onFileOpen={vi.fn()}
113
- />,
114
- );
252
+ mockTextRead();
253
+ renderWorkspaceFilePreview();
115
254
 
116
255
  expect(screen.queryByText(t("chatWorkspacePreview"))).toBeNull();
117
256
  expect(screen.getByTitle("/tmp/example.ts")).toBeTruthy();
118
257
  expect(screen.getByText("tmp")).toBeTruthy();
119
258
  expect(screen.getByText("example.ts")).toBeTruthy();
120
259
  });
260
+ });
121
261
 
262
+ describe("ChatSessionWorkspaceFilePreview breadcrumbs", () => {
122
263
  it("renders project-relative breadcrumbs when the file is inside the workspace", () => {
123
- serverPathReadMock.mockReturnValue({
124
- isLoading: false,
125
- error: null,
126
- data: {
127
- kind: "text",
128
- resolvedPath: "/tmp/workspace/src/example.ts",
129
- text: "const answer = 42;\n",
130
- truncated: false,
131
- },
264
+ mockTextRead({ resolvedPath: "/tmp/workspace/src/example.ts" });
265
+ renderWorkspaceFilePreview({
266
+ sessionProjectRoot: "/tmp/workspace",
267
+ sessionWorkingDir: "/tmp/workspace",
132
268
  });
133
269
 
134
- render(
135
- <ChatSessionWorkspaceFilePreview
136
- file={buildWorkspaceFile({ viewMode: "preview" })}
137
- sessionProjectRoot="/tmp/workspace"
138
- sessionWorkingDir="/tmp/workspace"
139
- onFileOpen={vi.fn()}
140
- />,
141
- );
142
-
143
270
  expect(screen.getByText("workspace")).toBeTruthy();
144
271
  expect(screen.getByText("src")).toBeTruthy();
145
272
  expect(screen.getByText("example.ts")).toBeTruthy();
@@ -151,30 +278,68 @@ describe("ChatSessionWorkspaceFilePreview", () => {
151
278
  );
152
279
  });
153
280
 
154
- it("keeps line and truncation metadata without the duplicated type badge", () => {
155
- serverPathReadMock.mockReturnValue({
156
- isLoading: false,
157
- error: null,
158
- data: {
159
- kind: "text",
160
- resolvedPath: "/tmp/example.ts",
161
- text: "const answer = 42;\n",
162
- truncated: true,
163
- },
281
+ it("browses from a breadcrumb segment and opens selected files", () => {
282
+ mockTextRead({ resolvedPath: "/tmp/workspace/src/example.ts" });
283
+ mockWorkspaceBreadcrumbBrowseTree();
284
+ const onFileOpen = vi.fn();
285
+
286
+ renderWorkspaceFilePreview({
287
+ onFileOpen,
288
+ sessionProjectRoot: "/tmp/workspace",
289
+ sessionWorkingDir: "/tmp/workspace",
290
+ });
291
+
292
+ fireEvent.click(screen.getByRole("button", { name: "src" }));
293
+
294
+ expect(serverPathBrowseMock).toHaveBeenCalledWith({
295
+ path: "/tmp/workspace/src",
296
+ includeFiles: true,
297
+ enabled: true,
164
298
  });
299
+ fireEvent.click(screen.getByRole("button", { name: "components" }));
300
+ fireEvent.click(screen.getByRole("button", { name: "button.tsx" }));
165
301
 
166
- render(
167
- <ChatSessionWorkspaceFilePreview
168
- file={buildWorkspaceFile({
169
- viewMode: "preview",
170
- line: 12,
171
- column: 4,
172
- })}
173
- sessionProjectRoot="/tmp"
174
- sessionWorkingDir="/tmp"
175
- onFileOpen={vi.fn()}
176
- />,
302
+ expect(onFileOpen).toHaveBeenCalledWith({
303
+ path: "/tmp/workspace/src/components/button.tsx",
304
+ label: "button.tsx",
305
+ viewMode: "preview",
306
+ });
307
+ });
308
+
309
+ it("keeps the breadcrumb browser compact", () => {
310
+ mockTextRead({ resolvedPath: "/tmp/workspace/src/example.ts" });
311
+ mockWorkspaceBreadcrumbBrowseTree();
312
+
313
+ renderWorkspaceFilePreview({
314
+ sessionProjectRoot: "/tmp/workspace",
315
+ sessionWorkingDir: "/tmp/workspace",
316
+ });
317
+
318
+ fireEvent.click(screen.getByRole("button", { name: "src" }));
319
+
320
+ expect(screen.getByTestId("workspace-breadcrumb-popover").className).toContain(
321
+ "w-[22rem]",
322
+ );
323
+ expect(screen.getByTestId("workspace-breadcrumb-browser").className).toContain(
324
+ "max-h-72",
325
+ );
326
+ expect(screen.getAllByRole("button", { name: "workspace" }).at(-1)?.className).toContain(
327
+ "h-5",
177
328
  );
329
+ expect(screen.getByRole("button", { name: "components" }).className).toContain(
330
+ "h-6",
331
+ );
332
+ });
333
+
334
+ it("keeps line and truncation metadata without the duplicated type badge", () => {
335
+ mockTextRead({ truncated: true });
336
+ renderWorkspaceFilePreview({
337
+ file: {
338
+ viewMode: "preview",
339
+ line: 12,
340
+ column: 4,
341
+ },
342
+ });
178
343
 
179
344
  expect(screen.getByText("L12:4")).toBeTruthy();
180
345
  expect(screen.getByText(t("chatWorkspacePreviewTruncated"))).toBeTruthy();
@@ -182,30 +347,20 @@ describe("ChatSessionWorkspaceFilePreview", () => {
182
347
  });
183
348
 
184
349
  it("uses the session working directory as the base path when no project root is set", () => {
185
- serverPathReadMock.mockReturnValue({
186
- isLoading: false,
187
- error: null,
188
- data: {
189
- kind: "text",
190
- resolvedPath: "/tmp/agent-workspace/AGENTS.md",
191
- text: "# Agent rules\n",
192
- truncated: false,
350
+ mockTextRead({
351
+ resolvedPath: "/tmp/agent-workspace/AGENTS.md",
352
+ text: "# Agent rules\n",
353
+ });
354
+ renderWorkspaceFilePreview({
355
+ file: {
356
+ path: "AGENTS.md",
357
+ label: "AGENTS.md",
358
+ viewMode: "preview",
193
359
  },
360
+ sessionProjectRoot: null,
361
+ sessionWorkingDir: "/tmp/agent-workspace",
194
362
  });
195
363
 
196
- render(
197
- <ChatSessionWorkspaceFilePreview
198
- file={buildWorkspaceFile({
199
- path: "AGENTS.md",
200
- label: "AGENTS.md",
201
- viewMode: "preview",
202
- })}
203
- sessionProjectRoot={null}
204
- sessionWorkingDir="/tmp/agent-workspace"
205
- onFileOpen={vi.fn()}
206
- />,
207
- );
208
-
209
364
  expect(serverPathReadMock).toHaveBeenCalledWith({
210
365
  path: "AGENTS.md",
211
366
  basePath: "/tmp/agent-workspace",
@@ -0,0 +1,121 @@
1
+ import { Fragment } from "react";
2
+ import type { ChatFileOpenActionViewModel } from "@nextclaw/agent-chat-ui";
3
+ import { ChevronRight, File, Folder, Loader2 } from "lucide-react";
4
+ import { ScrollArea } from "@/shared/components/ui/scroll-area";
5
+ import { useServerPathBrowse } from "@/shared/hooks/use-server-path-browse";
6
+ import type { ServerPathEntryView } from "@/shared/lib/api";
7
+ import { t } from "@/shared/lib/i18n";
8
+ import { cn } from "@/shared/lib/utils";
9
+
10
+ function WorkspaceBreadcrumbEntryIcon({
11
+ kind,
12
+ }: {
13
+ kind: ServerPathEntryView["kind"];
14
+ }) {
15
+ return kind === "directory" ? (
16
+ <Folder className="h-3.5 w-3.5 shrink-0 text-emerald-600" />
17
+ ) : (
18
+ <File className="h-3.5 w-3.5 shrink-0 text-sky-600" />
19
+ );
20
+ }
21
+
22
+ export function WorkspaceBreadcrumbBrowser({
23
+ browsePath,
24
+ onBrowsePathChange,
25
+ onClose,
26
+ onFileOpen,
27
+ }: {
28
+ browsePath: string | null;
29
+ onBrowsePathChange: (path: string) => void;
30
+ onClose: () => void;
31
+ onFileOpen: (action: ChatFileOpenActionViewModel) => void;
32
+ }) {
33
+ const browseQuery = useServerPathBrowse({
34
+ path: browsePath,
35
+ includeFiles: true,
36
+ enabled: Boolean(browsePath),
37
+ });
38
+ const errorMessage = browseQuery.error
39
+ ? browseQuery.error instanceof Error
40
+ ? browseQuery.error.message
41
+ : String(browseQuery.error)
42
+ : null;
43
+ const entries = browseQuery.data?.entries ?? [];
44
+
45
+ const openEntry = (entry: ServerPathEntryView) => {
46
+ if (entry.kind === "directory") {
47
+ onBrowsePathChange(entry.path);
48
+ return;
49
+ }
50
+
51
+ onFileOpen({
52
+ path: entry.path,
53
+ label: entry.name,
54
+ viewMode: "preview",
55
+ });
56
+ onClose();
57
+ };
58
+
59
+ return (
60
+ <div
61
+ data-testid="workspace-breadcrumb-browser"
62
+ className="flex max-h-72 flex-col overflow-hidden"
63
+ >
64
+ <div className="border-b border-gray-200 px-2 py-1">
65
+ <div className="flex min-w-0 flex-wrap items-center gap-0.5 text-[11px] leading-none text-gray-600">
66
+ {browseQuery.data?.breadcrumbs.map((breadcrumb, index) => (
67
+ <Fragment key={breadcrumb.path}>
68
+ <button
69
+ type="button"
70
+ className="h-5 rounded-sm px-1 text-left hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
71
+ onClick={() => onBrowsePathChange(breadcrumb.path)}
72
+ >
73
+ {breadcrumb.label}
74
+ </button>
75
+ {index < browseQuery.data.breadcrumbs.length - 1 ? (
76
+ <ChevronRight className="h-3 w-3 shrink-0 text-gray-300" />
77
+ ) : null}
78
+ </Fragment>
79
+ ))}
80
+ </div>
81
+ </div>
82
+
83
+ <ScrollArea className="min-h-0 flex-1 px-1 py-1">
84
+ {browseQuery.isLoading ? (
85
+ <div className="flex items-center gap-1.5 px-2 py-4 text-[11px] text-gray-500">
86
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
87
+ {t("loading")}
88
+ </div>
89
+ ) : errorMessage ? (
90
+ <div className="px-2 py-3 text-[11px] text-rose-600">
91
+ {t("pathBrowseFailed")}: {errorMessage}
92
+ </div>
93
+ ) : entries.length > 0 ? (
94
+ <div className="space-y-0.5">
95
+ {entries.map((entry) => (
96
+ <button
97
+ key={entry.path}
98
+ type="button"
99
+ className={cn(
100
+ "flex h-6 w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[11px] text-gray-700 transition-colors hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
101
+ entry.hidden ? "opacity-65" : null,
102
+ )}
103
+ onClick={() => openEntry(entry)}
104
+ >
105
+ <WorkspaceBreadcrumbEntryIcon kind={entry.kind} />
106
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
107
+ {entry.kind === "directory" ? (
108
+ <ChevronRight className="h-3 w-3 shrink-0 text-gray-300" />
109
+ ) : null}
110
+ </button>
111
+ ))}
112
+ </div>
113
+ ) : (
114
+ <div className="px-2 py-4 text-[11px] text-gray-500">
115
+ {t("emptyDirectory")}
116
+ </div>
117
+ )}
118
+ </ScrollArea>
119
+ </div>
120
+ );
121
+ }