@nextclaw/ui 0.11.20 → 0.11.22

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 (125) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/{ChannelsList-DAx7wv0_.js → ChannelsList-Zeys_w43.js} +6 -6
  3. package/dist/assets/ChatPage-DWOU_8P6.js +43 -0
  4. package/dist/assets/DocBrowser-B9OaZjmg.js +1 -0
  5. package/dist/assets/{DocBrowser-DKkE3Y4I.js → DocBrowser-BmtBLFU0.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-BcZRBsCg.js → DocBrowserContext-YIKkPb76.js} +1 -1
  7. package/dist/assets/{LogoBadge-BIPDLEwK.js → LogoBadge-F7ZWdxLT.js} +1 -1
  8. package/dist/assets/MarketplacePage-BfaTTqN6.js +1 -0
  9. package/dist/assets/{MarketplacePage-Dlp5BgCh.js → MarketplacePage-Cd4faegU.js} +2 -2
  10. package/dist/assets/{McpMarketplacePage-CwKtAil8.js → McpMarketplacePage-C09Ngs7O.js} +2 -2
  11. package/dist/assets/ModelConfig-DJgdcgvQ.js +1 -0
  12. package/dist/assets/ProvidersList-w0rVFIBf.js +1 -0
  13. package/dist/assets/RemoteAccessPage-BJ_ckkOV.js +1 -0
  14. package/dist/assets/RuntimeConfig-Cmn2xPQO.js +1 -0
  15. package/dist/assets/{SearchConfig-v46R5a2U.js → SearchConfig-BT13qpR_.js} +1 -1
  16. package/dist/assets/{SecretsConfig-CXvUpbB_.js → SecretsConfig-CvqEVn0B.js} +2 -2
  17. package/dist/assets/{SessionsConfig-7vUHMtOh.js → SessionsConfig-DHHcYznk.js} +2 -2
  18. package/dist/assets/{book-open-DzSduAaw.js → book-open-CXoF5nQC.js} +1 -1
  19. package/dist/assets/chat-session-display-VW6ZMvZP.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-C1vpvW4r.js → chunk-JZWAC4HX-CvRWvTy5.js} +1 -1
  21. package/dist/assets/{config-Df97LeLR.js → config-DJswxxE8.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CcR5wVoU.js → createLucideIcon-CjGHOWb6.js} +1 -1
  23. package/dist/assets/{dist-Dii9v3X9.js → dist-Cl2QB-2y.js} +1 -1
  24. package/dist/assets/{dist-BMlnBah3.js → dist-nqTTbVdA.js} +1 -1
  25. package/dist/assets/{external-link-CnSDrvJE.js → external-link-tIO7zING.js} +1 -1
  26. package/dist/assets/{hash-CAnX6PNt.js → hash-JWUyl1pT.js} +1 -1
  27. package/dist/assets/i18n-CDHMXlRZ.js +1 -0
  28. package/dist/assets/index-BlH4-cBw.css +1 -0
  29. package/dist/assets/{index-B0DzQqwv.js → index-C6d0xmtm.js} +3 -3
  30. package/dist/assets/{label-CtIFj7_6.js → label-BIpeNu4r.js} +1 -1
  31. package/dist/assets/loader-circle-Cs8XVFTw.js +1 -0
  32. package/dist/assets/{logos-3KFNiOej.js → logos-DThdM9lk.js} +1 -1
  33. package/dist/assets/{page-layout-BMwpn87D.js → page-layout-D3Xo605Z.js} +1 -1
  34. package/dist/assets/plus-PHf8q-Ct.js +1 -0
  35. package/dist/assets/{popover-BIzq25oH.js → popover-BJRUGA_H.js} +1 -1
  36. package/dist/assets/provider-models-bz5y28rq.js +1 -0
  37. package/dist/assets/{react-ji6GGP_j.js → react-7ZHqQtEV.js} +1 -1
  38. package/dist/assets/refresh-ccw-CC6-_QuL.js +1 -0
  39. package/dist/assets/{save-CMgYkJ-y.js → save-DJM5RRWW.js} +1 -1
  40. package/dist/assets/search-C91yH_6y.js +1 -0
  41. package/dist/assets/{security-config-Xi5DYW7j.js → security-config-T5zpg16O.js} +1 -1
  42. package/dist/assets/{select-Cz82gl01.js → select-DSkTc61S.js} +1 -1
  43. package/dist/assets/skeleton-Dzg-HOiN.js +1 -0
  44. package/dist/assets/{status-dot-C7q1HvLH.js → status-dot-LNBlDu3q.js} +1 -1
  45. package/dist/assets/{switch-DYswvkYj.js → switch-Bo-Y46HZ.js} +1 -1
  46. package/dist/assets/tabs-custom-DXv507_2.js +1 -0
  47. package/dist/assets/{trash-2-DfXI7-ap.js → trash-2-DFZmW6Gg.js} +1 -1
  48. package/dist/assets/useConfirmDialog-Bs5Ll17m.js +1 -0
  49. package/dist/assets/{useMutation-s2sn2yzh.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 +41 -0
  57. package/src/components/chat/ChatConversationPanel.test.tsx +43 -7
  58. package/src/components/chat/ChatConversationPanel.tsx +23 -17
  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 +81 -6
  64. package/src/components/chat/adapters/chat-message.adapter.test.ts +393 -3
  65. package/src/components/chat/adapters/chat-message.partial-json.ts +89 -0
  66. package/src/components/chat/adapters/file-operation/card.ts +330 -0
  67. package/src/components/chat/adapters/file-operation/diff.ts +398 -0
  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-composer-state.ts +3 -3
  71. package/src/components/chat/chat-session-display.test.ts +21 -0
  72. package/src/components/chat/chat-session-display.ts +6 -1
  73. package/src/components/chat/containers/chat-input-bar.container.tsx +29 -32
  74. package/src/components/chat/containers/chat-message-list.container.tsx +1 -0
  75. package/src/components/chat/hooks/use-chat-session-label.ts +19 -0
  76. package/src/components/chat/hooks/use-chat-session-project.test.tsx +117 -0
  77. package/src/components/chat/hooks/use-chat-session-project.ts +40 -0
  78. package/src/components/chat/{chat-session-label.service.ts → hooks/use-chat-session-update.ts} +11 -7
  79. package/src/components/chat/managers/chat-session-list.manager.ts +5 -1
  80. package/src/components/chat/ncp/NcpChatPage.tsx +55 -17
  81. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +33 -0
  82. package/src/components/chat/ncp/ncp-chat-page-data.ts +21 -15
  83. package/src/components/chat/ncp/ncp-session-adapter.test.ts +176 -0
  84. package/src/components/chat/ncp/ncp-session-adapter.ts +16 -0
  85. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +63 -0
  86. package/src/components/chat/session-header/chat-session-header-actions.tsx +95 -0
  87. package/src/components/chat/session-header/chat-session-header-menu-item.tsx +35 -0
  88. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +66 -0
  89. package/src/components/chat/session-header/chat-session-project-badge.tsx +102 -0
  90. package/src/components/chat/session-header/chat-session-project-dialog.tsx +34 -0
  91. package/src/components/chat/stores/chat-input.store.ts +6 -3
  92. package/src/components/chat/stores/chat-thread.store.ts +6 -2
  93. package/src/components/chat/useNcpAgentRuntime.test.tsx +90 -0
  94. package/src/components/path-picker/server-path-picker-dialog.test.tsx +92 -0
  95. package/src/components/path-picker/server-path-picker-dialog.tsx +282 -0
  96. package/src/hooks/server-path/use-server-path-browse.ts +19 -0
  97. package/src/hooks/useConfig.ts +26 -1
  98. package/src/lib/i18n/i18n-language-owner.ts +94 -0
  99. package/src/lib/i18n/i18n.path-picker.ts +12 -0
  100. package/src/lib/i18n.chat.ts +25 -1
  101. package/src/lib/i18n.ts +21 -84
  102. package/src/lib/session-project/session-project.utils.ts +30 -0
  103. package/src/remote/remote-access-feedback.service.test.ts +18 -0
  104. package/src/remote/remote-access-feedback.service.ts +10 -1
  105. package/dist/assets/ChatPage-l2PYwCeB.js +0 -38
  106. package/dist/assets/DocBrowser-CIHLqoIm.js +0 -1
  107. package/dist/assets/MarketplacePage-TVeyVOuO.js +0 -1
  108. package/dist/assets/ModelConfig-Dg6F3Ldb.js +0 -1
  109. package/dist/assets/ProvidersList-f7bQdRxA.js +0 -1
  110. package/dist/assets/RemoteAccessPage-w_dY7P4T.js +0 -1
  111. package/dist/assets/RuntimeConfig-M4OKjmgU.js +0 -1
  112. package/dist/assets/chat-session-display-CGfXhJoT.js +0 -1
  113. package/dist/assets/i18n-CXBpwAwA.js +0 -1
  114. package/dist/assets/index-BahpXJg8.css +0 -1
  115. package/dist/assets/loader-circle-qgU4zQDw.js +0 -1
  116. package/dist/assets/plus-C9cYVbL-.js +0 -1
  117. package/dist/assets/provider-models-C8JQUd1E.js +0 -1
  118. package/dist/assets/search-sl1OeJFl.js +0 -1
  119. package/dist/assets/skeleton-rgIt7a5q.js +0 -1
  120. package/dist/assets/tabs-custom-DKYQxrx1.js +0 -1
  121. package/dist/assets/useConfirmDialog-CXDAxtRL.js +0 -1
  122. package/dist/assets/x-MIimOGs6.js +0 -1
  123. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-WtpHP_DW.js} +0 -0
  124. /package/dist/assets/{config-layout-B-7erZRN.js → config-layout-LQ10ozRC.js} +0 -0
  125. /package/dist/assets/{marketplace-localization-CXeGRf6E.js → marketplace-localization-CxSTG9wr.js} +0 -0
@@ -0,0 +1,95 @@
1
+ import { useState } from 'react';
2
+ import { FolderOpen, MoreHorizontal, Trash2 } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
5
+ import { useChatSessionProject } from '@/components/chat/hooks/use-chat-session-project';
6
+ import { ChatSessionHeaderMenuItem } from '@/components/chat/session-header/chat-session-header-menu-item';
7
+ import { ChatSessionProjectDialog } from '@/components/chat/session-header/chat-session-project-dialog';
8
+ import { t } from '@/lib/i18n';
9
+
10
+ type ChatSessionHeaderActionsProps = {
11
+ sessionKey: string;
12
+ canDeleteSession: boolean;
13
+ isDeletePending: boolean;
14
+ projectRoot?: string | null;
15
+ onDeleteSession: () => void;
16
+ };
17
+
18
+ export function ChatSessionHeaderActions({
19
+ sessionKey,
20
+ canDeleteSession,
21
+ isDeletePending,
22
+ projectRoot,
23
+ onDeleteSession,
24
+ }: ChatSessionHeaderActionsProps) {
25
+ const updateSessionProject = useChatSessionProject();
26
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
27
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
28
+ const [isProjectPending, setIsProjectPending] = useState(false);
29
+ const isBusy = isDeletePending || isProjectPending;
30
+
31
+ const runProjectUpdate = async (nextProjectRoot: string | null) => {
32
+ const persistToServer = canDeleteSession;
33
+ setIsProjectPending(true);
34
+ try {
35
+ await updateSessionProject({
36
+ sessionKey,
37
+ projectRoot: nextProjectRoot,
38
+ persistToServer,
39
+ });
40
+ setIsDialogOpen(false);
41
+ setIsMenuOpen(false);
42
+ } finally {
43
+ setIsProjectPending(false);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <>
49
+ <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
50
+ <PopoverTrigger asChild>
51
+ <Button
52
+ variant="ghost"
53
+ size="icon"
54
+ className="rounded-lg shrink-0 text-gray-400 hover:text-gray-700"
55
+ aria-label={t('chatSessionMoreActions')}
56
+ disabled={isBusy}
57
+ >
58
+ <MoreHorizontal className="h-4 w-4" />
59
+ </Button>
60
+ </PopoverTrigger>
61
+ <PopoverContent align="end" className="w-56 p-2">
62
+ <div className="space-y-1">
63
+ <ChatSessionHeaderMenuItem
64
+ icon={FolderOpen}
65
+ label={t('chatSessionSetProject')}
66
+ onClick={() => {
67
+ setIsMenuOpen(false);
68
+ setIsDialogOpen(true);
69
+ }}
70
+ disabled={isBusy}
71
+ />
72
+ <ChatSessionHeaderMenuItem
73
+ icon={Trash2}
74
+ label={t('chatDeleteSession')}
75
+ onClick={() => {
76
+ setIsMenuOpen(false);
77
+ onDeleteSession();
78
+ }}
79
+ disabled={!canDeleteSession || isBusy}
80
+ destructive
81
+ />
82
+ </div>
83
+ </PopoverContent>
84
+ </Popover>
85
+
86
+ <ChatSessionProjectDialog
87
+ open={isDialogOpen}
88
+ currentProjectRoot={projectRoot}
89
+ isSaving={isProjectPending}
90
+ onOpenChange={setIsDialogOpen}
91
+ onSave={runProjectUpdate}
92
+ />
93
+ </>
94
+ );
95
+ }
@@ -0,0 +1,35 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ type ChatSessionHeaderMenuItemProps = {
5
+ icon: LucideIcon;
6
+ label: string;
7
+ onClick: () => void;
8
+ disabled?: boolean;
9
+ destructive?: boolean;
10
+ };
11
+
12
+ export function ChatSessionHeaderMenuItem({
13
+ icon: Icon,
14
+ label,
15
+ onClick,
16
+ disabled = false,
17
+ destructive = false,
18
+ }: ChatSessionHeaderMenuItemProps) {
19
+ return (
20
+ <button
21
+ type="button"
22
+ className={cn(
23
+ 'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
24
+ destructive
25
+ ? 'text-destructive hover:bg-destructive/10'
26
+ : 'text-gray-700 hover:bg-gray-100'
27
+ )}
28
+ onClick={onClick}
29
+ disabled={disabled}
30
+ >
31
+ <Icon className="h-4 w-4 shrink-0" />
32
+ <span>{label}</span>
33
+ </button>
34
+ );
35
+ }
@@ -0,0 +1,66 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { ChatSessionProjectBadge } from '@/components/chat/session-header/chat-session-project-badge';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ updateSessionProject: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@/components/chat/hooks/use-chat-session-project', () => ({
11
+ useChatSessionProject: () => mocks.updateSessionProject,
12
+ }));
13
+
14
+ vi.mock('@/components/chat/session-header/chat-session-project-dialog', () => ({
15
+ ChatSessionProjectDialog: () => null,
16
+ }));
17
+
18
+ describe('ChatSessionProjectBadge', () => {
19
+ beforeEach(() => {
20
+ mocks.updateSessionProject.mockReset();
21
+ mocks.updateSessionProject.mockResolvedValue(undefined);
22
+ });
23
+
24
+ it('shows project actions inside the badge popover', async () => {
25
+ const user = userEvent.setup();
26
+
27
+ render(
28
+ <ChatSessionProjectBadge
29
+ sessionKey="session-1"
30
+ projectName="project-alpha"
31
+ projectRoot="/tmp/project-alpha"
32
+ persistToServer
33
+ />
34
+ );
35
+
36
+ await user.click(screen.getByRole('button', { name: 'Set Project Directory' }));
37
+
38
+ expect(screen.getAllByText('Set Project Directory').length).toBeGreaterThan(0);
39
+ expect(screen.getByText('Clear Project Directory')).toBeTruthy();
40
+ expect(screen.getByText('/tmp/project-alpha')).toBeTruthy();
41
+ });
42
+
43
+ it('clears the current project from the badge popover', async () => {
44
+ const user = userEvent.setup();
45
+
46
+ render(
47
+ <ChatSessionProjectBadge
48
+ sessionKey="session-1"
49
+ projectName="project-alpha"
50
+ projectRoot="/tmp/project-alpha"
51
+ persistToServer
52
+ />
53
+ );
54
+
55
+ await user.click(screen.getByRole('button', { name: 'Set Project Directory' }));
56
+ await user.click(screen.getByText('Clear Project Directory'));
57
+
58
+ await waitFor(() => {
59
+ expect(mocks.updateSessionProject).toHaveBeenCalledWith({
60
+ sessionKey: 'session-1',
61
+ projectRoot: null,
62
+ persistToServer: true,
63
+ });
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,102 @@
1
+ import { useState } from 'react';
2
+ import { ChevronDown, FolderOpen, FolderX, Pencil } from 'lucide-react';
3
+ import { useChatSessionProject } from '@/components/chat/hooks/use-chat-session-project';
4
+ import { ChatSessionHeaderMenuItem } from '@/components/chat/session-header/chat-session-header-menu-item';
5
+ import { ChatSessionProjectDialog } from '@/components/chat/session-header/chat-session-project-dialog';
6
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
7
+ import { t } from '@/lib/i18n';
8
+
9
+ type ChatSessionProjectBadgeProps = {
10
+ sessionKey: string;
11
+ projectName: string;
12
+ projectRoot?: string | null;
13
+ persistToServer: boolean;
14
+ };
15
+
16
+ export function ChatSessionProjectBadge({
17
+ sessionKey,
18
+ projectName,
19
+ projectRoot,
20
+ persistToServer,
21
+ }: ChatSessionProjectBadgeProps) {
22
+ const updateSessionProject = useChatSessionProject();
23
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
24
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
25
+ const [isProjectPending, setIsProjectPending] = useState(false);
26
+
27
+ const runProjectUpdate = async (nextProjectRoot: string | null) => {
28
+ setIsProjectPending(true);
29
+ try {
30
+ await updateSessionProject({
31
+ sessionKey,
32
+ projectRoot: nextProjectRoot,
33
+ persistToServer,
34
+ });
35
+ setIsDialogOpen(false);
36
+ setIsMenuOpen(false);
37
+ } finally {
38
+ setIsProjectPending(false);
39
+ }
40
+ };
41
+
42
+ return (
43
+ <>
44
+ <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
45
+ <PopoverTrigger asChild>
46
+ <button
47
+ type="button"
48
+ title={projectRoot ?? undefined}
49
+ className="min-w-0 max-w-[320px] shrink rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 transition-colors hover:border-emerald-300 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
50
+ aria-label={t('chatSessionSetProject')}
51
+ disabled={isProjectPending}
52
+ >
53
+ <span className="flex min-w-0 items-center gap-1.5">
54
+ <FolderOpen className="h-3.5 w-3.5 shrink-0" />
55
+ <span className="truncate">{projectName}</span>
56
+ <ChevronDown className="h-3 w-3 shrink-0 opacity-70" />
57
+ </span>
58
+ </button>
59
+ </PopoverTrigger>
60
+ <PopoverContent align="start" className="w-72 p-2">
61
+ <div className="px-3 pb-2 pt-1">
62
+ <div className="text-[11px] font-medium uppercase tracking-wider text-emerald-700/80">
63
+ {projectName}
64
+ </div>
65
+ {projectRoot ? (
66
+ <div className="mt-1 break-all text-xs text-gray-500">
67
+ {projectRoot}
68
+ </div>
69
+ ) : null}
70
+ </div>
71
+ <div className="space-y-1">
72
+ <ChatSessionHeaderMenuItem
73
+ icon={Pencil}
74
+ label={t('chatSessionSetProject')}
75
+ onClick={() => {
76
+ setIsMenuOpen(false);
77
+ setIsDialogOpen(true);
78
+ }}
79
+ disabled={isProjectPending}
80
+ />
81
+ <ChatSessionHeaderMenuItem
82
+ icon={FolderX}
83
+ label={t('chatSessionClearProject')}
84
+ onClick={() => {
85
+ void runProjectUpdate(null);
86
+ }}
87
+ disabled={isProjectPending}
88
+ />
89
+ </div>
90
+ </PopoverContent>
91
+ </Popover>
92
+
93
+ <ChatSessionProjectDialog
94
+ open={isDialogOpen}
95
+ currentProjectRoot={projectRoot}
96
+ isSaving={isProjectPending}
97
+ onOpenChange={setIsDialogOpen}
98
+ onSave={runProjectUpdate}
99
+ />
100
+ </>
101
+ );
102
+ }
@@ -0,0 +1,34 @@
1
+ import { ServerPathPickerDialog } from '@/components/path-picker/server-path-picker-dialog';
2
+ import { t } from '@/lib/i18n';
3
+
4
+ type ChatSessionProjectDialogProps = {
5
+ open: boolean;
6
+ currentProjectRoot?: string | null;
7
+ isSaving: boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ onSave: (projectRoot: string) => Promise<void> | void;
10
+ };
11
+
12
+ export function ChatSessionProjectDialog({
13
+ open,
14
+ currentProjectRoot,
15
+ isSaving,
16
+ onOpenChange,
17
+ onSave,
18
+ }: ChatSessionProjectDialogProps) {
19
+ return (
20
+ <ServerPathPickerDialog
21
+ open={open}
22
+ currentPath={currentProjectRoot}
23
+ isSaving={isSaving}
24
+ onOpenChange={onOpenChange}
25
+ onConfirm={onSave}
26
+ title={t('chatSessionProjectDialogTitle')}
27
+ description={t('chatSessionProjectDialogDescription')}
28
+ pathLabel={t('chatSessionProjectPathLabel')}
29
+ pathPlaceholder={t('chatSessionProjectPathPlaceholder')}
30
+ confirmLabel={t('chatSessionSetProject')}
31
+ hint={t('chatSessionProjectUpdateHint')}
32
+ />
33
+ );
34
+ }
@@ -1,8 +1,7 @@
1
1
  import { create } from 'zustand';
2
2
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
3
3
  import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
4
- import type { MarketplaceInstalledRecord } from '@/api/types';
5
- import type { ThinkingLevel } from '@/api/types';
4
+ import type { SessionSkillEntryView, ThinkingLevel } from '@/api/types';
6
5
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
7
6
  import { createInitialChatComposerNodes } from '@/components/chat/chat-composer-state';
8
7
 
@@ -12,6 +11,8 @@ export type ChatInputSnapshot = {
12
11
  attachments: NcpDraftAttachment[];
13
12
  draft: string;
14
13
  pendingSessionType: string;
14
+ pendingProjectRoot: string | null;
15
+ pendingProjectRootSessionKey: string | null;
15
16
  defaultSessionType: string;
16
17
  canStopGeneration: boolean;
17
18
  stopDisabledReason: string | null;
@@ -39,7 +40,7 @@ export type ChatInputSnapshot = {
39
40
  stopReason?: string;
40
41
  canEditSessionType: boolean;
41
42
  sessionTypeUnavailable: boolean;
42
- skillRecords: MarketplaceInstalledRecord[];
43
+ skillRecords: SessionSkillEntryView[];
43
44
  isSkillsLoading: boolean;
44
45
  selectedSkills: string[];
45
46
  };
@@ -55,6 +56,8 @@ const initialSnapshot: ChatInputSnapshot = {
55
56
  attachments: [],
56
57
  draft: '',
57
58
  pendingSessionType: 'native',
59
+ pendingProjectRoot: null,
60
+ pendingProjectRootSessionKey: null,
58
61
  defaultSessionType: 'native',
59
62
  canStopGeneration: false,
60
63
  stopDisabledReason: null,
@@ -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;
@@ -31,8 +33,10 @@ const initialSnapshot: ChatThreadSnapshot = {
31
33
  sessionTypeUnavailable: false,
32
34
  sessionTypeUnavailableMessage: null,
33
35
  sessionTypeLabel: null,
34
- selectedSessionKey: null,
36
+ sessionKey: null,
35
37
  sessionDisplayName: undefined,
38
+ sessionProjectRoot: null,
39
+ sessionProjectName: null,
36
40
  canDeleteSession: false,
37
41
  isDeletePending: false,
38
42
  threadRef: null,
@@ -0,0 +1,90 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { NcpEventType, type NcpEndpointEvent } from "@nextclaw/ncp";
4
+ import { useNcpAgentRuntime } from "../../../../ncp-packages/nextclaw-ncp-react/src/hooks/use-ncp-agent-runtime.ts";
5
+
6
+ function createEvent(type: NcpEventType, delta: string): NcpEndpointEvent {
7
+ return {
8
+ type,
9
+ payload: {
10
+ sessionId: "session-1",
11
+ messageId: "assistant-1",
12
+ toolCallId: "tool-1",
13
+ delta,
14
+ },
15
+ } as NcpEndpointEvent;
16
+ }
17
+
18
+ describe("useNcpAgentRuntime", () => {
19
+ beforeEach(() => {
20
+ vi.useFakeTimers();
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.useRealTimers();
25
+ });
26
+
27
+ it("batches streamed endpoint events before dispatching them to the manager", async () => {
28
+ let subscriber: ((event: NcpEndpointEvent) => void) | null = null;
29
+ const snapshot = {
30
+ messages: [],
31
+ streamingMessage: null,
32
+ error: null,
33
+ activeRun: null,
34
+ };
35
+ const client = {
36
+ subscribe: vi.fn((callback: (event: NcpEndpointEvent) => void) => {
37
+ subscriber = callback;
38
+ return () => {
39
+ subscriber = null;
40
+ };
41
+ }),
42
+ stop: vi.fn().mockResolvedValue(undefined),
43
+ send: vi.fn().mockResolvedValue(undefined),
44
+ abort: vi.fn().mockResolvedValue(undefined),
45
+ stream: vi.fn().mockResolvedValue(undefined),
46
+ };
47
+ const manager = {
48
+ getSnapshot: vi.fn(() => snapshot),
49
+ subscribe: vi.fn(() => () => {}),
50
+ dispatch: vi.fn().mockResolvedValue(undefined),
51
+ dispatchBatch: vi.fn().mockResolvedValue(undefined),
52
+ };
53
+
54
+ renderHook(() =>
55
+ useNcpAgentRuntime({
56
+ sessionId: "session-1",
57
+ client: client as never,
58
+ manager: manager as never,
59
+ }),
60
+ );
61
+
62
+ expect(subscriber).not.toBeNull();
63
+
64
+ act(() => {
65
+ subscriber?.(createEvent(NcpEventType.MessageToolCallArgsDelta, '{"path":"src/app.ts",'));
66
+ subscriber?.(
67
+ createEvent(
68
+ NcpEventType.MessageToolCallArgsDelta,
69
+ '"content":"console.log(1);"}',
70
+ ),
71
+ );
72
+ });
73
+
74
+ expect(manager.dispatchBatch).not.toHaveBeenCalled();
75
+
76
+ await act(async () => {
77
+ vi.advanceTimersByTime(16);
78
+ await Promise.resolve();
79
+ });
80
+
81
+ expect(manager.dispatchBatch).toHaveBeenCalledTimes(1);
82
+ expect(manager.dispatchBatch).toHaveBeenCalledWith([
83
+ createEvent(NcpEventType.MessageToolCallArgsDelta, '{"path":"src/app.ts",'),
84
+ createEvent(
85
+ NcpEventType.MessageToolCallArgsDelta,
86
+ '"content":"console.log(1);"}',
87
+ ),
88
+ ]);
89
+ });
90
+ });
@@ -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
+ });