@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
@@ -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 { 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,