@nextclaw/ui 0.11.21 → 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 (120) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{ChannelsList-ByHWHkQS.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-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-BfaTTqN6.js +1 -0
  9. package/dist/assets/{MarketplacePage-CmhsZXr1.js → MarketplacePage-Cd4faegU.js} +2 -2
  10. package/dist/assets/{McpMarketplacePage-C7PkCYbp.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-Dm7r2yfp.js → SearchConfig-BT13qpR_.js} +1 -1
  16. package/dist/assets/{SecretsConfig-BBP_mbQh.js → SecretsConfig-CvqEVn0B.js} +2 -2
  17. package/dist/assets/{SessionsConfig-6wNJloZN.js → SessionsConfig-DHHcYznk.js} +2 -2
  18. package/dist/assets/{book-open-B26jGBjY.js → book-open-CXoF5nQC.js} +1 -1
  19. package/dist/assets/chat-session-display-VW6ZMvZP.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-BlH4-cBw.css +1 -0
  29. package/dist/assets/{index-DvKS3L9j.js → index-C6d0xmtm.js} +3 -3
  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-T5zpg16O.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-Bs5Ll17m.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 +3 -3
  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 +13 -9
  64. package/src/components/chat/adapters/chat-message.adapter.test.ts +76 -4
  65. package/src/components/chat/adapters/{chat-message.file-operation-card.ts → file-operation/card.ts} +74 -181
  66. package/src/components/chat/adapters/{chat-message.file-operation-diff.ts → file-operation/diff.ts} +178 -188
  67. package/src/components/chat/adapters/file-operation/line-builder.ts +249 -0
  68. package/src/components/chat/adapters/file-operation/record-readers.ts +233 -0
  69. package/src/components/chat/chat-composer-state.ts +3 -3
  70. package/src/components/chat/chat-session-display.test.ts +21 -0
  71. package/src/components/chat/chat-session-display.ts +6 -1
  72. package/src/components/chat/containers/chat-input-bar.container.tsx +21 -24
  73. package/src/components/chat/hooks/use-chat-session-label.ts +19 -0
  74. package/src/components/chat/hooks/use-chat-session-project.test.tsx +117 -0
  75. package/src/components/chat/hooks/use-chat-session-project.ts +40 -0
  76. package/src/components/chat/{chat-session-label.service.ts → hooks/use-chat-session-update.ts} +11 -7
  77. package/src/components/chat/managers/chat-session-list.manager.ts +5 -1
  78. package/src/components/chat/ncp/NcpChatPage.tsx +55 -17
  79. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +33 -0
  80. package/src/components/chat/ncp/ncp-chat-page-data.ts +21 -15
  81. package/src/components/chat/ncp/ncp-session-adapter.test.ts +3 -0
  82. package/src/components/chat/ncp/ncp-session-adapter.ts +16 -0
  83. package/src/components/chat/session-header/chat-session-header-actions.test.tsx +63 -0
  84. package/src/components/chat/session-header/chat-session-header-actions.tsx +95 -0
  85. package/src/components/chat/session-header/chat-session-header-menu-item.tsx +35 -0
  86. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +66 -0
  87. package/src/components/chat/session-header/chat-session-project-badge.tsx +102 -0
  88. package/src/components/chat/session-header/chat-session-project-dialog.tsx +34 -0
  89. package/src/components/chat/stores/chat-input.store.ts +6 -3
  90. package/src/components/chat/stores/chat-thread.store.ts +6 -2
  91. package/src/components/path-picker/server-path-picker-dialog.test.tsx +92 -0
  92. package/src/components/path-picker/server-path-picker-dialog.tsx +282 -0
  93. package/src/hooks/server-path/use-server-path-browse.ts +19 -0
  94. package/src/hooks/useConfig.ts +26 -1
  95. package/src/lib/i18n/i18n-language-owner.ts +94 -0
  96. package/src/lib/i18n/i18n.path-picker.ts +12 -0
  97. package/src/lib/i18n.chat.ts +23 -0
  98. package/src/lib/i18n.ts +21 -84
  99. package/src/lib/session-project/session-project.utils.ts +30 -0
  100. package/dist/assets/ChatPage-FdT3pDnw.js +0 -42
  101. package/dist/assets/DocBrowser-CMdPdbZj.js +0 -1
  102. package/dist/assets/MarketplacePage-9oKmxN2n.js +0 -1
  103. package/dist/assets/ModelConfig-DmCY6jWM.js +0 -1
  104. package/dist/assets/ProvidersList-ClT-34aX.js +0 -1
  105. package/dist/assets/RemoteAccessPage-B6hUZl1O.js +0 -1
  106. package/dist/assets/RuntimeConfig-C5aqliGk.js +0 -1
  107. package/dist/assets/chat-session-display-Bjmn4aIZ.js +0 -1
  108. package/dist/assets/i18n-CSytxMFI.js +0 -1
  109. package/dist/assets/index-CUy6doWo.css +0 -1
  110. package/dist/assets/loader-circle-B2J777gj.js +0 -1
  111. package/dist/assets/plus-CM9XJ0Tf.js +0 -1
  112. package/dist/assets/provider-models-C8JQUd1E.js +0 -1
  113. package/dist/assets/search-Ctaw34Kp.js +0 -1
  114. package/dist/assets/skeleton-Bycyb0zU.js +0 -1
  115. package/dist/assets/tabs-custom-TZQ5WPWP.js +0 -1
  116. package/dist/assets/useConfirmDialog-BDpdjfIO.js +0 -1
  117. package/dist/assets/x-CHOBE-63.js +0 -1
  118. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-WtpHP_DW.js} +0 -0
  119. /package/dist/assets/{config-layout-B-7erZRN.js → config-layout-LQ10ozRC.js} +0 -0
  120. /package/dist/assets/{marketplace-localization-CXeGRf6E.js → marketplace-localization-CxSTG9wr.js} +0 -0
@@ -0,0 +1,117 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { toast } from 'sonner';
4
+ import { useChatSessionProject } from '@/components/chat/hooks/use-chat-session-project';
5
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ updateSession: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('sonner', () => ({
12
+ toast: {
13
+ success: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ vi.mock('@/components/chat/hooks/use-chat-session-update', () => ({
18
+ useChatSessionUpdate: () => mocks.updateSession,
19
+ }));
20
+
21
+ describe('useChatSessionProject', () => {
22
+ beforeEach(() => {
23
+ useChatInputStore.setState((state) => ({
24
+ snapshot: {
25
+ ...state.snapshot,
26
+ pendingProjectRoot: null,
27
+ pendingProjectRootSessionKey: null,
28
+ },
29
+ }));
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ it('stores the draft project root locally when the session does not exist yet', async () => {
37
+ const { result } = renderHook(() => useChatSessionProject());
38
+
39
+ await act(async () => {
40
+ await result.current({
41
+ sessionKey: 'draft-session-1',
42
+ projectRoot: '/tmp/project-alpha',
43
+ persistToServer: false,
44
+ });
45
+ });
46
+
47
+ expect(mocks.updateSession).not.toHaveBeenCalled();
48
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
49
+ pendingProjectRoot: '/tmp/project-alpha',
50
+ pendingProjectRootSessionKey: 'draft-session-1',
51
+ });
52
+ expect(toast.success).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it('keeps an explicit draft override when clearing the project root locally', async () => {
56
+ const { result } = renderHook(() => useChatSessionProject());
57
+
58
+ await act(async () => {
59
+ await result.current({
60
+ sessionKey: 'draft-session-1',
61
+ projectRoot: null,
62
+ persistToServer: false,
63
+ });
64
+ });
65
+
66
+ expect(mocks.updateSession).not.toHaveBeenCalled();
67
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
68
+ pendingProjectRoot: null,
69
+ pendingProjectRootSessionKey: 'draft-session-1',
70
+ });
71
+ expect(toast.success).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('persists to the server and mirrors the updated project override locally for an existing session', async () => {
75
+ const { result } = renderHook(() => useChatSessionProject());
76
+
77
+ await act(async () => {
78
+ await result.current({
79
+ sessionKey: 'session-1',
80
+ projectRoot: '/tmp/project-beta',
81
+ persistToServer: true,
82
+ });
83
+ });
84
+
85
+ expect(mocks.updateSession).toHaveBeenCalledWith({
86
+ sessionKey: 'session-1',
87
+ patch: { projectRoot: '/tmp/project-beta' },
88
+ successMessage: 'Project directory updated',
89
+ });
90
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
91
+ pendingProjectRoot: '/tmp/project-beta',
92
+ pendingProjectRootSessionKey: 'session-1',
93
+ });
94
+ });
95
+
96
+ it('persists clearing to the server and keeps the cleared override until session state catches up', async () => {
97
+ const { result } = renderHook(() => useChatSessionProject());
98
+
99
+ await act(async () => {
100
+ await result.current({
101
+ sessionKey: 'session-1',
102
+ projectRoot: null,
103
+ persistToServer: true,
104
+ });
105
+ });
106
+
107
+ expect(mocks.updateSession).toHaveBeenCalledWith({
108
+ sessionKey: 'session-1',
109
+ patch: { projectRoot: null },
110
+ successMessage: 'Project directory cleared',
111
+ });
112
+ expect(useChatInputStore.getState().snapshot).toMatchObject({
113
+ pendingProjectRoot: null,
114
+ pendingProjectRootSessionKey: 'session-1',
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,40 @@
1
+ import { toast } from 'sonner';
2
+ import { t } from '@/lib/i18n';
3
+ import { useChatSessionUpdate } from '@/components/chat/hooks/use-chat-session-update';
4
+ import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
5
+
6
+ type UpdateChatSessionProjectParams = {
7
+ sessionKey: string;
8
+ projectRoot: string | null;
9
+ persistToServer: boolean;
10
+ };
11
+
12
+ export function useChatSessionProject() {
13
+ const updateSession = useChatSessionUpdate();
14
+
15
+ return async (params: UpdateChatSessionProjectParams): Promise<void> => {
16
+ const successMessage = params.projectRoot
17
+ ? t('chatSessionProjectUpdated')
18
+ : t('chatSessionProjectCleared');
19
+
20
+ if (!params.persistToServer) {
21
+ useChatInputStore.getState().setSnapshot({
22
+ pendingProjectRoot: params.projectRoot,
23
+ pendingProjectRootSessionKey: params.sessionKey
24
+ });
25
+ toast.success(successMessage);
26
+ return;
27
+ }
28
+
29
+ await updateSession({
30
+ sessionKey: params.sessionKey,
31
+ patch: { projectRoot: params.projectRoot },
32
+ successMessage,
33
+ });
34
+
35
+ useChatInputStore.getState().setSnapshot({
36
+ pendingProjectRoot: params.projectRoot,
37
+ pendingProjectRootSessionKey: params.sessionKey,
38
+ });
39
+ };
40
+ }
@@ -1,24 +1,28 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from 'sonner';
3
+ import type { SessionPatchUpdate } from '@/api/types';
3
4
  import { upsertNcpSessionSummaryInQueryClient } from '@/api/ncp-session-query-cache';
4
5
  import { updateNcpSession } from '@/api/ncp-session';
5
6
  import { t } from '@/lib/i18n';
6
7
 
7
- type UpdateChatSessionLabelParams = {
8
+ type UpdateChatSessionParams = {
8
9
  sessionKey: string;
9
- label: string | null;
10
+ patch: SessionPatchUpdate;
11
+ successMessage?: string;
10
12
  };
11
13
 
12
- export function useChatSessionLabelService() {
14
+ export function useChatSessionUpdate() {
13
15
  const queryClient = useQueryClient();
14
16
 
15
- return async (params: UpdateChatSessionLabelParams): Promise<void> => {
17
+ return async (params: UpdateChatSessionParams): Promise<void> => {
16
18
  try {
17
- const updated = await updateNcpSession(params.sessionKey, { label: params.label });
19
+ const updated = await updateNcpSession(params.sessionKey, params.patch);
18
20
  upsertNcpSessionSummaryInQueryClient(queryClient, updated);
19
- toast.success(t('configSavedApplied'));
21
+ toast.success(params.successMessage ?? t('configSavedApplied'));
20
22
  } catch (error) {
21
- toast.error(t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)));
23
+ toast.error(
24
+ t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)),
25
+ );
22
26
  throw error;
23
27
  }
24
28
  };
@@ -44,7 +44,11 @@ export class ChatSessionListManager {
44
44
  ? sessionType.trim()
45
45
  : defaultSessionType;
46
46
  this.streamActionsManager.resetStreamState();
47
- useChatInputStore.getState().setSnapshot({ pendingSessionType: nextSessionType });
47
+ useChatInputStore.getState().setSnapshot({
48
+ pendingSessionType: nextSessionType,
49
+ pendingProjectRoot: null,
50
+ pendingProjectRootSessionKey: null
51
+ });
48
52
  this.uiManager.goToChatRoot();
49
53
  };
50
54
 
@@ -23,12 +23,14 @@ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-l
23
23
  import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
24
24
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
25
25
  import { normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
26
+ import { getSessionProjectName, normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
26
27
  import { appClient } from '@/transport';
27
28
 
28
- function buildNcpSendMetadata(payload: {
29
+ export function buildNcpSendMetadata(payload: {
29
30
  model?: string;
30
31
  thinkingLevel?: string;
31
32
  sessionType?: string;
33
+ projectRoot?: string | null;
32
34
  requestedSkills?: string[];
33
35
  composerNodes?: Parameters<typeof buildInlineSkillTokensFromComposer>[0];
34
36
  }): Record<string, unknown> {
@@ -44,9 +46,13 @@ function buildNcpSendMetadata(payload: {
44
46
  if (payload.sessionType?.trim()) {
45
47
  metadata.session_type = payload.sessionType.trim();
46
48
  }
49
+ const projectRoot = normalizeSessionProjectRootValue(payload.projectRoot);
50
+ if (projectRoot) {
51
+ metadata.project_root = projectRoot;
52
+ }
47
53
  const requestedSkills = normalizeRequestedSkills(payload.requestedSkills);
48
54
  if (requestedSkills.length > 0) {
49
- metadata.requested_skills = requestedSkills;
55
+ metadata.requested_skill_refs = requestedSkills;
50
56
  }
51
57
  const inlineSkillTokens = payload.composerNodes
52
58
  ? buildInlineSkillTokensFromComposer(payload.composerNodes)
@@ -71,6 +77,8 @@ export function NcpChatPage({ view }: ChatPageProps) {
71
77
  const selectedSessionKey = useChatSessionListStore((state) => state.snapshot.selectedSessionKey);
72
78
  const selectedAgentId = useChatSessionListStore((state) => state.snapshot.selectedAgentId);
73
79
  const pendingSessionType = useChatInputStore((state) => state.snapshot.pendingSessionType);
80
+ const pendingProjectRoot = useChatInputStore((state) => state.snapshot.pendingProjectRoot);
81
+ const pendingProjectRootSessionKey = useChatInputStore((state) => state.snapshot.pendingProjectRootSessionKey);
74
82
  const currentSelectedModel = useChatInputStore((state) => state.snapshot.selectedModel);
75
83
  const { confirm, ConfirmDialog } = useConfirmDialog();
76
84
  const location = useLocation();
@@ -83,8 +91,11 @@ export function NcpChatPage({ view }: ChatPageProps) {
83
91
  () => parseSessionKeyFromRoute(routeSessionIdParam),
84
92
  [routeSessionIdParam]
85
93
  );
94
+ const sessionKey = selectedSessionKey ?? draftSessionId;
95
+ const hasSessionProjectRootOverride = pendingProjectRootSessionKey === sessionKey;
96
+ const sessionProjectRootOverride = hasSessionProjectRootOverride ? pendingProjectRoot : undefined;
86
97
  const {
87
- installedSkillsQuery,
98
+ sessionSkillsQuery,
88
99
  isProviderStateResolved,
89
100
  modelOptions,
90
101
  sessionSummaries,
@@ -98,7 +109,8 @@ export function NcpChatPage({ view }: ChatPageProps) {
98
109
  sessionTypeUnavailableMessage
99
110
  } = useNcpChatPageData({
100
111
  query,
101
- selectedSessionKey,
112
+ sessionKey,
113
+ projectRootOverride: sessionProjectRootOverride,
102
114
  currentSelectedModel,
103
115
  pendingSessionType,
104
116
  setPendingSessionType: presenter.chatInputManager.setPendingSessionType,
@@ -106,7 +118,6 @@ export function NcpChatPage({ view }: ChatPageProps) {
106
118
  setSelectedThinkingLevel: presenter.chatInputManager.setSelectedThinkingLevel
107
119
  });
108
120
 
109
- const activeSessionId = selectedSessionKey ?? draftSessionId;
110
121
  const sessionSummariesRef = useRef(sessionSummaries);
111
122
  useEffect(() => {
112
123
  sessionSummariesRef.current = sessionSummaries;
@@ -141,7 +152,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
141
152
  }, []);
142
153
 
143
154
  const agent = useHydratedNcpAgent({
144
- sessionId: activeSessionId,
155
+ sessionId: sessionKey,
145
156
  client: ncpClient,
146
157
  loadSeed
147
158
  });
@@ -158,6 +169,9 @@ export function NcpChatPage({ view }: ChatPageProps) {
158
169
  }
159
170
  }, [presenter, selectedSessionKey]);
160
171
 
172
+ const effectiveSessionProjectRoot = hasSessionProjectRootOverride ? pendingProjectRoot : selectedSession?.projectRoot ?? null;
173
+ const effectiveSessionProjectName = hasSessionProjectRootOverride ? getSessionProjectName(effectiveSessionProjectRoot) : selectedSession?.projectName ?? getSessionProjectName(effectiveSessionProjectRoot);
174
+
161
175
  const isSending = agent.isSending || agent.isRunning;
162
176
  const isAwaitingAssistantOutput = agent.isRunning;
163
177
  const canStopCurrentRun = agent.isRunning;
@@ -175,7 +189,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
175
189
 
176
190
  sessionStreamAttachInFlightRef.current = true;
177
191
  void ncpClient
178
- .stream({ sessionId: activeSessionId })
192
+ .stream({ sessionId: sessionKey })
179
193
  .catch(() => undefined)
180
194
  .finally(() => {
181
195
  sessionStreamAttachInFlightRef.current = false;
@@ -185,24 +199,28 @@ export function NcpChatPage({ view }: ChatPageProps) {
185
199
  return appClient.subscribe((event) => {
186
200
  if (
187
201
  event.type === 'session.run-status' &&
188
- event.payload.sessionKey === activeSessionId &&
202
+ event.payload.sessionKey === sessionKey &&
189
203
  event.payload.status === 'running'
190
204
  ) {
191
205
  attachRealtimeSessionStream();
192
206
  }
193
207
  });
194
- }, [activeSessionId, agent.isHydrating, agent.isRunning, agent.isSending, ncpClient]);
208
+ }, [agent.isHydrating, agent.isRunning, agent.isSending, ncpClient, sessionKey]);
195
209
 
196
210
  useEffect(() => {
197
211
  presenter.chatStreamActionsManager.bind({
198
212
  sendMessage: async (payload) => {
199
- if (payload.sessionKey !== activeSessionId) {
213
+ if (payload.sessionKey !== sessionKey) {
200
214
  return;
201
215
  }
202
216
  const metadata = buildNcpSendMetadata({
203
217
  model: payload.model,
204
218
  thinkingLevel: payload.thinkingLevel,
205
219
  sessionType: payload.sessionType,
220
+ projectRoot:
221
+ payload.sessionKey === pendingProjectRootSessionKey
222
+ ? pendingProjectRoot
223
+ : selectedSession?.projectRoot ?? null,
206
224
  requestedSkills: payload.requestedSkills,
207
225
  composerNodes: payload.composerNodes
208
226
  });
@@ -238,7 +256,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
238
256
  await agent.abort();
239
257
  },
240
258
  resumeRun: async (run: ResumeRunParams) => {
241
- if (run.sessionKey !== activeSessionId) {
259
+ if (run.sessionKey !== sessionKey) {
242
260
  return;
243
261
  }
244
262
  await agent.streamRun();
@@ -248,7 +266,24 @@ export function NcpChatPage({ view }: ChatPageProps) {
248
266
  },
249
267
  applyHistoryMessages: () => {}
250
268
  });
251
- }, [activeSessionId, agent, presenter]);
269
+ }, [
270
+ agent,
271
+ pendingProjectRoot,
272
+ pendingProjectRootSessionKey,
273
+ presenter,
274
+ selectedSession?.projectRoot,
275
+ sessionKey
276
+ ]);
277
+
278
+ useEffect(() => {
279
+ if (!selectedSession || pendingProjectRootSessionKey !== selectedSession.key || (selectedSession.projectRoot ?? null) !== pendingProjectRoot) {
280
+ return;
281
+ }
282
+ useChatInputStore.getState().setSnapshot({
283
+ pendingProjectRoot: null,
284
+ pendingProjectRootSessionKey: null
285
+ });
286
+ }, [pendingProjectRoot, pendingProjectRootSessionKey, selectedSession]);
252
287
 
253
288
  useChatSessionSync({
254
289
  view,
@@ -293,7 +328,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
293
328
  canEditSessionType,
294
329
  sessionTypeUnavailable,
295
330
  skillRecords,
296
- isSkillsLoading: installedSkillsQuery.isLoading
331
+ isSkillsLoading: sessionSkillsQuery.isLoading
297
332
  });
298
333
  presenter.chatThreadManager.syncSnapshot({
299
334
  isProviderStateResolved,
@@ -301,8 +336,10 @@ export function NcpChatPage({ view }: ChatPageProps) {
301
336
  sessionTypeUnavailable,
302
337
  sessionTypeUnavailableMessage,
303
338
  sessionTypeLabel: currentSessionTypeLabel,
304
- selectedSessionKey,
339
+ sessionKey,
305
340
  sessionDisplayName: currentSessionDisplayName,
341
+ sessionProjectRoot: effectiveSessionProjectRoot,
342
+ sessionProjectName: effectiveSessionProjectName,
306
343
  canDeleteSession: Boolean(selectedSession),
307
344
  threadRef,
308
345
  isHistoryLoading: agent.isHydrating,
@@ -317,16 +354,17 @@ export function NcpChatPage({ view }: ChatPageProps) {
317
354
  currentSessionDisplayName,
318
355
  currentSessionTypeLabel,
319
356
  defaultSessionType,
320
- installedSkillsQuery.isLoading,
357
+ sessionSkillsQuery.isLoading,
321
358
  isAwaitingAssistantOutput,
322
359
  isProviderStateResolved,
323
360
  isSending,
324
361
  lastSendError,
325
- modelOptions.length,
326
362
  modelOptions,
327
363
  presenter,
364
+ effectiveSessionProjectName,
365
+ effectiveSessionProjectRoot,
328
366
  selectedSession,
329
- selectedSessionKey,
367
+ sessionKey,
330
368
  selectedSessionType,
331
369
  sessionTypeOptions,
332
370
  sessionTypeUnavailable,
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { buildNcpSendMetadata } from '@/components/chat/ncp/NcpChatPage';
2
3
  import { filterModelOptionsBySessionType } from '@/components/chat/ncp/ncp-chat-page-data';
3
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
4
5
 
@@ -42,3 +43,35 @@ describe('filterModelOptionsBySessionType', () => {
42
43
  ).toEqual(modelOptions);
43
44
  });
44
45
  });
46
+
47
+ describe('buildNcpSendMetadata', () => {
48
+ it('includes the project root in the first-message metadata when present', () => {
49
+ expect(
50
+ buildNcpSendMetadata({
51
+ sessionType: 'codex',
52
+ projectRoot: ' /tmp/project-alpha ',
53
+ }),
54
+ ).toMatchObject({
55
+ session_type: 'codex',
56
+ project_root: '/tmp/project-alpha',
57
+ });
58
+ });
59
+
60
+ it('omits project_root when the input is blank', () => {
61
+ expect(
62
+ buildNcpSendMetadata({
63
+ projectRoot: ' ',
64
+ }),
65
+ ).not.toHaveProperty('project_root');
66
+ });
67
+
68
+ it('sends requested skill refs instead of legacy requested skill names', () => {
69
+ expect(
70
+ buildNcpSendMetadata({
71
+ requestedSkills: ['project:/tmp/project-alpha/.agents/skills/review'],
72
+ }),
73
+ ).toMatchObject({
74
+ requested_skill_refs: ['project:/tmp/project-alpha/.agents/skills/review'],
75
+ });
76
+ });
77
+ });
@@ -14,15 +14,16 @@ import {
14
14
  import {
15
15
  useConfig,
16
16
  useConfigMeta,
17
+ useNcpSessionSkills,
17
18
  useNcpSessions
18
19
  } from '@/hooks/useConfig';
19
20
  import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
20
- import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
21
21
  import { buildProviderModelCatalog, composeProviderModel, resolveModelThinkingCapability } from '@/lib/provider-models';
22
22
 
23
23
  type UseNcpChatPageDataParams = {
24
24
  query: string;
25
- selectedSessionKey: string | null;
25
+ sessionKey: string;
26
+ projectRootOverride?: string | null;
26
27
  currentSelectedModel: string;
27
28
  pendingSessionType: string;
28
29
  setPendingSessionType: Dispatch<SetStateAction<string>>;
@@ -51,7 +52,12 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
51
52
  const configMetaQuery = useConfigMeta();
52
53
  const sessionsQuery = useNcpSessions({ limit: 200 });
53
54
  const sessionTypesQuery = useNcpChatSessionTypes();
54
- const installedSkillsQuery = useMarketplaceInstalled('skill');
55
+ const sessionSkillsQuery = useNcpSessionSkills({
56
+ sessionId: params.sessionKey,
57
+ ...(Object.prototype.hasOwnProperty.call(params, 'projectRootOverride')
58
+ ? { projectRoot: params.projectRootOverride ?? null }
59
+ : {})
60
+ });
55
61
  const isProviderStateResolved =
56
62
  (configQuery.isFetched || configQuery.isSuccess) &&
57
63
  (configMetaQuery.isFetched || configMetaQuery.isSuccess);
@@ -101,16 +107,16 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
101
107
  [allSessions, params.query]
102
108
  );
103
109
  const selectedSession = useMemo(
104
- () => allSessions.find((session) => session.key === params.selectedSessionKey) ?? null,
105
- [allSessions, params.selectedSessionKey]
110
+ () => allSessions.find((session) => session.key === params.sessionKey) ?? null,
111
+ [allSessions, params.sessionKey]
106
112
  );
107
113
  const skillRecords = useMemo(
108
- () => installedSkillsQuery.data?.records ?? [],
109
- [installedSkillsQuery.data?.records]
114
+ () => sessionSkillsQuery.data?.records ?? [],
115
+ [sessionSkillsQuery.data?.records]
110
116
  );
111
117
  const sessionTypeState = useChatSessionTypeState({
112
118
  selectedSession,
113
- selectedSessionKey: params.selectedSessionKey,
119
+ selectedSessionKey: params.sessionKey,
114
120
  pendingSessionType: params.pendingSessionType,
115
121
  setPendingSessionType: params.setPendingSessionType,
116
122
  sessionTypesData: sessionTypesQuery.data
@@ -127,10 +133,10 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
127
133
  () =>
128
134
  resolveRecentSessionPreferredModel({
129
135
  sessions: allSessions,
130
- selectedSessionKey: params.selectedSessionKey,
136
+ selectedSessionKey: params.sessionKey,
131
137
  sessionType: sessionTypeState.selectedSessionType
132
138
  }),
133
- [allSessions, params.selectedSessionKey, sessionTypeState.selectedSessionType]
139
+ [allSessions, params.sessionKey, sessionTypeState.selectedSessionType]
134
140
  );
135
141
  const currentModelOption = useMemo(
136
142
  () => filteredModelOptions.find((option) => option.value === params.currentSelectedModel),
@@ -148,15 +154,15 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
148
154
  () =>
149
155
  resolveRecentSessionPreferredThinking({
150
156
  sessions: allSessions,
151
- selectedSessionKey: params.selectedSessionKey,
157
+ selectedSessionKey: params.sessionKey,
152
158
  sessionType: sessionTypeState.selectedSessionType
153
159
  }),
154
- [allSessions, params.selectedSessionKey, sessionTypeState.selectedSessionType]
160
+ [allSessions, params.sessionKey, sessionTypeState.selectedSessionType]
155
161
  );
156
162
 
157
163
  useSyncSelectedModel({
158
164
  modelOptions: filteredModelOptions,
159
- selectedSessionKey: params.selectedSessionKey,
165
+ selectedSessionKey: params.sessionKey,
160
166
  selectedSessionExists: Boolean(selectedSession),
161
167
  selectedSessionPreferredModel: selectedSession?.preferredModel,
162
168
  fallbackPreferredModel: sessionTypeState.selectedSessionTypeOption?.recommendedModel ?? recentSessionPreferredModel,
@@ -165,7 +171,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
165
171
  });
166
172
  useSyncSelectedThinking({
167
173
  supportedThinkingLevels,
168
- selectedSessionKey: params.selectedSessionKey,
174
+ selectedSessionKey: params.sessionKey,
169
175
  selectedSessionExists: Boolean(selectedSession),
170
176
  selectedSessionPreferredThinking: selectedSession?.preferredThinking ?? null,
171
177
  fallbackPreferredThinking: recentSessionPreferredThinking ?? null,
@@ -178,7 +184,7 @@ export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
178
184
  configMetaQuery,
179
185
  sessionsQuery,
180
186
  sessionTypesQuery,
181
- installedSkillsQuery,
187
+ sessionSkillsQuery,
182
188
  isProviderStateResolved,
183
189
  modelOptions: filteredModelOptions,
184
190
  sessionSummaries,
@@ -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,6 +35,8 @@ 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,
39
42
  messageCount: 3
@@ -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) {
@@ -207,6 +219,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
207
219
  const label = readNcpSessionLabel(summary);
208
220
  const preferredModel = readNcpSessionPreferredModel(summary);
209
221
  const preferredThinking = readNcpSessionPreferredThinking(summary);
222
+ const projectRoot = readNcpSessionProjectRoot(summary);
223
+ const projectName = getSessionProjectName(projectRoot);
210
224
  const context = parseSessionContext(summary.sessionId);
211
225
  return {
212
226
  key: summary.sessionId,
@@ -216,6 +230,8 @@ export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionE
216
230
  ...context,
217
231
  ...(preferredModel ? { preferredModel } : {}),
218
232
  ...(preferredThinking ? { preferredThinking } : {}),
233
+ ...(projectRoot ? { projectRoot } : {}),
234
+ ...(projectName ? { projectName } : {}),
219
235
  sessionType: readNcpSessionType(summary),
220
236
  sessionTypeMutable: false,
221
237
  messageCount: summary.messageCount
@@ -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
+ });