@nextclaw/ui 0.15.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assets/{api-b1R9VdeH.js → api-B7Qigvtm.js} +2 -2
  3. package/dist/assets/{appearance-settings-page-D2O9pch9.js → appearance-settings-page-CuE0Iiqj.js} +1 -1
  4. package/dist/assets/{book-open-B77lQ2Tg.js → book-open-Ba2f7YiG.js} +1 -1
  5. package/dist/assets/channels-list-page-CTsM6TcP.js +8 -0
  6. package/dist/assets/{chat-page-CZ7WxPir.js → chat-page-C_5dcuE3.js} +1 -1
  7. package/dist/assets/config-split-page-Fbb4PtTf.js +1 -0
  8. package/dist/assets/{confirm-dialog-C-NXSioX.js → confirm-dialog-D2EG3nqE.js} +1 -1
  9. package/dist/assets/{desktop-update-config-usHpXugP.js → desktop-update-config-BGR3HUi1.js} +1 -1
  10. package/dist/assets/{dist-jdGmbb6w.js → dist-7JTjycxE.js} +1 -1
  11. package/dist/assets/{dist-YtoqU-AT.js → dist-wXLhk4hl.js} +1 -1
  12. package/dist/assets/doc-browser-bHyXnxlj.js +1 -0
  13. package/dist/assets/doc-browser-context-CYAGm5d5.js +1 -0
  14. package/dist/assets/doc-browser-gTrQ3jCF.js +1 -0
  15. package/dist/assets/{es2015-bNpL2LKy.js → es2015-j1SRCIX5.js} +1 -1
  16. package/dist/assets/{external-link-BM6fk8q-.js → external-link-DxuPaDoK.js} +1 -1
  17. package/dist/assets/form-actions-BztL7P_T.js +1 -0
  18. package/dist/assets/{i18n-provider-CntWolRA.js → i18n-provider-B4oMxOFx.js} +1 -1
  19. package/dist/assets/index-CvrvblgX.css +1 -0
  20. package/dist/assets/index-Obd4JYU2.js +104 -0
  21. package/dist/assets/loader-circle-CdzgK4eH.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-CLmUstLN.js → mcp-marketplace-page-Bt7CuOoq.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-Ck3t78_n.js +1 -0
  24. package/dist/assets/model-config-page-DeKk9gvE.js +1 -0
  25. package/dist/assets/plus-Bx7wQbhG.js +1 -0
  26. package/dist/assets/{provider-scoped-model-input-Cm_a4GHf.js → provider-scoped-model-input-DsH2LF1q.js} +1 -1
  27. package/dist/assets/providers-config-page-BFnGp68F.js +1 -0
  28. package/dist/assets/{react-BN8uzPnp.js → react-UirzUuST.js} +1 -1
  29. package/dist/assets/{refresh-cw-BpVcFPmJ.js → refresh-cw-eT8BF-8B.js} +1 -1
  30. package/dist/assets/remote-CDkHZtA8.js +1 -0
  31. package/dist/assets/rotate-cw-1JqxHTuw.js +1 -0
  32. package/dist/assets/runtime-config-page-BelAqiNG.js +1 -0
  33. package/dist/assets/{save-BpFW5fpk.js → save-Cb2g0BOi.js} +1 -1
  34. package/dist/assets/{search-D0uO7enf.js → search-BcHjwdsb.js} +1 -1
  35. package/dist/assets/search-config-page-zPm6--Ig.js +1 -0
  36. package/dist/assets/secrets-config-page-Cxyp-KuQ.js +3 -0
  37. package/dist/assets/security-config-B_gCq9fn.js +1 -0
  38. package/dist/assets/{select-Bh-CrA4p.js → select-CdSEOwVM.js} +1 -1
  39. package/dist/assets/{setting-row-BJfCHRcb.js → setting-row-CTAtGexr.js} +1 -1
  40. package/dist/assets/{settings-2-Blzi1vr6.js → settings-2-2DWauOjY.js} +1 -1
  41. package/dist/assets/{side-dock-ZdaSrQVB.js → side-dock-DPvakJ0a.js} +2 -2
  42. package/dist/assets/{skeleton-BLlEzebZ.js → skeleton-P5oM14E1.js} +1 -1
  43. package/dist/assets/{switch-CFLJFNSd.js → switch-BU_UEULb.js} +1 -1
  44. package/dist/assets/{tag-chip-CzGihiQx.js → tag-chip-D-WsKoT1.js} +1 -1
  45. package/dist/assets/{tooltip-CdcrBjrC.js → tooltip-CjkOaHW7.js} +1 -1
  46. package/dist/assets/{use-config-CVPgsBmU.js → use-config--hx5oxQn.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-CSeBk9SF.js → use-confirm-dialog-CD9a_SK1.js} +1 -1
  48. package/dist/assets/x-DT5x45JU.js +1 -0
  49. package/dist/index.html +38 -30
  50. package/index.html +12 -4
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +29 -1
  53. package/src/app/components/layout/sidebar-items.tsx +43 -4
  54. package/src/app/components/layout/sidebar.tsx +83 -61
  55. package/src/app/configs/app-navigation.config.ts +49 -0
  56. package/src/app/styles/design-system.css +82 -0
  57. package/src/features/channels/components/config/channel-form.tsx +14 -11
  58. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +40 -0
  59. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +3 -1
  60. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -1
  61. package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +48 -0
  62. package/src/features/chat/components/layout/__tests__/chat-sidebar-utility-menu.test.tsx +55 -0
  63. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +0 -3
  64. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +3 -4
  65. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +29 -30
  66. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -7
  67. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +2 -1
  68. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +22 -1
  69. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -5
  70. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -11
  71. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +116 -0
  72. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +108 -46
  73. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +6 -0
  74. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +48 -24
  75. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +0 -3
  76. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +0 -4
  77. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +25 -3
  78. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +9 -4
  79. package/src/features/chat/features/welcome/components/chat-welcome.tsx +0 -5
  80. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +159 -56
  81. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -0
  82. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +144 -0
  83. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +99 -0
  84. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -1
  85. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +66 -7
  86. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -0
  87. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +44 -1
  88. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +45 -3
  89. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +33 -2
  90. package/src/features/chat/managers/chat-session-list.manager.ts +32 -0
  91. package/src/features/chat/stores/chat-session-list.store.ts +28 -5
  92. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +5 -12
  93. package/src/features/panel-apps/components/panel-app-list-item.tsx +6 -6
  94. package/src/features/panel-apps/index.ts +1 -0
  95. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -10
  96. package/src/features/panel-apps/utils/panel-app-iframe.utils.ts +6 -0
  97. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -3
  98. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +3 -1
  99. package/src/features/service-apps/components/service-apps-panel.tsx +145 -35
  100. package/src/features/settings/components/config/provider-auth-section.tsx +1 -1
  101. package/src/features/settings/components/config/provider-form.tsx +11 -8
  102. package/src/features/settings/components/config/provider-models-section.tsx +7 -7
  103. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  104. package/src/features/settings/components/config/secrets-config-form.tsx +5 -4
  105. package/src/features/settings/pages/model-config-page.tsx +26 -25
  106. package/src/features/settings/pages/providers-config-page.tsx +10 -13
  107. package/src/features/settings/pages/search-config-page.tsx +8 -5
  108. package/src/features/system-status/components/config/runtime-config-editor.tsx +5 -4
  109. package/src/index.css +18 -0
  110. package/src/shared/components/config-split-page.tsx +6 -6
  111. package/src/shared/components/doc-browser/__tests__/doc-browser-url.utils.test.ts +73 -0
  112. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +48 -0
  113. package/src/shared/components/doc-browser/doc-browser-context.tsx +4 -0
  114. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +5 -1
  115. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +2 -1
  116. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +10 -10
  117. package/src/shared/components/doc-browser/doc-browser.tsx +6 -4
  118. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +62 -4
  119. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +18 -0
  120. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +28 -3
  121. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +36 -0
  122. package/src/shared/components/ui/actions/form-actions.tsx +25 -0
  123. package/src/shared/components/ui/actions/icon-action-button.tsx +25 -2
  124. package/src/shared/components/ui/card.tsx +12 -4
  125. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +2 -1
  126. package/src/shared/hooks/use-server-path-browse.ts +26 -4
  127. package/src/shared/hooks/use-server-path-read.ts +11 -1
  128. package/src/shared/lib/api/utils/server-path.utils.ts +1 -0
  129. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -0
  130. package/src/shared/lib/i18n/locales/en-US/core.json +3 -0
  131. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +5 -0
  132. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -0
  133. package/src/shared/lib/i18n/locales/zh-CN/core.json +3 -0
  134. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +5 -0
  135. package/src/shared/lib/session-project/workspace-file-breadcrumb.utils.ts +9 -5
  136. package/src/shared/lib/theme/index.test.ts +21 -0
  137. package/src/shared/lib/theme/index.ts +6 -4
  138. package/src/vite-env.d.ts +2 -0
  139. package/dist/assets/channels-list-page-DcsIQc6A.js +0 -8
  140. package/dist/assets/config-split-page-BWQ6_Raq.js +0 -1
  141. package/dist/assets/doc-browser-B_Tf9n70.js +0 -1
  142. package/dist/assets/doc-browser-BdZPFOVa.js +0 -1
  143. package/dist/assets/doc-browser-context-DwokemUn.js +0 -1
  144. package/dist/assets/index-BIcEw90u.js +0 -104
  145. package/dist/assets/index-BiNSTKTU.css +0 -1
  146. package/dist/assets/loader-circle-MBgNSVIH.js +0 -1
  147. package/dist/assets/mcp-marketplace-page-rZkmWPMy.js +0 -1
  148. package/dist/assets/model-config-page-Di0ySJD2.js +0 -1
  149. package/dist/assets/plus-vDqW5Kxp.js +0 -1
  150. package/dist/assets/providers-config-page-BAf2W0MV.js +0 -1
  151. package/dist/assets/remote-CLQ0IBM8.js +0 -1
  152. package/dist/assets/rotate-cw-CvNBv7_w.js +0 -1
  153. package/dist/assets/runtime-config-page-CflvvoDc.js +0 -1
  154. package/dist/assets/search-config-page-D0klFUXd.js +0 -1
  155. package/dist/assets/secrets-config-page-dVZ631Wp.js +0 -3
  156. package/dist/assets/security-config-C5gnlkuj.js +0 -1
  157. package/dist/assets/x-BF2KQ--V.js +0 -1
  158. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-C_iP1zl_.js} +0 -0
@@ -13,6 +13,7 @@ export type ChatSidebarProjectGroup = {
13
13
  projectName: string;
14
14
  items: NcpSessionListItemView[];
15
15
  latestUpdatedAt: number;
16
+ isPinned: boolean;
16
17
  };
17
18
 
18
19
  export function getSessionActivityAtTimestamp(
@@ -33,8 +34,19 @@ export function sortSessionItemsByActivityAtDesc(
33
34
  );
34
35
  }
35
36
 
37
+ function sortSessionItemsByPinnedActivityAtDesc(
38
+ items: NcpSessionListItemView[],
39
+ pinnedSessionKeys: ReadonlySet<string>,
40
+ ): NcpSessionListItemView[] {
41
+ return [...items].sort((left, right) => {
42
+ const pinnedDifference = Number(pinnedSessionKeys.has(right.session.key)) - Number(pinnedSessionKeys.has(left.session.key));
43
+ return pinnedDifference || getSessionActivityAtTimestamp(right) - getSessionActivityAtTimestamp(left);
44
+ });
45
+ }
46
+
36
47
  export function groupSessionsByDate(
37
48
  items: NcpSessionListItemView[],
49
+ pinnedSessionKeys: ReadonlySet<string> = new Set(),
38
50
  ): ChatSidebarDateGroup[] {
39
51
  const now = new Date();
40
52
  const todayStart = new Date(
@@ -50,7 +62,13 @@ export function groupSessionsByDate(
50
62
  const previous7: NcpSessionListItemView[] = [];
51
63
  const older: NcpSessionListItemView[] = [];
52
64
 
53
- for (const item of items) {
65
+ const unpinnedItems = items.filter((item) => !pinnedSessionKeys.has(item.session.key));
66
+ const pinnedItems = sortSessionItemsByPinnedActivityAtDesc(
67
+ items.filter((item) => pinnedSessionKeys.has(item.session.key)),
68
+ pinnedSessionKeys,
69
+ );
70
+
71
+ for (const item of unpinnedItems) {
54
72
  const ts = getSessionActivityAtTimestamp(item);
55
73
  if (ts >= todayStart) {
56
74
  today.push(item);
@@ -64,6 +82,7 @@ export function groupSessionsByDate(
64
82
  }
65
83
 
66
84
  const groups: ChatSidebarDateGroup[] = [];
85
+ if (pinnedItems.length > 0) groups.push({ label: t("chatSidebarPinned"), items: pinnedItems });
67
86
  if (today.length > 0) groups.push({ label: t("chatSidebarToday"), items: today });
68
87
  if (yesterday.length > 0)
69
88
  groups.push({ label: t("chatSidebarYesterday"), items: yesterday });
@@ -75,6 +94,8 @@ export function groupSessionsByDate(
75
94
 
76
95
  export function groupSessionsByProject(
77
96
  items: NcpSessionListItemView[],
97
+ pinnedSessionKeys: ReadonlySet<string> = new Set(),
98
+ pinnedProjectRoots: ReadonlySet<string> = new Set(),
78
99
  ): ChatSidebarProjectGroup[] {
79
100
  const grouped = new Map<string, ChatSidebarProjectGroup>();
80
101
 
@@ -101,15 +122,16 @@ export function groupSessionsByProject(
101
122
  projectRoot,
102
123
  items: [item],
103
124
  latestUpdatedAt: updatedAt,
125
+ isPinned: pinnedProjectRoots.has(projectRoot),
104
126
  });
105
127
  }
106
128
 
107
129
  return [...grouped.values()]
108
130
  .map((group) => ({
109
131
  ...group,
110
- items: sortSessionItemsByActivityAtDesc(group.items),
132
+ items: sortSessionItemsByPinnedActivityAtDesc(group.items, pinnedSessionKeys),
111
133
  }))
112
- .sort((left, right) => right.latestUpdatedAt - left.latestUpdatedAt);
134
+ .sort((left, right) => Number(right.isPinned) - Number(left.isPinned) || right.latestUpdatedAt - left.latestUpdatedAt);
113
135
  }
114
136
 
115
137
  export function groupChildSessionsByParentKey(
@@ -1,4 +1,5 @@
1
1
  import { AlarmClock, BrainCircuit, MessageCircle } from 'lucide-react';
2
+ import { cn } from '@/shared/lib/utils';
2
3
  import { t } from '@/shared/lib/i18n';
3
4
 
4
5
  const WELCOME_CAPABILITIES = [
@@ -34,13 +35,17 @@ export function ChatWelcomeCapabilityGrid({
34
35
  return (
35
36
  <button
36
37
  key={capability.titleKey}
38
+ type="button"
37
39
  onClick={() => onSelectPrompt(t(capability.promptKey))}
38
- className="min-w-0 rounded-2xl border border-border bg-card p-3 text-left text-card-foreground shadow-card transition-[border-color,box-shadow,transform] hover:border-primary/30 hover:shadow-card-hover sm:p-4"
40
+ className={cn(
41
+ 'min-w-0 rounded-xl border border-border/75 bg-card p-3 text-left text-card-foreground shadow-none transition-colors sm:p-3.5',
42
+ 'hover:bg-muted/40',
43
+ )}
39
44
  >
40
- <div className="mb-3 flex h-9 w-9 items-center justify-center rounded-xl bg-accent">
41
- <Icon className="h-4.5 w-4.5 text-primary" />
45
+ <div className="mb-2.5 flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
46
+ <Icon className="h-4 w-4" />
42
47
  </div>
43
- <div className="mb-1 text-sm font-semibold text-foreground">
48
+ <div className="mb-1 text-sm font-medium text-foreground">
44
49
  {t(capability.titleKey)}
45
50
  </div>
46
51
  <div className="text-[11px] leading-relaxed text-muted-foreground">
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { useState } from 'react';
3
- import { Bot } from 'lucide-react';
4
3
  import { ChatSessionProjectDialog } from '@/features/chat/features/session/components/session-header/chat-session-project-dialog';
5
4
  import { ChatWelcomeAgentPicker } from '@/features/chat/features/welcome/components/chat-welcome-agent-picker';
6
5
  import { ChatWelcomeCapabilityGrid } from '@/features/chat/features/welcome/components/chat-welcome-capability-grid';
@@ -64,10 +63,6 @@ export function ChatWelcome({
64
63
  return (
65
64
  <div className="flex min-h-full items-center justify-center px-4 py-8 sm:p-8">
66
65
  <div className="min-w-0 w-full max-w-[min(760px,100%)]">
67
- <div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-border bg-accent shadow-card">
68
- <Bot className="h-8 w-8 text-primary" />
69
- </div>
70
-
71
66
  <div className="text-center">
72
67
  <h2 className="text-2xl font-semibold text-foreground sm:text-3xl">
73
68
  {t('chatWelcomeTitle')}
@@ -14,6 +14,7 @@ const serverPathBrowseMock = vi.fn();
14
14
  type RenderWorkspaceFilePreviewOptions = {
15
15
  file?: Partial<ChatWorkspaceFileTab>;
16
16
  onFileOpen?: (action: ChatFileOpenActionViewModel) => void;
17
+ refreshVersion?: number;
17
18
  sessionProjectRoot?: string | null;
18
19
  sessionWorkingDir?: string | null;
19
20
  };
@@ -82,6 +83,7 @@ function mockTextRead(overrides: TextReadDataOverrides = {}) {
82
83
  function renderWorkspaceFilePreview({
83
84
  file,
84
85
  onFileOpen = vi.fn(),
86
+ refreshVersion,
85
87
  sessionProjectRoot = "/tmp",
86
88
  sessionWorkingDir = "/tmp",
87
89
  }: RenderWorkspaceFilePreviewOptions = {}) {
@@ -90,6 +92,7 @@ function renderWorkspaceFilePreview({
90
92
  file={buildWorkspaceFile(file ?? {})}
91
93
  sessionProjectRoot={sessionProjectRoot}
92
94
  sessionWorkingDir={sessionWorkingDir}
95
+ refreshVersion={refreshVersion}
93
96
  onFileOpen={onFileOpen}
94
97
  />,
95
98
  );
@@ -97,55 +100,68 @@ function renderWorkspaceFilePreview({
97
100
  }
98
101
 
99
102
  function mockWorkspaceBreadcrumbBrowseTree() {
100
- serverPathBrowseMock.mockImplementation(
101
- ({ path }: { path?: string | null }) => ({
102
- isLoading: false,
103
- error: null,
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
- ],
103
+ serverPathBrowseMock.mockImplementation(({ path }: { path?: string | null }) => {
104
+ if (path === "/tmp/workspace/src/components") {
105
+ return {
106
+ isLoading: false,
107
+ error: null,
108
+ data: {
109
+ currentPath: "/tmp/workspace/src/components",
110
+ parentPath: "/tmp/workspace/src",
111
+ homePath: "/Users/demo",
112
+ breadcrumbs: [
113
+ { label: "workspace", path: "/tmp/workspace" },
114
+ { label: "src", path: "/tmp/workspace/src" },
115
+ { label: "components", path: "/tmp/workspace/src/components" },
116
+ ],
117
+ entries: [
118
+ {
119
+ name: "button.tsx",
120
+ path: "/tmp/workspace/src/components/button.tsx",
121
+ kind: "file",
122
+ hidden: false,
146
123
  },
147
- }),
148
- );
124
+ ],
125
+ },
126
+ };
127
+ }
128
+
129
+ if (path === "/tmp/workspace/src") {
130
+ return {
131
+ isLoading: false,
132
+ error: null,
133
+ data: {
134
+ currentPath: "/tmp/workspace/src",
135
+ parentPath: "/tmp/workspace",
136
+ homePath: "/Users/demo",
137
+ breadcrumbs: [
138
+ { label: "workspace", path: "/tmp/workspace" },
139
+ { label: "src", path: "/tmp/workspace/src" },
140
+ ],
141
+ entries: [
142
+ {
143
+ name: "components",
144
+ path: "/tmp/workspace/src/components",
145
+ kind: "directory",
146
+ hidden: false,
147
+ },
148
+ {
149
+ name: "index.ts",
150
+ path: "/tmp/workspace/src/index.ts",
151
+ kind: "file",
152
+ hidden: false,
153
+ },
154
+ ],
155
+ },
156
+ };
157
+ }
158
+
159
+ return {
160
+ isLoading: false,
161
+ error: new Error("server path must point to a directory"),
162
+ data: null,
163
+ };
164
+ });
149
165
  }
150
166
 
151
167
  beforeEach(() => {
@@ -153,14 +169,8 @@ beforeEach(() => {
153
169
  serverPathBrowseMock.mockReset();
154
170
  serverPathBrowseMock.mockReturnValue({
155
171
  isLoading: false,
156
- error: null,
157
- data: {
158
- currentPath: "/tmp",
159
- parentPath: null,
160
- homePath: "/Users/demo",
161
- breadcrumbs: [],
162
- entries: [],
163
- },
172
+ error: new Error("server path must point to a directory"),
173
+ data: null,
164
174
  });
165
175
  });
166
176
 
@@ -229,6 +239,26 @@ describe("ChatSessionWorkspaceFilePreview rendering", () => {
229
239
  expect(screen.queryByTestId("file-code-surface")).toBeNull();
230
240
  });
231
241
 
242
+ it("adds the refresh version to rendered HTML iframe URLs", () => {
243
+ mockTextRead({
244
+ resolvedPath: "/tmp/example.html",
245
+ text: "<!doctype html><h1>Hello</h1>",
246
+ });
247
+ renderWorkspaceFilePreview({
248
+ file: {
249
+ label: "example.html",
250
+ path: "/tmp/example.html",
251
+ previewViewer: "rendered",
252
+ viewMode: "preview",
253
+ },
254
+ refreshVersion: 3,
255
+ });
256
+
257
+ expect(screen.getByTestId("workspace-html-preview").getAttribute("src")).toContain(
258
+ "refresh=3",
259
+ );
260
+ });
261
+
232
262
  it("keeps HTML files in the source preview when source is requested", () => {
233
263
  mockTextRead({
234
264
  resolvedPath: "/tmp/example.html",
@@ -291,6 +321,79 @@ describe("ChatSessionWorkspaceFilePreview rendering", () => {
291
321
  expect(screen.getByText("tmp")).toBeTruthy();
292
322
  expect(screen.getByText("example.ts")).toBeTruthy();
293
323
  });
324
+
325
+ it("renders directory entries and opens child directories or files", () => {
326
+ serverPathReadMock.mockReturnValue({
327
+ isLoading: false,
328
+ error: new Error("server path must point to a file"),
329
+ data: null,
330
+ });
331
+ serverPathBrowseMock.mockReturnValue({
332
+ isLoading: false,
333
+ error: null,
334
+ data: {
335
+ currentPath: "/tmp/workspace/src",
336
+ parentPath: "/tmp/workspace",
337
+ homePath: "/Users/demo",
338
+ breadcrumbs: [
339
+ { label: "workspace", path: "/tmp/workspace" },
340
+ { label: "src", path: "/tmp/workspace/src" },
341
+ ],
342
+ entries: [
343
+ {
344
+ name: "components",
345
+ path: "/tmp/workspace/src/components",
346
+ kind: "directory",
347
+ hidden: false,
348
+ },
349
+ {
350
+ name: "index.ts",
351
+ path: "/tmp/workspace/src/index.ts",
352
+ kind: "file",
353
+ hidden: false,
354
+ },
355
+ ],
356
+ },
357
+ });
358
+ const onFileOpen = vi.fn();
359
+
360
+ renderWorkspaceFilePreview({
361
+ file: {
362
+ path: "src",
363
+ label: "src",
364
+ viewMode: "preview",
365
+ },
366
+ onFileOpen,
367
+ sessionProjectRoot: "/tmp/workspace",
368
+ sessionWorkingDir: "/tmp/workspace",
369
+ });
370
+
371
+ expect(serverPathBrowseMock).toHaveBeenCalledWith({
372
+ path: "src",
373
+ basePath: "/tmp/workspace",
374
+ includeFiles: true,
375
+ enabled: true,
376
+ });
377
+ expect(screen.getByTestId("workspace-directory-browser")).toBeTruthy();
378
+
379
+ fireEvent.click(
380
+ screen.getByRole("button", { name: "Open directory: components" }),
381
+ );
382
+ expect(onFileOpen).toHaveBeenCalledWith({
383
+ path: "/tmp/workspace/src/components",
384
+ label: "components",
385
+ viewMode: "preview",
386
+ });
387
+
388
+ fireEvent.click(
389
+ screen.getByRole("button", { name: "Open file: index.ts" }),
390
+ );
391
+ expect(onFileOpen).toHaveBeenCalledWith({
392
+ path: "/tmp/workspace/src/index.ts",
393
+ label: "index.ts",
394
+ viewMode: "preview",
395
+ });
396
+ });
294
397
  });
295
398
 
296
399
  describe("ChatSessionWorkspaceFilePreview breadcrumbs", () => {
@@ -40,6 +40,7 @@ it("shows compact child session metadata without repeating the tab title", () =>
40
40
  kind: "child-session",
41
41
  tab: createChildTab(),
42
42
  }}
43
+ filePreviewRefreshVersion={0}
43
44
  sessionCronJobs={[]}
44
45
  sessionProjectRoot={null}
45
46
  sessionWorkingDir={null}
@@ -0,0 +1,144 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ChatSessionWorkspacePanel } from "@/features/chat/features/workspace/components/chat-session-workspace-panel";
5
+ import type { ChatWorkspaceFileTab } from "@/features/chat/stores/chat-thread.store";
6
+ import type * as ReactQuery from "@tanstack/react-query";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ syncVisibleWorkspaceSelection: vi.fn(),
10
+ selectChildSessionDetail: vi.fn(),
11
+ selectWorkspaceFile: vi.fn(),
12
+ closeWorkspaceFile: vi.fn(),
13
+ openSessionCronPanel: vi.fn(),
14
+ goBackWorkspacePanel: vi.fn(),
15
+ goForwardWorkspacePanel: vi.fn(),
16
+ closeWorkspacePanel: vi.fn(),
17
+ invalidateQueries: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("@tanstack/react-query", async (importOriginal) => ({
21
+ ...(await importOriginal<typeof ReactQuery>()),
22
+ useQueryClient: () => ({
23
+ invalidateQueries: mocks.invalidateQueries,
24
+ }),
25
+ }));
26
+
27
+ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
28
+ usePresenter: () => ({
29
+ chatThreadManager: mocks,
30
+ }),
31
+ }));
32
+
33
+ vi.mock("@/features/chat/features/ncp/hooks/use-ncp-child-session-tabs-view", () => ({
34
+ useNcpChildSessionTabsView: () => [],
35
+ }));
36
+
37
+ vi.mock("@/features/chat/features/workspace/components/chat-session-workspace-panel-content", () => ({
38
+ ChatSessionWorkspacePanelContent: ({
39
+ activeSelection,
40
+ filePreviewRefreshVersion,
41
+ }: {
42
+ activeSelection: { kind: string };
43
+ filePreviewRefreshVersion: number;
44
+ }) => (
45
+ <div
46
+ data-file-refresh-version={filePreviewRefreshVersion}
47
+ data-testid="workspace-panel-content"
48
+ >
49
+ {activeSelection.kind}
50
+ </div>
51
+ ),
52
+ }));
53
+
54
+ function createWorkspaceFileTab(): ChatWorkspaceFileTab {
55
+ return {
56
+ key: "file-tab",
57
+ parentSessionKey: "session-1",
58
+ path: "README.md",
59
+ viewMode: "preview",
60
+ };
61
+ }
62
+
63
+ function renderPanel(displayMode: "docked" | "overlay" = "docked") {
64
+ const fileTab = createWorkspaceFileTab();
65
+
66
+ return render(
67
+ <ChatSessionWorkspacePanel
68
+ sessionKey="session-1"
69
+ childSessionTabs={[]}
70
+ activeChildSessionKey={null}
71
+ activeSideChatDraft={null}
72
+ workspaceFileTabs={[fileTab]}
73
+ activeWorkspaceFileKey={fileTab.key}
74
+ workspaceNavigationHistory={[{ kind: "file", key: fileTab.key }]}
75
+ workspaceNavigationHistoryIndex={0}
76
+ activePanelKind="file"
77
+ sessionCronJobs={[]}
78
+ sessionProjectRoot={null}
79
+ sessionWorkingDir={null}
80
+ displayMode={displayMode}
81
+ />,
82
+ );
83
+ }
84
+
85
+ describe("ChatSessionWorkspacePanel", () => {
86
+ beforeEach(() => {
87
+ vi.clearAllMocks();
88
+ });
89
+
90
+ it("refreshes the active workspace file from the top action bar", async () => {
91
+ const user = userEvent.setup();
92
+ renderPanel();
93
+
94
+ await user.click(screen.getByRole("button", { name: "Refresh preview" }));
95
+
96
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
97
+ queryKey: ["server-path-read", "README.md", null],
98
+ });
99
+ expect(mocks.invalidateQueries).toHaveBeenCalledWith({
100
+ queryKey: ["server-path-browse", "README.md", "", true],
101
+ });
102
+ expect(
103
+ screen
104
+ .getByTestId("workspace-panel-content")
105
+ .getAttribute("data-file-refresh-version"),
106
+ ).toBe("1");
107
+ });
108
+
109
+ it("maximizes and restores the docked workspace panel within its container", async () => {
110
+ const user = userEvent.setup();
111
+ renderPanel();
112
+
113
+ const panel = screen.getByTestId("chat-session-workspace-panel");
114
+ expect(panel.className).not.toContain("absolute");
115
+
116
+ await user.click(
117
+ screen.getByRole("button", { name: "Maximize workspace panel" }),
118
+ );
119
+
120
+ expect(panel.className).toContain("absolute");
121
+ expect(panel.className).not.toContain("fixed");
122
+ expect(
123
+ screen.queryByTestId("resizable-right-panel-handle"),
124
+ ).toBeNull();
125
+
126
+ await user.click(
127
+ screen.getByRole("button", { name: "Restore workspace panel" }),
128
+ );
129
+
130
+ expect(panel.className).not.toContain("absolute");
131
+ expect(screen.getByTestId("resizable-right-panel-handle")).toBeTruthy();
132
+ });
133
+
134
+ it("does not show a redundant maximize action in viewport overlay mode", () => {
135
+ renderPanel("overlay");
136
+
137
+ expect(
138
+ screen.queryByRole("button", { name: "Maximize workspace panel" }),
139
+ ).toBeNull();
140
+ expect(screen.getByTestId("chat-session-workspace-panel").className).toContain(
141
+ "fixed",
142
+ );
143
+ });
144
+ });
@@ -0,0 +1,99 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ChatFileOpenActionViewModel } from "@nextclaw/agent-chat-ui";
3
+ import { ChevronRight, FileCode2, Folder } from "lucide-react";
4
+ import type { useServerPathBrowse } from "@/shared/hooks/use-server-path-browse";
5
+ import type { ServerPathEntryView } from "@/shared/lib/api";
6
+ import { t } from "@/shared/lib/i18n";
7
+ import { cn } from "@/shared/lib/utils";
8
+
9
+ type ChatSessionWorkspaceDirectoryBrowserProps = {
10
+ browseQuery: ReturnType<typeof useServerPathBrowse>;
11
+ onFileOpen: (action: ChatFileOpenActionViewModel) => void;
12
+ renderStatus: (params: {
13
+ text: string;
14
+ tone?: "muted" | "error";
15
+ }) => ReactNode;
16
+ };
17
+
18
+ function WorkspaceDirectoryEntryIcon({
19
+ kind,
20
+ }: {
21
+ kind: ServerPathEntryView["kind"];
22
+ }) {
23
+ return kind === "directory" ? (
24
+ <Folder className="h-4 w-4 shrink-0 text-emerald-600" />
25
+ ) : (
26
+ <FileCode2 className="h-4 w-4 shrink-0 text-sky-600" />
27
+ );
28
+ }
29
+
30
+ function buildDirectoryEntryLabel(entry: ServerPathEntryView): string {
31
+ const actionLabel =
32
+ entry.kind === "directory"
33
+ ? t("chatWorkspaceOpenDirectory")
34
+ : t("chatWorkspaceOpenFile");
35
+ return `${actionLabel}: ${entry.name}`;
36
+ }
37
+
38
+ export function ChatSessionWorkspaceDirectoryBrowser({
39
+ browseQuery,
40
+ onFileOpen,
41
+ renderStatus,
42
+ }: ChatSessionWorkspaceDirectoryBrowserProps) {
43
+ const entries = browseQuery.data?.entries ?? [];
44
+ const errorMessage = browseQuery.error
45
+ ? browseQuery.error instanceof Error
46
+ ? browseQuery.error.message
47
+ : String(browseQuery.error)
48
+ : null;
49
+ const openEntry = (entry: ServerPathEntryView) => {
50
+ onFileOpen({
51
+ path: entry.path,
52
+ label: entry.name,
53
+ viewMode: "preview",
54
+ });
55
+ };
56
+
57
+ if (browseQuery.isLoading && !browseQuery.data) {
58
+ return renderStatus({ text: t("chatWorkspaceLoadingDirectory") });
59
+ }
60
+
61
+ if (errorMessage && !browseQuery.data) {
62
+ return renderStatus({
63
+ tone: "error",
64
+ text: `${t("chatWorkspaceDirectoryLoadFailed")}: ${errorMessage}`,
65
+ });
66
+ }
67
+
68
+ if (entries.length === 0) {
69
+ return renderStatus({ text: t("chatWorkspaceDirectoryEmpty") });
70
+ }
71
+
72
+ return (
73
+ <div
74
+ data-testid="workspace-directory-browser"
75
+ className="h-full overflow-auto bg-white px-2 py-2 custom-scrollbar"
76
+ >
77
+ <div className="space-y-0.5">
78
+ {entries.map((entry) => (
79
+ <button
80
+ key={entry.path}
81
+ type="button"
82
+ aria-label={buildDirectoryEntryLabel(entry)}
83
+ className={cn(
84
+ "flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
85
+ entry.hidden ? "opacity-65" : null,
86
+ )}
87
+ onClick={() => openEntry(entry)}
88
+ >
89
+ <WorkspaceDirectoryEntryIcon kind={entry.kind} />
90
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
91
+ {entry.kind === "directory" ? (
92
+ <ChevronRight className="h-3.5 w-3.5 shrink-0 text-gray-300" />
93
+ ) : null}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -1,6 +1,6 @@
1
1
  import { Fragment, useState } from "react";
2
2
  import type { ChatFileOpenActionViewModel } from "@nextclaw/agent-chat-ui";
3
- import { ChevronRight, FileCode2, FolderTree } from "lucide-react";
3
+ import { ChevronRight, FileCode2, Folder, FolderTree } from "lucide-react";
4
4
  import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover";
5
5
  import { WorkspaceBreadcrumbBrowser } from "./chat-session-workspace-file-breadcrumb-browser";
6
6
  import type {
@@ -59,6 +59,8 @@ function WorkspaceBreadcrumbSegmentButton({
59
59
  >
60
60
  {segment.kind === "workspace" ? (
61
61
  <FolderTree className="h-3 w-3 shrink-0" />
62
+ ) : segment.kind === "directory" && segment.isCurrent ? (
63
+ <Folder className="h-3 w-3 shrink-0" />
62
64
  ) : segment.isCurrent ? (
63
65
  <FileCode2 className="h-3 w-3 shrink-0" />
64
66
  ) : null}