@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.
- package/CHANGELOG.md +24 -0
- package/dist/assets/{ChannelsList-ByHWHkQS.js → ChannelsList-DVDu1xvz.js} +6 -6
- package/dist/assets/ChatPage-Z9tRzm_n.js +43 -0
- package/dist/assets/DocBrowser-B9OaZjmg.js +1 -0
- package/dist/assets/{DocBrowser-3y_NHZ71.js → DocBrowser-BmtBLFU0.js} +1 -1
- package/dist/assets/{DocBrowserContext-CVJuwCcw.js → DocBrowserContext-YIKkPb76.js} +1 -1
- package/dist/assets/{LogoBadge-D8fyilO-.js → LogoBadge-F7ZWdxLT.js} +1 -1
- package/dist/assets/{MarketplacePage-CmhsZXr1.js → MarketplacePage-Buo9HrOz.js} +2 -2
- package/dist/assets/MarketplacePage-D6rVQEQR.js +1 -0
- package/dist/assets/{McpMarketplacePage-C7PkCYbp.js → McpMarketplacePage-JnkYwK7p.js} +2 -2
- package/dist/assets/ModelConfig-BYRhgp0c.js +1 -0
- package/dist/assets/ProvidersList-DmLyyHvX.js +1 -0
- package/dist/assets/RemoteAccessPage-CDSSvH7Z.js +1 -0
- package/dist/assets/RuntimeConfig-v7a7Fe3x.js +1 -0
- package/dist/assets/{SearchConfig-Dm7r2yfp.js → SearchConfig-D5f1EkLE.js} +1 -1
- package/dist/assets/{SecretsConfig-BBP_mbQh.js → SecretsConfig-D61IKcYt.js} +2 -2
- package/dist/assets/{SessionsConfig-6wNJloZN.js → SessionsConfig-BRIxVTEv.js} +2 -2
- package/dist/assets/{book-open-B26jGBjY.js → book-open-CXoF5nQC.js} +1 -1
- package/dist/assets/chat-session-display-D0WpnuRZ.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-B-4B29RN.js → chunk-JZWAC4HX-CvRWvTy5.js} +1 -1
- package/dist/assets/{config-BaC29Qf-.js → config-DJswxxE8.js} +1 -1
- package/dist/assets/{createLucideIcon-DiFAvXmK.js → createLucideIcon-CjGHOWb6.js} +1 -1
- package/dist/assets/{dist-pCfWPG1A.js → dist-Cl2QB-2y.js} +1 -1
- package/dist/assets/{dist-kW_O3kyZ.js → dist-nqTTbVdA.js} +1 -1
- package/dist/assets/{external-link-D5-p-Gmm.js → external-link-tIO7zING.js} +1 -1
- package/dist/assets/{hash-BlwrSV0q.js → hash-JWUyl1pT.js} +1 -1
- package/dist/assets/i18n-CDHMXlRZ.js +1 -0
- package/dist/assets/{index-DvKS3L9j.js → index-BuwbBgmT.js} +3 -3
- package/dist/assets/index-bZ8cqQIS.css +1 -0
- package/dist/assets/{label-RyXfZqkP.js → label-BIpeNu4r.js} +1 -1
- package/dist/assets/loader-circle-Cs8XVFTw.js +1 -0
- package/dist/assets/{logos-Bpl8QTgI.js → logos-DThdM9lk.js} +1 -1
- package/dist/assets/{page-layout--S0YBU0W.js → page-layout-D3Xo605Z.js} +1 -1
- package/dist/assets/plus-PHf8q-Ct.js +1 -0
- package/dist/assets/{popover-BEjfbEwy.js → popover-BJRUGA_H.js} +1 -1
- package/dist/assets/provider-models-bz5y28rq.js +1 -0
- package/dist/assets/{react-BuSP2-8B.js → react-7ZHqQtEV.js} +1 -1
- package/dist/assets/refresh-ccw-CC6-_QuL.js +1 -0
- package/dist/assets/{save-DPPPpD_c.js → save-DJM5RRWW.js} +1 -1
- package/dist/assets/search-C91yH_6y.js +1 -0
- package/dist/assets/{security-config-6t78Ph-I.js → security-config-DbUyWcQz.js} +1 -1
- package/dist/assets/{select-CT50pzod.js → select-DSkTc61S.js} +1 -1
- package/dist/assets/skeleton-Dzg-HOiN.js +1 -0
- package/dist/assets/{status-dot-BbBqRHfh.js → status-dot-LNBlDu3q.js} +1 -1
- package/dist/assets/{switch-D3l6AcCk.js → switch-Bo-Y46HZ.js} +1 -1
- package/dist/assets/tabs-custom-DXv507_2.js +1 -0
- package/dist/assets/{trash-2-B2_AGVE3.js → trash-2-DFZmW6Gg.js} +1 -1
- package/dist/assets/useConfirmDialog-COwYXDKm.js +1 -0
- package/dist/assets/{useMutation-BzCrO8j-.js → useMutation-DrZrOgVL.js} +1 -1
- package/dist/assets/x-D7Q1yqSF.js +1 -0
- package/dist/index.html +18 -18
- package/package.json +6 -6
- package/src/api/ncp-session.test.ts +37 -0
- package/src/api/ncp-session.ts +29 -1
- package/src/api/server-path.ts +23 -0
- package/src/api/types.ts +45 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +53 -9
- package/src/components/chat/ChatConversationPanel.tsx +122 -79
- package/src/components/chat/ChatSidebar.test.tsx +2 -2
- package/src/components/chat/ChatSidebar.tsx +2 -2
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +1 -0
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +7 -2
- package/src/components/chat/adapters/chat-message-part.adapter.ts +26 -14
- package/src/components/chat/adapters/chat-message.adapter.test.ts +159 -13
- package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +191 -0
- package/src/components/chat/adapters/{chat-message.file-operation-card.ts → file-operation/card.ts} +74 -181
- package/src/components/chat/adapters/{chat-message.file-operation-diff.ts → file-operation/diff.ts} +178 -188
- package/src/components/chat/adapters/file-operation/line-builder.ts +249 -0
- package/src/components/chat/adapters/file-operation/record-readers.ts +233 -0
- package/src/components/chat/chat-child-session-panel.tsx +100 -0
- package/src/components/chat/chat-composer-state.ts +3 -3
- package/src/components/chat/chat-page-runtime.test.ts +1 -0
- package/src/components/chat/chat-session-display.test.ts +22 -0
- package/src/components/chat/chat-session-display.ts +6 -1
- package/src/components/chat/containers/chat-input-bar.container.tsx +21 -24
- package/src/components/chat/containers/chat-message-list.container.tsx +4 -0
- package/src/components/chat/hooks/use-chat-session-label.ts +19 -0
- package/src/components/chat/hooks/use-chat-session-project.test.tsx +117 -0
- package/src/components/chat/hooks/use-chat-session-project.ts +40 -0
- package/src/components/chat/{chat-session-label.service.ts → hooks/use-chat-session-update.ts} +11 -7
- package/src/components/chat/managers/chat-session-list.manager.ts +5 -1
- package/src/components/chat/ncp/NcpChatPage.tsx +219 -116
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +33 -0
- package/src/components/chat/ncp/ncp-chat-page-data.ts +21 -15
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +49 -0
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +24 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +47 -0
- package/src/components/chat/ncp/use-ncp-session-list-view.ts +10 -1
- package/src/components/chat/presenter/chat-presenter-context.tsx +4 -1
- package/src/components/chat/session-header/chat-session-header-actions.test.tsx +63 -0
- package/src/components/chat/session-header/chat-session-header-actions.tsx +95 -0
- package/src/components/chat/session-header/chat-session-header-menu-item.tsx +35 -0
- package/src/components/chat/session-header/chat-session-project-badge.test.tsx +66 -0
- package/src/components/chat/session-header/chat-session-project-badge.tsx +102 -0
- package/src/components/chat/session-header/chat-session-project-dialog.tsx +34 -0
- package/src/components/chat/stores/chat-input.store.ts +6 -3
- package/src/components/chat/stores/chat-thread.store.ts +17 -3
- package/src/components/chat/useHydratedNcpAgent.test.tsx +30 -23
- package/src/components/path-picker/server-path-picker-dialog.test.tsx +92 -0
- package/src/components/path-picker/server-path-picker-dialog.tsx +282 -0
- package/src/hooks/server-path/use-server-path-browse.ts +19 -0
- package/src/hooks/useConfig.ts +26 -1
- package/src/lib/i18n/i18n-language-owner.ts +94 -0
- package/src/lib/i18n/i18n.path-picker.ts +12 -0
- package/src/lib/i18n.chat.ts +23 -0
- package/src/lib/i18n.ts +21 -84
- package/src/lib/session-project/session-project.utils.ts +30 -0
- package/dist/assets/ChatPage-FdT3pDnw.js +0 -42
- package/dist/assets/DocBrowser-CMdPdbZj.js +0 -1
- package/dist/assets/MarketplacePage-9oKmxN2n.js +0 -1
- package/dist/assets/ModelConfig-DmCY6jWM.js +0 -1
- package/dist/assets/ProvidersList-ClT-34aX.js +0 -1
- package/dist/assets/RemoteAccessPage-B6hUZl1O.js +0 -1
- package/dist/assets/RuntimeConfig-C5aqliGk.js +0 -1
- package/dist/assets/chat-session-display-Bjmn4aIZ.js +0 -1
- package/dist/assets/i18n-CSytxMFI.js +0 -1
- package/dist/assets/index-CUy6doWo.css +0 -1
- package/dist/assets/loader-circle-B2J777gj.js +0 -1
- package/dist/assets/plus-CM9XJ0Tf.js +0 -1
- package/dist/assets/provider-models-C8JQUd1E.js +0 -1
- package/dist/assets/search-Ctaw34Kp.js +0 -1
- package/dist/assets/skeleton-Bycyb0zU.js +0 -1
- package/dist/assets/tabs-custom-TZQ5WPWP.js +0 -1
- package/dist/assets/useConfirmDialog-BDpdjfIO.js +0 -1
- package/dist/assets/x-CHOBE-63.js +0 -1
- package/src/components/chat/adapters/chat-message.subagent-tool-card.ts +0 -154
- /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-WtpHP_DW.js} +0 -0
- /package/dist/assets/{config-layout-B-7erZRN.js → config-layout-LQ10ozRC.js} +0 -0
- /package/dist/assets/{marketplace-localization-CXeGRf6E.js → marketplace-localization-CxSTG9wr.js} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appQueryClient } from '@/app-query-client';
|
|
2
2
|
import { deleteNcpSessionSummaryInQueryClient } from '@/api/ncp-session-query-cache';
|
|
3
3
|
import { deleteNcpSession as deleteNcpSessionApi } from '@/api/ncp-session';
|
|
4
|
+
import type { ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
|
|
4
5
|
import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
|
|
5
6
|
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
6
7
|
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
@@ -45,6 +46,54 @@ export class NcpChatThreadManager {
|
|
|
45
46
|
this.uiManager.goToProviders();
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
openSessionFromToolAction = (action: ChatToolActionViewModel) => {
|
|
50
|
+
if (action.kind !== 'open-session') {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (action.sessionKind === 'child' && !this.isCompactViewport()) {
|
|
54
|
+
const parentSessionKey =
|
|
55
|
+
action.parentSessionId?.trim() ||
|
|
56
|
+
useChatSessionListStore.getState().snapshot.selectedSessionKey ||
|
|
57
|
+
null;
|
|
58
|
+
useChatThreadStore.getState().setSnapshot({
|
|
59
|
+
childSessionDetailSessionKey: action.sessionId,
|
|
60
|
+
childSessionDetailParentSessionKey: parentSessionKey,
|
|
61
|
+
childSessionDetailLabel: action.label?.trim() || null,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.uiManager.goToSession(action.sessionId);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
closeChildSessionDetail = () => {
|
|
69
|
+
useChatThreadStore.getState().setSnapshot({
|
|
70
|
+
childSessionDetailSessionKey: null,
|
|
71
|
+
childSessionDetailParentSessionKey: null,
|
|
72
|
+
childSessionDetailLabel: null,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
goToParentSession = () => {
|
|
77
|
+
const {
|
|
78
|
+
parentSessionKey,
|
|
79
|
+
childSessionDetailParentSessionKey,
|
|
80
|
+
} = useChatThreadStore.getState().snapshot;
|
|
81
|
+
const resolvedParentSessionKey =
|
|
82
|
+
parentSessionKey ?? childSessionDetailParentSessionKey;
|
|
83
|
+
if (!resolvedParentSessionKey) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.closeChildSessionDetail();
|
|
87
|
+
this.uiManager.goToSession(resolvedParentSessionKey);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
private isCompactViewport = (): boolean => {
|
|
91
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return window.matchMedia('(max-width: 767px)').matches;
|
|
95
|
+
};
|
|
96
|
+
|
|
48
97
|
private deleteCurrentSession = async () => {
|
|
49
98
|
const {
|
|
50
99
|
snapshot: { selectedSessionKey }
|
|
@@ -24,6 +24,7 @@ describe('adaptNcpSessionSummary', () => {
|
|
|
24
24
|
label: 'NCP Planning Thread',
|
|
25
25
|
model: 'openai/gpt-5',
|
|
26
26
|
preferred_thinking: 'medium',
|
|
27
|
+
project_root: '/Users/demo/workspace/project-alpha',
|
|
27
28
|
session_type: 'native'
|
|
28
29
|
}
|
|
29
30
|
})
|
|
@@ -34,11 +35,34 @@ describe('adaptNcpSessionSummary', () => {
|
|
|
34
35
|
label: 'NCP Planning Thread',
|
|
35
36
|
preferredModel: 'openai/gpt-5',
|
|
36
37
|
preferredThinking: 'medium',
|
|
38
|
+
projectRoot: '/Users/demo/workspace/project-alpha',
|
|
39
|
+
projectName: 'project-alpha',
|
|
37
40
|
sessionType: 'native',
|
|
38
41
|
sessionTypeMutable: false,
|
|
42
|
+
isChildSession: false,
|
|
39
43
|
messageCount: 3
|
|
40
44
|
});
|
|
41
45
|
});
|
|
46
|
+
|
|
47
|
+
it('marks child sessions from parent_session_id metadata and keeps the request link', () => {
|
|
48
|
+
const adapted = adaptNcpSessionSummary(
|
|
49
|
+
createSummary({
|
|
50
|
+
metadata: {
|
|
51
|
+
label: 'Verifier',
|
|
52
|
+
session_type: 'native',
|
|
53
|
+
parent_session_id: 'parent-session-1',
|
|
54
|
+
spawned_by_request_id: 'request-1',
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(adapted).toMatchObject({
|
|
60
|
+
key: 'ncp-session-1',
|
|
61
|
+
isChildSession: true,
|
|
62
|
+
parentSessionId: 'parent-session-1',
|
|
63
|
+
spawnedByRequestId: 'request-1',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
42
66
|
});
|
|
43
67
|
|
|
44
68
|
describe('adaptNcpMessageToUiMessage', () => {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { ToolInvocationStatus, type UIMessage } from '@nextclaw/agent-chat';
|
|
2
2
|
import type { NcpMessagePart } from '@nextclaw/ncp';
|
|
3
3
|
import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView, ThinkingLevel } from '@/api/types';
|
|
4
|
+
import {
|
|
5
|
+
getSessionProjectName,
|
|
6
|
+
normalizeSessionProjectRootValue,
|
|
7
|
+
} from '@/lib/session-project/session-project.utils';
|
|
4
8
|
|
|
5
9
|
const THINKING_LEVEL_SET = new Set<string>(['off', 'minimal', 'low', 'medium', 'high', 'adaptive', 'xhigh']);
|
|
6
10
|
|
|
@@ -68,6 +72,14 @@ function readNcpSessionLabel(summary: NcpSessionSummaryView): string | null {
|
|
|
68
72
|
return readOptionalString(metadata.label);
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
function readNcpSessionProjectRoot(summary: NcpSessionSummaryView): string | null {
|
|
76
|
+
const metadata = readMetadata(summary);
|
|
77
|
+
if (!metadata) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return normalizeSessionProjectRootValue(metadata.project_root ?? metadata.projectRoot);
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
function readNcpSessionType(summary: NcpSessionSummaryView): string {
|
|
72
84
|
const metadata = readMetadata(summary);
|
|
73
85
|
if (!metadata) {
|
|
@@ -76,6 +88,30 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
|
|
|
76
88
|
return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
function readNcpParentSessionId(summary: NcpSessionSummaryView): string | null {
|
|
92
|
+
const metadata = readMetadata(summary);
|
|
93
|
+
if (!metadata) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return readOptionalString(metadata.parent_session_id) ?? readOptionalString(metadata.parentSessionId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readNcpSpawnedByRequestId(summary: NcpSessionSummaryView): string | null {
|
|
100
|
+
const metadata = readMetadata(summary);
|
|
101
|
+
if (!metadata) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return readOptionalString(metadata.spawned_by_request_id) ?? readOptionalString(metadata.spawnedByRequestId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readPromotedChildSession(summary: NcpSessionSummaryView): boolean {
|
|
108
|
+
const metadata = readMetadata(summary);
|
|
109
|
+
if (!metadata) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return metadata.child_session_promoted === true;
|
|
113
|
+
}
|
|
114
|
+
|
|
79
115
|
function parseSessionContext(sessionKey: string): { channel?: string; type?: string } {
|
|
80
116
|
if (sessionKey === 'heartbeat') {
|
|
81
117
|
return { type: 'heartbeat' };
|
|
@@ -207,7 +243,12 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
|
|
|
207
243
|
const label = readNcpSessionLabel(summary);
|
|
208
244
|
const preferredModel = readNcpSessionPreferredModel(summary);
|
|
209
245
|
const preferredThinking = readNcpSessionPreferredThinking(summary);
|
|
246
|
+
const projectRoot = readNcpSessionProjectRoot(summary);
|
|
247
|
+
const projectName = getSessionProjectName(projectRoot);
|
|
210
248
|
const context = parseSessionContext(summary.sessionId);
|
|
249
|
+
const parentSessionId = readNcpParentSessionId(summary);
|
|
250
|
+
const spawnedByRequestId = readNcpSpawnedByRequestId(summary);
|
|
251
|
+
const isPromotedChildSession = readPromotedChildSession(summary);
|
|
211
252
|
return {
|
|
212
253
|
key: summary.sessionId,
|
|
213
254
|
createdAt: summary.updatedAt,
|
|
@@ -216,8 +257,14 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
|
|
|
216
257
|
...context,
|
|
217
258
|
...(preferredModel ? { preferredModel } : {}),
|
|
218
259
|
...(preferredThinking ? { preferredThinking } : {}),
|
|
260
|
+
...(projectRoot ? { projectRoot } : {}),
|
|
261
|
+
...(projectName ? { projectName } : {}),
|
|
219
262
|
sessionType: readNcpSessionType(summary),
|
|
220
263
|
sessionTypeMutable: false,
|
|
264
|
+
isChildSession: Boolean(parentSessionId),
|
|
265
|
+
...(isPromotedChildSession ? { isPromotedChildSession } : {}),
|
|
266
|
+
...(parentSessionId ? { parentSessionId } : {}),
|
|
267
|
+
...(spawnedByRequestId ? { spawnedByRequestId } : {}),
|
|
221
268
|
messageCount: summary.messageCount
|
|
222
269
|
};
|
|
223
270
|
}
|
|
@@ -15,13 +15,22 @@ function filterSessionsByQuery(sessions: readonly SessionEntryView[], query: str
|
|
|
15
15
|
return sessions.filter((session) => sessionMatchesQuery(session, query));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function shouldShowSessionInSidebar(session: SessionEntryView): boolean {
|
|
19
|
+
if (!session.isChildSession) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return session.isPromotedChildSession === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export function useNcpSessionListView(params: { limit?: number } = {}) {
|
|
19
26
|
const query = useChatSessionListStore((state) => state.snapshot.query);
|
|
20
27
|
const sessionsQuery = useNcpSessions({ limit: params.limit ?? 200 });
|
|
21
28
|
|
|
22
29
|
const items = useMemo<NcpSessionListItemView[]>(() => {
|
|
23
30
|
const summaries = sessionsQuery.data?.sessions ?? [];
|
|
24
|
-
const sessions = adaptNcpSessionSummaries(summaries)
|
|
31
|
+
const sessions = adaptNcpSessionSummaries(summaries).filter(
|
|
32
|
+
shouldShowSessionInSidebar,
|
|
33
|
+
);
|
|
25
34
|
const filteredSessions = filterSessionsByQuery(sessions, query);
|
|
26
35
|
const summaryBySessionId = new Map(summaries.map((summary) => [summary.sessionId, summary]));
|
|
27
36
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
1
|
+
import type { ChatComposerNode, ChatToolActionViewModel } from '@nextclaw/agent-chat-ui';
|
|
2
2
|
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
3
3
|
import { createContext, useContext } from 'react';
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
@@ -37,6 +37,9 @@ export type ChatThreadManagerLike = {
|
|
|
37
37
|
deleteSession: () => void;
|
|
38
38
|
createSession: () => void;
|
|
39
39
|
goToProviders: () => void;
|
|
40
|
+
openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
|
|
41
|
+
closeChildSessionDetail: () => void;
|
|
42
|
+
goToParentSession: () => void;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
export type ChatPresenterLike = {
|
|
@@ -0,0 +1,63 @@
|
|
|
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 { ChatSessionHeaderActions } from '@/components/chat/session-header/chat-session-header-actions';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
updateSessionProject: vi.fn(),
|
|
8
|
+
onDeleteSession: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@/components/chat/hooks/use-chat-session-project', () => ({
|
|
12
|
+
useChatSessionProject: () => mocks.updateSessionProject,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@/components/chat/session-header/chat-session-project-dialog', () => ({
|
|
16
|
+
ChatSessionProjectDialog: () => null,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('ChatSessionHeaderActions', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mocks.updateSessionProject.mockReset();
|
|
22
|
+
mocks.onDeleteSession.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('keeps only the set-project action in the more-actions menu when a project is already attached', async () => {
|
|
26
|
+
const user = userEvent.setup();
|
|
27
|
+
|
|
28
|
+
render(
|
|
29
|
+
<ChatSessionHeaderActions
|
|
30
|
+
sessionKey="session-1"
|
|
31
|
+
canDeleteSession
|
|
32
|
+
isDeletePending={false}
|
|
33
|
+
projectRoot="/tmp/project-alpha"
|
|
34
|
+
onDeleteSession={mocks.onDeleteSession}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await user.click(screen.getByRole('button', { name: 'More actions' }));
|
|
39
|
+
|
|
40
|
+
expect(screen.getByText('Set Project Directory')).toBeTruthy();
|
|
41
|
+
expect(screen.queryByText('Clear Project Directory')).toBeNull();
|
|
42
|
+
expect(screen.getByText('Delete Session')).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps the set-project entry in the more-actions menu when no project is attached', async () => {
|
|
46
|
+
const user = userEvent.setup();
|
|
47
|
+
|
|
48
|
+
render(
|
|
49
|
+
<ChatSessionHeaderActions
|
|
50
|
+
sessionKey="draft-session"
|
|
51
|
+
canDeleteSession={false}
|
|
52
|
+
isDeletePending={false}
|
|
53
|
+
projectRoot={null}
|
|
54
|
+
onDeleteSession={mocks.onDeleteSession}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await user.click(screen.getByRole('button', { name: 'More actions' }));
|
|
59
|
+
|
|
60
|
+
expect(screen.getByText('Set Project Directory')).toBeTruthy();
|
|
61
|
+
expect(screen.queryByText('Clear Project Directory')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -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 {
|
|
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:
|
|
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,
|