@nextclaw/ui 0.11.21 → 0.11.23

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 (129) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/{ChannelsList-ByHWHkQS.js → ChannelsList-DVDu1xvz.js} +6 -6
  3. package/dist/assets/ChatPage-Z9tRzm_n.js +43 -0
  4. package/dist/assets/DocBrowser-B9OaZjmg.js +1 -0
  5. package/dist/assets/{DocBrowser-3y_NHZ71.js → DocBrowser-BmtBLFU0.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-CVJuwCcw.js → DocBrowserContext-YIKkPb76.js} +1 -1
  7. package/dist/assets/{LogoBadge-D8fyilO-.js → LogoBadge-F7ZWdxLT.js} +1 -1
  8. package/dist/assets/{MarketplacePage-CmhsZXr1.js → MarketplacePage-Buo9HrOz.js} +2 -2
  9. package/dist/assets/MarketplacePage-D6rVQEQR.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-C7PkCYbp.js → McpMarketplacePage-JnkYwK7p.js} +2 -2
  11. package/dist/assets/ModelConfig-BYRhgp0c.js +1 -0
  12. package/dist/assets/ProvidersList-DmLyyHvX.js +1 -0
  13. package/dist/assets/RemoteAccessPage-CDSSvH7Z.js +1 -0
  14. package/dist/assets/RuntimeConfig-v7a7Fe3x.js +1 -0
  15. package/dist/assets/{SearchConfig-Dm7r2yfp.js → SearchConfig-D5f1EkLE.js} +1 -1
  16. package/dist/assets/{SecretsConfig-BBP_mbQh.js → SecretsConfig-D61IKcYt.js} +2 -2
  17. package/dist/assets/{SessionsConfig-6wNJloZN.js → SessionsConfig-BRIxVTEv.js} +2 -2
  18. package/dist/assets/{book-open-B26jGBjY.js → book-open-CXoF5nQC.js} +1 -1
  19. package/dist/assets/chat-session-display-D0WpnuRZ.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-B-4B29RN.js → chunk-JZWAC4HX-CvRWvTy5.js} +1 -1
  21. package/dist/assets/{config-BaC29Qf-.js → config-DJswxxE8.js} +1 -1
  22. package/dist/assets/{createLucideIcon-DiFAvXmK.js → createLucideIcon-CjGHOWb6.js} +1 -1
  23. package/dist/assets/{dist-pCfWPG1A.js → dist-Cl2QB-2y.js} +1 -1
  24. package/dist/assets/{dist-kW_O3kyZ.js → dist-nqTTbVdA.js} +1 -1
  25. package/dist/assets/{external-link-D5-p-Gmm.js → external-link-tIO7zING.js} +1 -1
  26. package/dist/assets/{hash-BlwrSV0q.js → hash-JWUyl1pT.js} +1 -1
  27. package/dist/assets/i18n-CDHMXlRZ.js +1 -0
  28. package/dist/assets/{index-DvKS3L9j.js → index-BuwbBgmT.js} +3 -3
  29. package/dist/assets/index-bZ8cqQIS.css +1 -0
  30. package/dist/assets/{label-RyXfZqkP.js → label-BIpeNu4r.js} +1 -1
  31. package/dist/assets/loader-circle-Cs8XVFTw.js +1 -0
  32. package/dist/assets/{logos-Bpl8QTgI.js → logos-DThdM9lk.js} +1 -1
  33. package/dist/assets/{page-layout--S0YBU0W.js → page-layout-D3Xo605Z.js} +1 -1
  34. package/dist/assets/plus-PHf8q-Ct.js +1 -0
  35. package/dist/assets/{popover-BEjfbEwy.js → popover-BJRUGA_H.js} +1 -1
  36. package/dist/assets/provider-models-bz5y28rq.js +1 -0
  37. package/dist/assets/{react-BuSP2-8B.js → react-7ZHqQtEV.js} +1 -1
  38. package/dist/assets/refresh-ccw-CC6-_QuL.js +1 -0
  39. package/dist/assets/{save-DPPPpD_c.js → save-DJM5RRWW.js} +1 -1
  40. package/dist/assets/search-C91yH_6y.js +1 -0
  41. package/dist/assets/{security-config-6t78Ph-I.js → security-config-DbUyWcQz.js} +1 -1
  42. package/dist/assets/{select-CT50pzod.js → select-DSkTc61S.js} +1 -1
  43. package/dist/assets/skeleton-Dzg-HOiN.js +1 -0
  44. package/dist/assets/{status-dot-BbBqRHfh.js → status-dot-LNBlDu3q.js} +1 -1
  45. package/dist/assets/{switch-D3l6AcCk.js → switch-Bo-Y46HZ.js} +1 -1
  46. package/dist/assets/tabs-custom-DXv507_2.js +1 -0
  47. package/dist/assets/{trash-2-B2_AGVE3.js → trash-2-DFZmW6Gg.js} +1 -1
  48. package/dist/assets/useConfirmDialog-COwYXDKm.js +1 -0
  49. package/dist/assets/{useMutation-BzCrO8j-.js → useMutation-DrZrOgVL.js} +1 -1
  50. package/dist/assets/x-D7Q1yqSF.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +6 -6
  53. package/src/api/ncp-session.test.ts +37 -0
  54. package/src/api/ncp-session.ts +29 -1
  55. package/src/api/server-path.ts +23 -0
  56. package/src/api/types.ts +45 -0
  57. package/src/components/chat/ChatConversationPanel.test.tsx +53 -9
  58. package/src/components/chat/ChatConversationPanel.tsx +122 -79
  59. package/src/components/chat/ChatSidebar.test.tsx +2 -2
  60. package/src/components/chat/ChatSidebar.tsx +2 -2
  61. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +1 -0
  62. package/src/components/chat/adapters/chat-input-bar.adapter.ts +7 -2
  63. package/src/components/chat/adapters/chat-message-part.adapter.ts +26 -14
  64. package/src/components/chat/adapters/chat-message.adapter.test.ts +159 -13
  65. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +191 -0
  66. package/src/components/chat/adapters/{chat-message.file-operation-card.ts → file-operation/card.ts} +74 -181
  67. package/src/components/chat/adapters/{chat-message.file-operation-diff.ts → file-operation/diff.ts} +178 -188
  68. package/src/components/chat/adapters/file-operation/line-builder.ts +249 -0
  69. package/src/components/chat/adapters/file-operation/record-readers.ts +233 -0
  70. package/src/components/chat/chat-child-session-panel.tsx +100 -0
  71. package/src/components/chat/chat-composer-state.ts +3 -3
  72. package/src/components/chat/chat-page-runtime.test.ts +1 -0
  73. package/src/components/chat/chat-session-display.test.ts +22 -0
  74. package/src/components/chat/chat-session-display.ts +6 -1
  75. package/src/components/chat/containers/chat-input-bar.container.tsx +21 -24
  76. package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
  77. package/src/components/chat/hooks/use-chat-session-label.ts +19 -0
  78. package/src/components/chat/hooks/use-chat-session-project.test.tsx +117 -0
  79. package/src/components/chat/hooks/use-chat-session-project.ts +40 -0
  80. package/src/components/chat/{chat-session-label.service.ts → hooks/use-chat-session-update.ts} +11 -7
  81. package/src/components/chat/managers/chat-session-list.manager.ts +5 -1
  82. package/src/components/chat/ncp/NcpChatPage.tsx +219 -116
  83. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +33 -0
  84. package/src/components/chat/ncp/ncp-chat-page-data.ts +21 -15
  85. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +49 -0
  86. package/src/components/chat/ncp/ncp-session-adapter.test.ts +24 -0
  87. package/src/components/chat/ncp/ncp-session-adapter.ts +47 -0
  88. package/src/components/chat/ncp/use-ncp-session-list-view.ts +10 -1
  89. package/src/components/chat/presenter/chat-presenter-context.tsx +4 -1
  90. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +63 -0
  91. package/src/components/chat/session-header/chat-session-header-actions.tsx +95 -0
  92. package/src/components/chat/session-header/chat-session-header-menu-item.tsx +35 -0
  93. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +66 -0
  94. package/src/components/chat/session-header/chat-session-project-badge.tsx +102 -0
  95. package/src/components/chat/session-header/chat-session-project-dialog.tsx +34 -0
  96. package/src/components/chat/stores/chat-input.store.ts +6 -3
  97. package/src/components/chat/stores/chat-thread.store.ts +17 -3
  98. package/src/components/chat/useHydratedNcpAgent.test.tsx +30 -23
  99. package/src/components/path-picker/server-path-picker-dialog.test.tsx +92 -0
  100. package/src/components/path-picker/server-path-picker-dialog.tsx +282 -0
  101. package/src/hooks/server-path/use-server-path-browse.ts +19 -0
  102. package/src/hooks/useConfig.ts +26 -1
  103. package/src/lib/i18n/i18n-language-owner.ts +94 -0
  104. package/src/lib/i18n/i18n.path-picker.ts +12 -0
  105. package/src/lib/i18n.chat.ts +23 -0
  106. package/src/lib/i18n.ts +21 -84
  107. package/src/lib/session-project/session-project.utils.ts +30 -0
  108. package/dist/assets/ChatPage-FdT3pDnw.js +0 -42
  109. package/dist/assets/DocBrowser-CMdPdbZj.js +0 -1
  110. package/dist/assets/MarketplacePage-9oKmxN2n.js +0 -1
  111. package/dist/assets/ModelConfig-DmCY6jWM.js +0 -1
  112. package/dist/assets/ProvidersList-ClT-34aX.js +0 -1
  113. package/dist/assets/RemoteAccessPage-B6hUZl1O.js +0 -1
  114. package/dist/assets/RuntimeConfig-C5aqliGk.js +0 -1
  115. package/dist/assets/chat-session-display-Bjmn4aIZ.js +0 -1
  116. package/dist/assets/i18n-CSytxMFI.js +0 -1
  117. package/dist/assets/index-CUy6doWo.css +0 -1
  118. package/dist/assets/loader-circle-B2J777gj.js +0 -1
  119. package/dist/assets/plus-CM9XJ0Tf.js +0 -1
  120. package/dist/assets/provider-models-C8JQUd1E.js +0 -1
  121. package/dist/assets/search-Ctaw34Kp.js +0 -1
  122. package/dist/assets/skeleton-Bycyb0zU.js +0 -1
  123. package/dist/assets/tabs-custom-TZQ5WPWP.js +0 -1
  124. package/dist/assets/useConfirmDialog-BDpdjfIO.js +0 -1
  125. package/dist/assets/x-CHOBE-63.js +0 -1
  126. package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +0 -154
  127. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-WtpHP_DW.js} +0 -0
  128. /package/dist/assets/{config-layout-B-7erZRN.js → config-layout-LQ10ozRC.js} +0 -0
  129. /package/dist/assets/{marketplace-localization-CXeGRf6E.js → marketplace-localization-CxSTG9wr.js} +0 -0
@@ -9,8 +9,10 @@ export type ChatThreadSnapshot = {
9
9
  sessionTypeUnavailable: boolean;
10
10
  sessionTypeUnavailableMessage?: string | null;
11
11
  sessionTypeLabel?: string | null;
12
- selectedSessionKey: string | null;
12
+ sessionKey: string | null;
13
13
  sessionDisplayName?: string;
14
+ sessionProjectRoot?: string | null;
15
+ sessionProjectName?: string | null;
14
16
  canDeleteSession: boolean;
15
17
  isDeletePending: boolean;
16
18
  threadRef: MutableRefObject<HTMLDivElement | null> | null;
@@ -18,6 +20,11 @@ export type ChatThreadSnapshot = {
18
20
  messages: readonly NcpMessage[];
19
21
  isSending: boolean;
20
22
  isAwaitingAssistantOutput: boolean;
23
+ parentSessionKey?: string | null;
24
+ parentSessionLabel?: string | null;
25
+ childSessionDetailSessionKey?: string | null;
26
+ childSessionDetailParentSessionKey?: string | null;
27
+ childSessionDetailLabel?: string | null;
21
28
  };
22
29
 
23
30
  type ChatThreadStore = {
@@ -31,15 +38,22 @@ const initialSnapshot: ChatThreadSnapshot = {
31
38
  sessionTypeUnavailable: false,
32
39
  sessionTypeUnavailableMessage: null,
33
40
  sessionTypeLabel: null,
34
- selectedSessionKey: null,
41
+ sessionKey: null,
35
42
  sessionDisplayName: undefined,
43
+ sessionProjectRoot: null,
44
+ sessionProjectName: null,
36
45
  canDeleteSession: false,
37
46
  isDeletePending: false,
38
47
  threadRef: null,
39
48
  isHistoryLoading: false,
40
49
  messages: [],
41
50
  isSending: false,
42
- isAwaitingAssistantOutput: false
51
+ isAwaitingAssistantOutput: false,
52
+ parentSessionKey: null,
53
+ parentSessionLabel: null,
54
+ childSessionDetailSessionKey: null,
55
+ childSessionDetailParentSessionKey: null,
56
+ childSessionDetailLabel: null,
43
57
  };
44
58
 
45
59
  export const useChatThreadStore = create<ChatThreadStore>((set) => ({
@@ -1,18 +1,19 @@
1
- import { renderHook, waitFor } from '@testing-library/react';
2
- import { beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { useHydratedNcpAgent } from '../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-hydrated-ncp-agent.ts';
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import type { NcpAgentClientEndpoint } from "@nextclaw/ncp";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { useHydratedNcpAgent } from "../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-hydrated-ncp-agent.ts";
4
5
 
5
6
  const mocks = vi.hoisted(() => ({
6
7
  manager: {
7
8
  reset: vi.fn(),
8
- hydrate: vi.fn()
9
+ hydrate: vi.fn(),
9
10
  },
10
11
  runtime: {
11
12
  snapshot: {
12
13
  messages: [],
13
14
  streamingMessage: null,
14
15
  activeRun: null,
15
- error: null
16
+ error: null,
16
17
  },
17
18
  visibleMessages: [],
18
19
  activeRunId: null,
@@ -20,16 +21,19 @@ const mocks = vi.hoisted(() => ({
20
21
  isSending: false,
21
22
  send: vi.fn(),
22
23
  abort: vi.fn(),
23
- streamRun: vi.fn()
24
- }
24
+ streamRun: vi.fn(),
25
+ },
25
26
  }));
26
27
 
27
- vi.mock('../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.js', () => ({
28
- useScopedAgentManager: () => mocks.manager,
29
- useNcpAgentRuntime: () => mocks.runtime
30
- }));
28
+ vi.mock(
29
+ "../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.js",
30
+ () => ({
31
+ useScopedAgentManager: () => mocks.manager,
32
+ useNcpAgentRuntime: () => mocks.runtime,
33
+ }),
34
+ );
31
35
 
32
- describe('useHydratedNcpAgent', () => {
36
+ describe("useHydratedNcpAgent", () => {
33
37
  beforeEach(() => {
34
38
  mocks.manager.reset.mockReset();
35
39
  mocks.manager.hydrate.mockReset();
@@ -38,40 +42,43 @@ describe('useHydratedNcpAgent', () => {
38
42
  mocks.runtime.streamRun.mockReset();
39
43
  });
40
44
 
41
- it('treats a newly selected session as hydrating immediately on rerender', async () => {
45
+ it("treats a newly selected session as hydrating immediately on rerender", async () => {
42
46
  const client = {
43
47
  stop: vi.fn().mockResolvedValue(undefined),
44
- stream: vi.fn().mockResolvedValue(undefined)
45
- } as never;
48
+ stream: vi.fn().mockResolvedValue(undefined),
49
+ } satisfies Pick<NcpAgentClientEndpoint, "stop" | "stream">;
46
50
  const loadSeed = vi
47
51
  .fn()
48
- .mockResolvedValueOnce({ messages: [], status: 'idle' })
49
- .mockResolvedValueOnce({ messages: [], status: 'idle' });
52
+ .mockResolvedValueOnce({ messages: [], status: "idle" })
53
+ .mockResolvedValueOnce({ messages: [], status: "idle" });
50
54
 
51
55
  const { result, rerender } = renderHook(
52
56
  ({ sessionId }: { sessionId: string }) =>
53
57
  useHydratedNcpAgent({
54
58
  sessionId,
55
- client,
56
- loadSeed
59
+ client: client as never,
60
+ loadSeed,
57
61
  }),
58
62
  {
59
63
  initialProps: {
60
- sessionId: 'session-a'
61
- }
62
- }
64
+ sessionId: "session-a",
65
+ },
66
+ },
63
67
  );
64
68
 
65
69
  await waitFor(() => {
66
70
  expect(result.current.isHydrating).toBe(false);
67
71
  });
72
+ expect(client.stream).toHaveBeenCalledWith({ sessionId: "session-a" });
68
73
 
69
- rerender({ sessionId: 'session-b' });
74
+ rerender({ sessionId: "session-b" });
70
75
 
71
76
  expect(result.current.isHydrating).toBe(true);
72
77
 
73
78
  await waitFor(() => {
74
79
  expect(result.current.isHydrating).toBe(false);
75
80
  });
81
+ expect(client.stream).toHaveBeenCalledWith({ sessionId: "session-b" });
82
+ expect(client.stream).toHaveBeenCalledTimes(2);
76
83
  });
77
84
  });
@@ -0,0 +1,92 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ServerPathPickerDialog } from '@/components/path-picker/server-path-picker-dialog';
4
+ import { setLanguage } from '@/lib/i18n';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ useServerPathBrowse: vi.fn(),
8
+ refetch: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@/hooks/server-path/use-server-path-browse', () => ({
12
+ useServerPathBrowse: mocks.useServerPathBrowse,
13
+ }));
14
+
15
+ describe('ServerPathPickerDialog', () => {
16
+ beforeEach(() => {
17
+ setLanguage('zh');
18
+ mocks.refetch.mockReset();
19
+ mocks.useServerPathBrowse.mockReturnValue({
20
+ data: {
21
+ currentPath: '/workspace',
22
+ homePath: '/Users/peiwang',
23
+ parentPath: '/',
24
+ breadcrumbs: [
25
+ { label: 'workspace', path: '/workspace' },
26
+ ],
27
+ entries: [
28
+ { name: 'playground', path: '/workspace/playground' },
29
+ { name: 'nextbot', path: '/workspace/nextbot' },
30
+ ],
31
+ },
32
+ isLoading: false,
33
+ error: null,
34
+ refetch: mocks.refetch,
35
+ });
36
+ });
37
+
38
+ it('filters entries inside the current directory', async () => {
39
+ const user = userEvent.setup();
40
+
41
+ render(
42
+ <ServerPathPickerDialog
43
+ open
44
+ currentPath="/workspace"
45
+ isSaving={false}
46
+ onOpenChange={() => {}}
47
+ onConfirm={() => {}}
48
+ title="选择目录"
49
+ description="选择一个目录"
50
+ pathLabel="目录"
51
+ confirmLabel="确定"
52
+ />
53
+ );
54
+
55
+ expect(screen.getByText('playground')).toBeTruthy();
56
+ expect(screen.getByText('nextbot')).toBeTruthy();
57
+
58
+ await user.type(screen.getByPlaceholderText('搜索当前目录'), 'play');
59
+
60
+ expect(screen.getByText('playground')).toBeTruthy();
61
+ expect(screen.queryByText('nextbot')).toBeNull();
62
+ });
63
+
64
+ it('shows the empty-search hint and keeps the list region scroll-contained', async () => {
65
+ const user = userEvent.setup();
66
+ render(
67
+ <ServerPathPickerDialog
68
+ open
69
+ currentPath="/workspace"
70
+ isSaving={false}
71
+ onOpenChange={() => {}}
72
+ onConfirm={() => {}}
73
+ title="选择目录"
74
+ description="选择一个目录"
75
+ pathLabel="目录"
76
+ confirmLabel="确定"
77
+ />
78
+ );
79
+
80
+ await user.type(screen.getByPlaceholderText('搜索当前目录'), 'missing');
81
+
82
+ expect(screen.getByText('当前目录下没有匹配结果。')).toBeTruthy();
83
+
84
+ const dialog = screen.getByRole('dialog');
85
+ expect(dialog.className).toContain('overflow-hidden');
86
+ expect(dialog.className).toContain('sm:h-[42rem]');
87
+
88
+ const scrollArea = screen.getByText('当前目录下没有匹配结果。').closest('.overflow-auto');
89
+ expect(scrollArea?.className).toContain('flex-1');
90
+ expect(scrollArea?.className).toContain('min-h-0');
91
+ });
92
+ });
@@ -0,0 +1,282 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { ChevronRight, Folder, FolderUp, Home, RefreshCcw, Search } from 'lucide-react';
3
+ import { useServerPathBrowse } from '@/hooks/server-path/use-server-path-browse';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@/components/ui/dialog';
13
+ import { Input } from '@/components/ui/input';
14
+ import { Label } from '@/components/ui/label';
15
+ import { ScrollArea } from '@/components/ui/scroll-area';
16
+ import { t } from '@/lib/i18n';
17
+
18
+ type ServerPathPickerDialogProps = {
19
+ open: boolean;
20
+ currentPath?: string | null;
21
+ isSaving: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ onConfirm: (path: string) => Promise<void> | void;
24
+ title: string;
25
+ description?: string;
26
+ pathLabel: string;
27
+ pathPlaceholder?: string;
28
+ confirmLabel: string;
29
+ hint?: string;
30
+ };
31
+
32
+ export function ServerPathPickerDialog({
33
+ open,
34
+ currentPath,
35
+ isSaving,
36
+ onOpenChange,
37
+ onConfirm,
38
+ title,
39
+ description,
40
+ pathLabel,
41
+ pathPlaceholder,
42
+ confirmLabel,
43
+ hint,
44
+ }: ServerPathPickerDialogProps) {
45
+ const [draftPath, setDraftPath] = useState('');
46
+ const [browsePath, setBrowsePath] = useState<string | null>(null);
47
+ const [searchText, setSearchText] = useState('');
48
+
49
+ useEffect(() => {
50
+ if (!open) {
51
+ return;
52
+ }
53
+ const nextPath = currentPath?.trim() || null;
54
+ setDraftPath(nextPath ?? '');
55
+ setBrowsePath(nextPath);
56
+ setSearchText('');
57
+ }, [currentPath, open]);
58
+
59
+ const browseQuery = useServerPathBrowse({
60
+ path: browsePath,
61
+ enabled: open,
62
+ });
63
+
64
+ useEffect(() => {
65
+ if (!open || !browseQuery.data) {
66
+ return;
67
+ }
68
+ if (draftPath.trim().length === 0) {
69
+ setDraftPath(browseQuery.data.currentPath);
70
+ }
71
+ }, [browseQuery.data, draftPath, open]);
72
+
73
+ const normalizedDraftPath = draftPath.trim();
74
+ const submitDisabled = normalizedDraftPath.length === 0 || isSaving;
75
+ const errorMessage = useMemo(() => {
76
+ if (!browseQuery.error) {
77
+ return null;
78
+ }
79
+ return browseQuery.error instanceof Error
80
+ ? browseQuery.error.message
81
+ : String(browseQuery.error);
82
+ }, [browseQuery.error]);
83
+
84
+ const normalizedSearchText = searchText.trim().toLowerCase();
85
+ const filteredEntries = useMemo(() => {
86
+ const entries = browseQuery.data?.entries ?? [];
87
+ if (normalizedSearchText.length === 0) {
88
+ return entries;
89
+ }
90
+ return entries.filter((entry) => {
91
+ const normalizedName = entry.name.toLowerCase();
92
+ const normalizedPath = entry.path.toLowerCase();
93
+ return (
94
+ normalizedName.includes(normalizedSearchText) ||
95
+ normalizedPath.includes(normalizedSearchText)
96
+ );
97
+ });
98
+ }, [browseQuery.data?.entries, normalizedSearchText]);
99
+
100
+ const navigateTo = (path: string | null) => {
101
+ setBrowsePath(path);
102
+ setSearchText('');
103
+ if (path) {
104
+ setDraftPath(path);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <Dialog
110
+ open={open}
111
+ onOpenChange={(nextOpen) => {
112
+ if (isSaving) {
113
+ return;
114
+ }
115
+ onOpenChange(nextOpen);
116
+ }}
117
+ >
118
+ <DialogContent className="max-h-[85vh] overflow-hidden sm:h-[42rem] sm:max-w-2xl sm:grid-rows-[auto_minmax(0,1fr)]">
119
+ <DialogHeader>
120
+ <DialogTitle>{title}</DialogTitle>
121
+ {description ? (
122
+ <DialogDescription>{description}</DialogDescription>
123
+ ) : null}
124
+ </DialogHeader>
125
+ <form
126
+ className="flex min-h-0 flex-col gap-4"
127
+ onSubmit={(event) => {
128
+ event.preventDefault();
129
+ if (submitDisabled) {
130
+ return;
131
+ }
132
+ void onConfirm(normalizedDraftPath);
133
+ }}
134
+ >
135
+ <div className="space-y-2">
136
+ <Label htmlFor="server-path-picker-input">{pathLabel}</Label>
137
+ <div className="flex gap-2">
138
+ <Input
139
+ id="server-path-picker-input"
140
+ value={draftPath}
141
+ onChange={(event) => setDraftPath(event.target.value)}
142
+ placeholder={pathPlaceholder}
143
+ autoFocus
144
+ disabled={isSaving}
145
+ />
146
+ <Button
147
+ type="button"
148
+ variant="outline"
149
+ onClick={() => navigateTo(normalizedDraftPath || null)}
150
+ disabled={isSaving}
151
+ >
152
+ {t('openPath')}
153
+ </Button>
154
+ </div>
155
+ </div>
156
+
157
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/70">
158
+ <div className="flex flex-wrap items-center gap-2 border-b border-gray-200 px-3 py-2">
159
+ <Button
160
+ type="button"
161
+ variant="ghost"
162
+ size="sm"
163
+ onClick={() => navigateTo(browseQuery.data?.homePath ?? null)}
164
+ disabled={isSaving || browseQuery.isLoading}
165
+ >
166
+ <Home className="mr-1 h-4 w-4" />
167
+ {t('homeDirectory')}
168
+ </Button>
169
+ <Button
170
+ type="button"
171
+ variant="ghost"
172
+ size="sm"
173
+ onClick={() => navigateTo(browseQuery.data?.parentPath ?? null)}
174
+ disabled={!browseQuery.data?.parentPath || isSaving || browseQuery.isLoading}
175
+ >
176
+ <FolderUp className="mr-1 h-4 w-4" />
177
+ {t('parentDirectory')}
178
+ </Button>
179
+ <Button
180
+ type="button"
181
+ variant="ghost"
182
+ size="sm"
183
+ onClick={() => {
184
+ void browseQuery.refetch();
185
+ }}
186
+ disabled={isSaving || browseQuery.isLoading}
187
+ >
188
+ <RefreshCcw className="mr-1 h-4 w-4" />
189
+ {t('chatRefresh')}
190
+ </Button>
191
+ </div>
192
+
193
+ <div className="border-b border-gray-200 px-3 py-2">
194
+ <div className="mb-2 text-xs font-medium text-gray-500">
195
+ {t('currentDirectory')}
196
+ </div>
197
+ <div className="flex flex-wrap items-center gap-1 text-xs text-gray-600">
198
+ {browseQuery.data?.breadcrumbs.map((breadcrumb, index) => (
199
+ <div key={breadcrumb.path} className="flex items-center gap-1">
200
+ <button
201
+ type="button"
202
+ className="rounded px-1.5 py-0.5 hover:bg-gray-200"
203
+ onClick={() => navigateTo(breadcrumb.path)}
204
+ disabled={isSaving}
205
+ >
206
+ {breadcrumb.label}
207
+ </button>
208
+ {index < browseQuery.data.breadcrumbs.length - 1 ? (
209
+ <ChevronRight className="h-3 w-3 text-gray-400" />
210
+ ) : null}
211
+ </div>
212
+ ))}
213
+ </div>
214
+ </div>
215
+
216
+ <div className="border-b border-gray-200 px-3 py-2">
217
+ <div className="relative">
218
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
219
+ <Input
220
+ value={searchText}
221
+ onChange={(event) => setSearchText(event.target.value)}
222
+ placeholder={t('pathPickerSearchPlaceholder')}
223
+ disabled={isSaving || browseQuery.isLoading}
224
+ className="pl-9"
225
+ />
226
+ </div>
227
+ </div>
228
+
229
+ <ScrollArea className="min-h-0 flex-1 px-2 py-2">
230
+ {browseQuery.isLoading ? (
231
+ <div className="px-2 py-6 text-sm text-gray-500">{t('loading')}</div>
232
+ ) : errorMessage ? (
233
+ <div className="px-2 py-4 text-sm text-destructive">
234
+ {t('pathBrowseFailed')}: {errorMessage}
235
+ </div>
236
+ ) : browseQuery.data && filteredEntries.length > 0 ? (
237
+ <div className="space-y-1">
238
+ {filteredEntries.map((entry) => (
239
+ <button
240
+ key={entry.path}
241
+ type="button"
242
+ className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm text-gray-700 transition-colors hover:bg-white"
243
+ onClick={() => navigateTo(entry.path)}
244
+ disabled={isSaving}
245
+ >
246
+ <Folder className="h-4 w-4 shrink-0 text-emerald-600" />
247
+ <span className="truncate">{entry.name}</span>
248
+ </button>
249
+ ))}
250
+ </div>
251
+ ) : browseQuery.data && browseQuery.data.entries.length > 0 ? (
252
+ <div className="px-2 py-6 text-sm text-gray-500">
253
+ {t('pathPickerSearchEmpty')}
254
+ </div>
255
+ ) : (
256
+ <div className="px-2 py-6 text-sm text-gray-500">{t('emptyDirectory')}</div>
257
+ )}
258
+ </ScrollArea>
259
+ </div>
260
+
261
+ {hint ? (
262
+ <p className="text-xs leading-relaxed text-gray-500">{hint}</p>
263
+ ) : null}
264
+
265
+ <DialogFooter>
266
+ <Button
267
+ type="button"
268
+ variant="outline"
269
+ onClick={() => onOpenChange(false)}
270
+ disabled={isSaving}
271
+ >
272
+ {t('cancel')}
273
+ </Button>
274
+ <Button type="submit" disabled={submitDisabled}>
275
+ {isSaving ? t('saving') : confirmLabel}
276
+ </Button>
277
+ </DialogFooter>
278
+ </form>
279
+ </DialogContent>
280
+ </Dialog>
281
+ );
282
+ }
@@ -0,0 +1,19 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { fetchServerPathBrowse } from '@/api/server-path';
3
+
4
+ export function useServerPathBrowse(params: {
5
+ path?: string | null;
6
+ includeFiles?: boolean;
7
+ enabled?: boolean;
8
+ }) {
9
+ return useQuery({
10
+ queryKey: ['server-path-browse', params.path ?? null, params.includeFiles ?? false],
11
+ queryFn: () =>
12
+ fetchServerPathBrowse({
13
+ path: params.path,
14
+ includeFiles: params.includeFiles,
15
+ }),
16
+ enabled: params.enabled ?? true,
17
+ staleTime: 0,
18
+ });
19
+ }
@@ -26,7 +26,13 @@ import {
26
26
  setCronJobEnabled,
27
27
  runCronJob
28
28
  } from '@/api/config';
29
- import { deleteNcpSession, fetchNcpSessionMessages, fetchNcpSessions, updateNcpSession } from '@/api/ncp-session';
29
+ import {
30
+ deleteNcpSession,
31
+ fetchNcpSessionMessages,
32
+ fetchNcpSessionSkills,
33
+ fetchNcpSessions,
34
+ updateNcpSession
35
+ } from '@/api/ncp-session';
30
36
  import { toast } from 'sonner';
31
37
  import { t } from '@/lib/i18n';
32
38
 
@@ -248,6 +254,24 @@ export function useNcpSessionMessages(sessionId: string | null, limit = 200) {
248
254
  });
249
255
  }
250
256
 
257
+ export function useNcpSessionSkills(params: {
258
+ sessionId: string | null;
259
+ projectRoot?: string | null;
260
+ }) {
261
+ return useQuery({
262
+ queryKey: ['ncp-session-skills', params.sessionId, params.projectRoot ?? null],
263
+ queryFn: () =>
264
+ fetchNcpSessionSkills(params.sessionId as string, {
265
+ ...(Object.prototype.hasOwnProperty.call(params, 'projectRoot')
266
+ ? { projectRoot: params.projectRoot ?? null }
267
+ : {})
268
+ }),
269
+ enabled: Boolean(params.sessionId),
270
+ staleTime: 5_000,
271
+ retry: false
272
+ });
273
+ }
274
+
251
275
  export function useDeleteNcpSession() {
252
276
  const queryClient = useQueryClient();
253
277
 
@@ -272,6 +296,7 @@ export function useUpdateNcpSession() {
272
296
  updateNcpSession(sessionId, data),
273
297
  onSuccess: (data) => {
274
298
  upsertNcpSessionSummaryInQueryClient(queryClient, data);
299
+ queryClient.invalidateQueries({ queryKey: ['ncp-session-skills', data.sessionId] });
275
300
  toast.success(t('configSavedApplied'));
276
301
  },
277
302
  onError: (error: Error) => {
@@ -0,0 +1,94 @@
1
+ export type I18nLanguage = 'zh' | 'en';
2
+
3
+ const I18N_STORAGE_KEY = 'nextclaw.ui.language';
4
+ const LANGUAGE_TO_LOCALE: Record<I18nLanguage, string> = {
5
+ en: 'en-US',
6
+ zh: 'zh-CN'
7
+ };
8
+
9
+ export const LANGUAGE_OPTIONS: Array<{ value: I18nLanguage; label: string }> = [
10
+ { value: 'en', label: 'English' },
11
+ { value: 'zh', label: '中文' }
12
+ ];
13
+
14
+ class I18nLanguageOwner {
15
+ private activeLanguage: I18nLanguage = 'en';
16
+ private initialized = false;
17
+ private readonly listeners = new Set<(lang: I18nLanguage) => void>();
18
+
19
+ private isLanguage = (value: unknown): value is I18nLanguage => {
20
+ return value === 'en' || value === 'zh';
21
+ };
22
+
23
+ private detectBrowserLanguage = (): I18nLanguage => {
24
+ if (typeof navigator === 'undefined') {
25
+ return 'en';
26
+ }
27
+ const preferred = navigator.language?.toLowerCase() ?? 'en';
28
+ return preferred.startsWith('zh') ? 'zh' : 'en';
29
+ };
30
+
31
+ resolveInitialLanguage = (): I18nLanguage => {
32
+ if (typeof window === 'undefined') {
33
+ return 'en';
34
+ }
35
+
36
+ try {
37
+ const saved = window.localStorage.getItem(I18N_STORAGE_KEY);
38
+ if (this.isLanguage(saved)) {
39
+ return saved;
40
+ }
41
+ } catch {
42
+ // ignore storage failures
43
+ }
44
+
45
+ return this.detectBrowserLanguage();
46
+ };
47
+
48
+ initialize = (): I18nLanguage => {
49
+ if (!this.initialized) {
50
+ this.activeLanguage = this.resolveInitialLanguage();
51
+ this.initialized = true;
52
+ }
53
+ return this.activeLanguage;
54
+ };
55
+
56
+ getLanguage = (): I18nLanguage => (this.initialized ? this.activeLanguage : this.initialize());
57
+
58
+ setLanguage = (lang: I18nLanguage): void => {
59
+ this.initialize();
60
+ if (this.activeLanguage === lang) {
61
+ return;
62
+ }
63
+
64
+ this.activeLanguage = lang;
65
+
66
+ if (typeof window !== 'undefined') {
67
+ try {
68
+ window.localStorage.setItem(I18N_STORAGE_KEY, lang);
69
+ } catch {
70
+ // ignore storage failures
71
+ }
72
+ }
73
+
74
+ this.listeners.forEach((listener) => listener(lang));
75
+ };
76
+
77
+ subscribeLanguageChange = (listener: (lang: I18nLanguage) => void): (() => void) => {
78
+ this.listeners.add(listener);
79
+ return () => {
80
+ this.listeners.delete(listener);
81
+ };
82
+ };
83
+
84
+ getLocale = (lang: I18nLanguage = this.getLanguage()): string => LANGUAGE_TO_LOCALE[lang];
85
+ }
86
+
87
+ const owner = new I18nLanguageOwner();
88
+
89
+ export const resolveInitialLanguage = owner.resolveInitialLanguage;
90
+ export const initializeI18n = owner.initialize;
91
+ export const getLanguage = owner.getLanguage;
92
+ export const setLanguage = owner.setLanguage;
93
+ export const subscribeLanguageChange = owner.subscribeLanguageChange;
94
+ export const getLocale = owner.getLocale;