@nextclaw/ui 0.12.3 → 0.12.5

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 (123) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/assets/{ChannelsList-DZWam3Ob.js → ChannelsList-C6-lh55g.js} +2 -2
  3. package/dist/assets/ChatPage-DOW0gPc2.js +45 -0
  4. package/dist/assets/DocBrowser-CGyeswYP.js +1 -0
  5. package/dist/assets/{DocBrowser-C7-1sXqo.js → DocBrowser-QUZ3nfmH.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-DN5tjUoS.js → DocBrowserContext-CpiIfhJO.js} +1 -1
  7. package/dist/assets/{LogoBadge-DDS1sU_U.js → LogoBadge-BUK13xK5.js} +1 -1
  8. package/dist/assets/MarketplacePage-BDVwhIYE.js +1 -0
  9. package/dist/assets/MarketplacePage-LnKKL3xK.js +49 -0
  10. package/dist/assets/McpMarketplacePage-BG4T_Pcx.js +40 -0
  11. package/dist/assets/ModelConfig-LtWuogIw.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-DGn6sFEN.js +1 -0
  13. package/dist/assets/ProvidersList-ma-_MlLo.js +1 -0
  14. package/dist/assets/{RemoteAccessPage-COnjm8_x.js → RemoteAccessPage-ff15qO-c.js} +1 -1
  15. package/dist/assets/RuntimeConfig-TgPandXF.js +1 -0
  16. package/dist/assets/SearchConfig-C9iBt7pl.js +1 -0
  17. package/dist/assets/{SecretsConfig-Cefg1LFJ.js → SecretsConfig-Bew4EF2A.js} +2 -2
  18. package/dist/assets/{SessionsConfig-BZnmVTIu.js → SessionsConfig-2r2yAGZg.js} +2 -2
  19. package/dist/assets/{book-open-DvWqOode.js → book-open-CJG8Yz3U.js} +1 -1
  20. package/dist/assets/{chat-session-display-D4bYa0b8.js → chat-session-display-DkAC5OMC.js} +1 -1
  21. package/dist/assets/{chunk-JZWAC4HX-CxfKRD7X.js → chunk-JZWAC4HX-D5b3Iyas.js} +1 -1
  22. package/dist/assets/{config-BeGwf2Ao.js → config-zvnxSXSP.js} +1 -1
  23. package/dist/assets/{createLucideIcon-C7MmdIX3.js → createLucideIcon-_FMJqZw2.js} +1 -1
  24. package/dist/assets/{dist-B6VMuIQN.js → dist-B1fpOuON.js} +1 -1
  25. package/dist/assets/{dist-RWNFhxvR.js → dist-BCXX7FD-.js} +2 -2
  26. package/dist/assets/{external-link-U86Acd1t.js → external-link-b7gAJWYY.js} +1 -1
  27. package/dist/assets/{hash-D-OVfV3Z.js → hash-Bhy4TwfZ.js} +1 -1
  28. package/dist/assets/i18n-DJg9BPYk.js +1 -0
  29. package/dist/assets/index-BoJbxdvZ.css +1 -0
  30. package/dist/assets/index-CtlT4E9Y.js +6 -0
  31. package/dist/assets/infiniteQueryBehavior-CTcVlD9s.js +1 -0
  32. package/dist/assets/loader-circle-B60I0hEk.js +1 -0
  33. package/dist/assets/{logos-U1_qDA3U.js → logos-GMeYU9vc.js} +1 -1
  34. package/dist/assets/{page-layout-Z1klaUFW.js → page-layout-C8UbWuMt.js} +1 -1
  35. package/dist/assets/plus-CR7RfK3H.js +1 -0
  36. package/dist/assets/{popover-xWbqMnIN.js → popover-8HSx9wQj.js} +1 -1
  37. package/dist/assets/react-BB4jko2M.js +1 -0
  38. package/dist/assets/{refresh-ccw-JQh1lwq-.js → refresh-ccw-CA4_C7Zg.js} +1 -1
  39. package/dist/assets/{save-4VRlzkii.js → save-BtvMy4lk.js} +1 -1
  40. package/dist/assets/search-C60UA27E.js +1 -0
  41. package/dist/assets/security-config-BkFDYZ6j.js +1 -0
  42. package/dist/assets/{select-DF-AUoie.js → select-xp_Ac8ip.js} +1 -1
  43. package/dist/assets/skeleton-uxz_5h3A.js +1 -0
  44. package/dist/assets/{status-dot-Bq_8Ojvv.js → status-dot-Cn4Pp7DZ.js} +1 -1
  45. package/dist/assets/{switch-D7JF_RZ-.js → switch-BTi6UOij.js} +1 -1
  46. package/dist/assets/{tabs-custom-CLksZ2bO.js → tabs-custom-BiiN8DME.js} +1 -1
  47. package/dist/assets/{trash-2-VV8jvziy.js → trash-2-BpsF0N-r.js} +1 -1
  48. package/dist/assets/use-infinite-scroll-loader-C8jBv11-.js +1 -0
  49. package/dist/assets/{useConfirmDialog-CuQqiPx7.js → useConfirmDialog-BJIwUZjH.js} +1 -1
  50. package/dist/assets/{useMutation-DBTWPbTg.js → useMutation-BjBOKHj_.js} +1 -1
  51. package/dist/assets/x-BfTu-g7D.js +1 -0
  52. package/dist/index.html +19 -18
  53. package/package.json +5 -5
  54. package/src/account/components/account-panel.tsx +46 -4
  55. package/src/account/managers/account.manager.ts +19 -4
  56. package/src/api/remote.ts +9 -0
  57. package/src/api/remote.types.ts +5 -0
  58. package/src/components/chat/ChatConversationPanel.test.tsx +183 -141
  59. package/src/components/chat/ChatSidebar.test.tsx +168 -28
  60. package/src/components/chat/ChatSidebar.tsx +103 -28
  61. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +11 -11
  62. package/src/components/chat/adapters/chat-message.adapter.test.ts +43 -6
  63. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +182 -44
  64. package/src/components/chat/adapters/chat-message.session-spawn-tool-card.test.ts +104 -0
  65. package/src/components/chat/chat-child-session-panel.tsx +103 -45
  66. package/src/components/chat/chat-page-runtime.test.ts +16 -19
  67. package/src/components/chat/chat-session-preference-sync.test.ts +13 -0
  68. package/src/components/chat/chat-session-preference-sync.ts +9 -7
  69. package/src/components/chat/chat-sidebar-list-mode-switch.tsx +43 -0
  70. package/src/components/chat/chat-sidebar-project-groups.tsx +152 -0
  71. package/src/components/chat/hooks/use-chat-session-project.test.tsx +5 -5
  72. package/src/components/chat/hooks/use-chat-session-project.ts +0 -5
  73. package/src/components/chat/hooks/use-chat-session-update.test.tsx +75 -0
  74. package/src/components/chat/hooks/use-chat-session-update.ts +4 -2
  75. package/src/components/chat/managers/chat-session-list.manager.test.ts +46 -6
  76. package/src/components/chat/managers/chat-session-list.manager.ts +19 -6
  77. package/src/components/chat/ncp/NcpChatPage.tsx +33 -38
  78. package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -5
  79. package/src/components/chat/ncp/ncp-chat-page-data.ts +0 -1
  80. package/src/components/chat/ncp/ncp-chat.presenter.ts +2 -16
  81. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +20 -7
  82. package/src/components/chat/session-header/chat-session-project-badge.test.tsx +16 -0
  83. package/src/components/chat/session-header/chat-session-project-badge.tsx +2 -2
  84. package/src/components/chat/stores/chat-session-list.store.ts +3 -0
  85. package/src/components/chat/useChatSessionTypeState.test.tsx +0 -3
  86. package/src/components/chat/useChatSessionTypeState.ts +3 -5
  87. package/src/components/config/ChannelsList.test.tsx +68 -0
  88. package/src/components/config/ChannelsList.tsx +22 -4
  89. package/src/components/config/ProvidersList.tsx +17 -3
  90. package/src/components/config/providers-list.test.tsx +68 -0
  91. package/src/components/layout/Sidebar.tsx +13 -13
  92. package/src/components/layout/sidebar.layout.test.tsx +32 -1
  93. package/src/components/marketplace/MarketplacePage.tsx +30 -30
  94. package/src/components/marketplace/marketplace-page-parts.tsx +16 -24
  95. package/src/components/marketplace/mcp/McpMarketplacePage.tsx +28 -26
  96. package/src/hooks/marketplace-list-pages.ts +27 -0
  97. package/src/hooks/use-infinite-scroll-loader.ts +88 -0
  98. package/src/hooks/useMarketplace.ts +14 -3
  99. package/src/hooks/useMcpMarketplace.ts +14 -3
  100. package/src/lib/i18n.chat.ts +3 -0
  101. package/src/lib/i18n.remote.ts +15 -0
  102. package/dist/assets/ChatPage-YBL7iJ1X.js +0 -43
  103. package/dist/assets/DocBrowser-DQjtSsY3.js +0 -1
  104. package/dist/assets/MarketplacePage-2tWWgwAb.js +0 -49
  105. package/dist/assets/MarketplacePage-BorWJftJ.js +0 -1
  106. package/dist/assets/McpMarketplacePage-N-fB4HID.js +0 -40
  107. package/dist/assets/ModelConfig-DvsBTUiE.js +0 -1
  108. package/dist/assets/ProviderScopedModelInput-D9woCARc.js +0 -1
  109. package/dist/assets/ProvidersList-D-qPGgC4.js +0 -1
  110. package/dist/assets/RuntimeConfig-BHpqcaHm.js +0 -1
  111. package/dist/assets/SearchConfig-DIT6M65Q.js +0 -1
  112. package/dist/assets/i18n-hM3v-3YG.js +0 -1
  113. package/dist/assets/index-CpxuJa9o.css +0 -1
  114. package/dist/assets/index-DHmCjcxq.js +0 -6
  115. package/dist/assets/label-CHJ1ATds.js +0 -1
  116. package/dist/assets/loader-circle-C8cpaL0w.js +0 -1
  117. package/dist/assets/marketplace-localization-CxSTG9wr.js +0 -1
  118. package/dist/assets/plus-CrkO1kob.js +0 -1
  119. package/dist/assets/react-3YE87-lE.js +0 -1
  120. package/dist/assets/search-EX-Papzl.js +0 -1
  121. package/dist/assets/security-config-DEgOD4VX.js +0 -1
  122. package/dist/assets/skeleton-B0mmt1vo.js +0 -1
  123. package/dist/assets/x-B4sxJkGY.js +0 -1
@@ -3,6 +3,7 @@ import { updateNcpSession } from '@/api/ncp-session';
3
3
  import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
4
4
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
5
5
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
6
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
6
7
 
7
8
  vi.mock('@/api/ncp-session', () => ({
8
9
  updateNcpSession: vi.fn(async () => ({
@@ -29,6 +30,12 @@ describe('ChatSessionPreferenceSync', () => {
29
30
  selectedSessionKey: null
30
31
  }
31
32
  }));
33
+ useChatThreadStore.setState((state) => ({
34
+ snapshot: {
35
+ ...state.snapshot,
36
+ canDeleteSession: false
37
+ }
38
+ }));
32
39
  vi.clearAllMocks();
33
40
  });
34
41
 
@@ -46,6 +53,12 @@ describe('ChatSessionPreferenceSync', () => {
46
53
  selectedSessionKey: 'session-1'
47
54
  }
48
55
  }));
56
+ useChatThreadStore.setState((state) => ({
57
+ snapshot: {
58
+ ...state.snapshot,
59
+ canDeleteSession: true
60
+ }
61
+ }));
49
62
 
50
63
  const sync = new ChatSessionPreferenceSync(updateNcpSession);
51
64
  sync.syncSelectedSessionPreferences();
@@ -1,6 +1,7 @@
1
1
  import type { SessionPatchUpdate, ThinkingLevel } from '@/api/types';
2
2
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
3
3
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
4
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
4
5
 
5
6
  type QueuedSessionPreferenceSync = {
6
7
  sessionKey: string;
@@ -30,8 +31,9 @@ export class ChatSessionPreferenceSync {
30
31
  syncSelectedSessionPreferences = (): void => {
31
32
  const inputSnapshot = useChatInputStore.getState().snapshot;
32
33
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
34
+ const threadSnapshot = useChatThreadStore.getState().snapshot;
33
35
  const sessionKey = sessionSnapshot.selectedSessionKey;
34
- if (!sessionKey) {
36
+ if (!sessionKey || !threadSnapshot.canDeleteSession) {
35
37
  return;
36
38
  }
37
39
 
@@ -44,15 +46,15 @@ export class ChatSessionPreferenceSync {
44
46
  });
45
47
  };
46
48
 
47
- private enqueue(next: QueuedSessionPreferenceSync): void {
49
+ private enqueue = (next: QueuedSessionPreferenceSync): void => {
48
50
  this.queued = next;
49
51
  if (this.inFlight) {
50
52
  return;
51
53
  }
52
54
  this.startFlush();
53
- }
55
+ };
54
56
 
55
- private startFlush(): void {
57
+ private startFlush = (): void => {
56
58
  this.inFlight = this.flush()
57
59
  .catch((error) => {
58
60
  console.error(`Failed to sync chat session preferences: ${String(error)}`);
@@ -63,13 +65,13 @@ export class ChatSessionPreferenceSync {
63
65
  this.startFlush();
64
66
  }
65
67
  });
66
- }
68
+ };
67
69
 
68
- private async flush(): Promise<void> {
70
+ private flush = async (): Promise<void> => {
69
71
  while (this.queued) {
70
72
  const current = this.queued;
71
73
  this.queued = null;
72
74
  await this.updateSession(current.sessionKey, current.patch);
73
75
  }
74
- }
76
+ };
75
77
  }
@@ -0,0 +1,43 @@
1
+ import { t } from '@/lib/i18n';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ type ChatSidebarListModeSwitchProps = {
5
+ isProjectFirstView: boolean;
6
+ onSelectMode: (mode: 'time-first' | 'project-first') => void;
7
+ };
8
+
9
+ export function ChatSidebarListModeSwitch(props: ChatSidebarListModeSwitchProps) {
10
+ const { isProjectFirstView, onSelectMode } = props;
11
+
12
+ return (
13
+ <div className="flex items-center gap-1.5 text-[11px]">
14
+ <button
15
+ type="button"
16
+ aria-pressed={!isProjectFirstView}
17
+ onClick={() => onSelectMode('time-first')}
18
+ className={cn(
19
+ 'transition-colors',
20
+ isProjectFirstView
21
+ ? 'text-gray-400 hover:text-gray-600'
22
+ : 'font-medium text-gray-600'
23
+ )}
24
+ >
25
+ {t('chatSidebarViewTime')}
26
+ </button>
27
+ <span className="text-gray-300">/</span>
28
+ <button
29
+ type="button"
30
+ aria-pressed={isProjectFirstView}
31
+ onClick={() => onSelectMode('project-first')}
32
+ className={cn(
33
+ 'transition-colors',
34
+ isProjectFirstView
35
+ ? 'font-medium text-gray-600'
36
+ : 'text-gray-400 hover:text-gray-600'
37
+ )}
38
+ >
39
+ {t('chatSidebarViewProject')}
40
+ </button>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,152 @@
1
+ import { useMemo, useState, type ReactNode } from 'react';
2
+ import { Plus } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
5
+ import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.store';
6
+ import type { NcpSessionListItemView } from '@/components/chat/ncp/use-ncp-session-list-view';
7
+ import { t } from '@/lib/i18n';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ export type ChatSidebarProjectGroup = {
11
+ projectRoot: string;
12
+ projectName: string;
13
+ items: NcpSessionListItemView[];
14
+ latestUpdatedAt: number;
15
+ };
16
+
17
+ type SessionTypeOption = ChatInputSnapshot['sessionTypeOptions'][number];
18
+
19
+ type ChatSidebarProjectGroupsProps = {
20
+ groups: ChatSidebarProjectGroup[];
21
+ defaultSessionType: string;
22
+ sessionTypeOptions: SessionTypeOption[];
23
+ renderSessionItem: (item: NcpSessionListItemView) => ReactNode;
24
+ onCreateSession: (sessionType: string, projectRoot: string) => void;
25
+ };
26
+
27
+ function resolveProjectGroupDefaultSessionType(
28
+ defaultSessionType: string,
29
+ sessionTypeOptions: SessionTypeOption[]
30
+ ): string {
31
+ if (sessionTypeOptions.some((option) => option.value === defaultSessionType)) {
32
+ return defaultSessionType;
33
+ }
34
+ return sessionTypeOptions[0]?.value ?? defaultSessionType;
35
+ }
36
+
37
+ function resolveSessionTypeStatusText(option: {
38
+ ready?: boolean;
39
+ reasonMessage?: string | null;
40
+ }): string {
41
+ if (option.ready === false) {
42
+ return option.reasonMessage?.trim() || t('statusSetup');
43
+ }
44
+ return t('statusReady');
45
+ }
46
+
47
+ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
48
+ const { groups, defaultSessionType, sessionTypeOptions, renderSessionItem, onCreateSession } = props;
49
+ const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
50
+ const preferredSessionType = useMemo(
51
+ () => resolveProjectGroupDefaultSessionType(defaultSessionType, sessionTypeOptions),
52
+ [defaultSessionType, sessionTypeOptions]
53
+ );
54
+ const supportsSessionTypeChoice = sessionTypeOptions.length > 1;
55
+
56
+ return (
57
+ <div className="space-y-3">
58
+ {groups.map((group) => {
59
+ const actionLabel = `${t('chatSidebarNewTask')} · ${group.projectName}`;
60
+
61
+ return (
62
+ <div key={group.projectRoot}>
63
+ <div className="flex items-center justify-between gap-2 px-2 py-0.5">
64
+ <div className="flex min-w-0 items-center gap-1.5">
65
+ <div
66
+ className="truncate text-[11px] font-medium uppercase tracking-wider text-gray-500"
67
+ title={group.projectRoot}
68
+ >
69
+ {group.projectName}
70
+ </div>
71
+ <span className="shrink-0 text-[10px] text-gray-400">
72
+ {group.items.length}
73
+ </span>
74
+ </div>
75
+ {supportsSessionTypeChoice ? (
76
+ <Popover
77
+ open={openProjectRoot === group.projectRoot}
78
+ onOpenChange={(nextOpen) => {
79
+ setOpenProjectRoot(nextOpen ? group.projectRoot : null);
80
+ }}
81
+ >
82
+ <PopoverTrigger asChild>
83
+ <Button
84
+ type="button"
85
+ variant="ghost"
86
+ size="icon"
87
+ className="h-7 w-7 shrink-0 rounded-lg text-gray-400 hover:bg-white hover:text-gray-900"
88
+ aria-label={actionLabel}
89
+ title={actionLabel}
90
+ >
91
+ <Plus className="h-3.5 w-3.5" />
92
+ </Button>
93
+ </PopoverTrigger>
94
+ <PopoverContent align="end" className="w-64 p-2">
95
+ <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-gray-400">
96
+ {t('chatSessionTypeLabel')}
97
+ </div>
98
+ <div className="mt-1 space-y-1">
99
+ {sessionTypeOptions.map((option) => (
100
+ <button
101
+ key={`${group.projectRoot}:${option.value}`}
102
+ type="button"
103
+ onClick={() => {
104
+ onCreateSession(option.value, group.projectRoot);
105
+ setOpenProjectRoot(null);
106
+ }}
107
+ className="w-full rounded-xl px-3 py-2 text-left transition-colors hover:bg-gray-100"
108
+ >
109
+ <div className="flex items-center justify-between gap-3">
110
+ <div className="text-[13px] font-medium text-gray-900">{option.label}</div>
111
+ <span
112
+ className={cn(
113
+ 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
114
+ option.ready === false
115
+ ? 'bg-amber-100 text-amber-800'
116
+ : 'bg-emerald-100 text-emerald-700'
117
+ )}
118
+ >
119
+ {option.ready === false ? t('statusSetup') : t('statusReady')}
120
+ </span>
121
+ </div>
122
+ <div className="mt-0.5 text-[11px] text-gray-500">
123
+ {resolveSessionTypeStatusText(option)}
124
+ </div>
125
+ </button>
126
+ ))}
127
+ </div>
128
+ </PopoverContent>
129
+ </Popover>
130
+ ) : (
131
+ <Button
132
+ type="button"
133
+ variant="ghost"
134
+ size="icon"
135
+ className="h-7 w-7 shrink-0 rounded-lg text-gray-400 hover:bg-white hover:text-gray-900"
136
+ onClick={() => onCreateSession(preferredSessionType, group.projectRoot)}
137
+ aria-label={actionLabel}
138
+ title={actionLabel}
139
+ >
140
+ <Plus className="h-3.5 w-3.5" />
141
+ </Button>
142
+ )}
143
+ </div>
144
+ <div className="space-y-0.5 pl-2">
145
+ {group.items.map(renderSessionItem)}
146
+ </div>
147
+ </div>
148
+ );
149
+ })}
150
+ </div>
151
+ );
152
+ }
@@ -71,7 +71,7 @@ describe('useChatSessionProject', () => {
71
71
  expect(toast.success).toHaveBeenCalledTimes(1);
72
72
  });
73
73
 
74
- it('persists to the server and mirrors the updated project override locally for an existing session', async () => {
74
+ it('persists to the server without reusing the draft override state for an existing session', async () => {
75
75
  const { result } = renderHook(() => useChatSessionProject());
76
76
 
77
77
  await act(async () => {
@@ -88,12 +88,12 @@ describe('useChatSessionProject', () => {
88
88
  successMessage: 'Project directory updated',
89
89
  });
90
90
  expect(useChatInputStore.getState().snapshot).toMatchObject({
91
- pendingProjectRoot: '/tmp/project-beta',
92
- pendingProjectRootSessionKey: 'session-1',
91
+ pendingProjectRoot: null,
92
+ pendingProjectRootSessionKey: null,
93
93
  });
94
94
  });
95
95
 
96
- it('persists clearing to the server and keeps the cleared override until session state catches up', async () => {
96
+ it('persists clearing to the server without keeping a session-scoped local override', async () => {
97
97
  const { result } = renderHook(() => useChatSessionProject());
98
98
 
99
99
  await act(async () => {
@@ -111,7 +111,7 @@ describe('useChatSessionProject', () => {
111
111
  });
112
112
  expect(useChatInputStore.getState().snapshot).toMatchObject({
113
113
  pendingProjectRoot: null,
114
- pendingProjectRootSessionKey: 'session-1',
114
+ pendingProjectRootSessionKey: null,
115
115
  });
116
116
  });
117
117
  });
@@ -31,10 +31,5 @@ export function useChatSessionProject() {
31
31
  patch: { projectRoot: params.projectRoot },
32
32
  successMessage,
33
33
  });
34
-
35
- useChatInputStore.getState().setSnapshot({
36
- pendingProjectRoot: params.projectRoot,
37
- pendingProjectRootSessionKey: params.sessionKey,
38
- });
39
34
  };
40
35
  }
@@ -0,0 +1,75 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import type { ReactNode } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { toast } from 'sonner';
6
+ import { useChatSessionUpdate } from '@/components/chat/hooks/use-chat-session-update';
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ updateNcpSession: vi.fn(),
10
+ upsertNcpSessionSummaryInQueryClient: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('sonner', () => ({
14
+ toast: {
15
+ success: vi.fn(),
16
+ error: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ vi.mock('@/api/ncp-session', () => ({
21
+ updateNcpSession: (...args: unknown[]) => mocks.updateNcpSession(...args),
22
+ }));
23
+
24
+ vi.mock('@/api/ncp-session-query-cache', () => ({
25
+ upsertNcpSessionSummaryInQueryClient: (...args: unknown[]) =>
26
+ mocks.upsertNcpSessionSummaryInQueryClient(...args),
27
+ }));
28
+
29
+ function createWrapper(queryClient: QueryClient) {
30
+ return function Wrapper({ children }: { children: ReactNode }) {
31
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
32
+ };
33
+ }
34
+
35
+ describe('useChatSessionUpdate', () => {
36
+ afterEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it('updates the session summary and invalidates the matching session skills queries', async () => {
41
+ const queryClient = new QueryClient();
42
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
43
+ const updatedSession = {
44
+ sessionId: 'session-1',
45
+ updatedAt: '2026-04-09T00:00:00.000Z',
46
+ status: 'idle',
47
+ metadata: { project_root: '/tmp/project-alpha' },
48
+ };
49
+ mocks.updateNcpSession.mockResolvedValue(updatedSession);
50
+
51
+ const { result } = renderHook(() => useChatSessionUpdate(), {
52
+ wrapper: createWrapper(queryClient),
53
+ });
54
+
55
+ await act(async () => {
56
+ await result.current({
57
+ sessionKey: 'session-1',
58
+ patch: { projectRoot: '/tmp/project-alpha' },
59
+ successMessage: 'Project directory updated',
60
+ });
61
+ });
62
+
63
+ expect(mocks.updateNcpSession).toHaveBeenCalledWith('session-1', {
64
+ projectRoot: '/tmp/project-alpha',
65
+ });
66
+ expect(mocks.upsertNcpSessionSummaryInQueryClient).toHaveBeenCalledWith(
67
+ queryClient,
68
+ updatedSession,
69
+ );
70
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({
71
+ queryKey: ['ncp-session-skills', 'session-1'],
72
+ });
73
+ expect(toast.success).toHaveBeenCalledWith('Project directory updated');
74
+ });
75
+ });
@@ -15,10 +15,12 @@ export function useChatSessionUpdate() {
15
15
  const queryClient = useQueryClient();
16
16
 
17
17
  return async (params: UpdateChatSessionParams): Promise<void> => {
18
+ const { sessionKey, patch, successMessage } = params;
18
19
  try {
19
- const updated = await updateNcpSession(params.sessionKey, params.patch);
20
+ const updated = await updateNcpSession(sessionKey, patch);
20
21
  upsertNcpSessionSummaryInQueryClient(queryClient, updated);
21
- toast.success(params.successMessage ?? t('configSavedApplied'));
22
+ await queryClient.invalidateQueries({ queryKey: ['ncp-session-skills', sessionKey] });
23
+ toast.success(successMessage ?? t('configSavedApplied'));
22
24
  } catch (error) {
23
25
  toast.error(
24
26
  t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)),
@@ -18,6 +18,7 @@ describe('ChatSessionListManager', () => {
18
18
  snapshot: {
19
19
  ...useChatSessionListStore.getState().snapshot,
20
20
  selectedSessionKey: 'session-1',
21
+ draftSessionKey: 'draft-root-1',
21
22
  listMode: 'time-first'
22
23
  }
23
24
  });
@@ -25,18 +26,19 @@ describe('ChatSessionListManager', () => {
25
26
 
26
27
  it('applies the requested session type when creating a session', () => {
27
28
  const uiManager = {
28
- goToChatRoot: vi.fn()
29
+ goToSession: vi.fn()
29
30
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
30
31
  const streamActionsManager = {
31
32
  resetStreamState: vi.fn()
32
33
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
33
34
 
34
- const manager = new ChatSessionListManager(uiManager, streamActionsManager, () => 'draft-session-1');
35
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
35
36
  manager.createSession('codex');
36
37
 
37
38
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
38
- expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
39
+ expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-1');
39
40
  expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
41
+ expect(useChatSessionListStore.getState().snapshot.draftSessionKey).not.toBe('draft-root-1');
40
42
  expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
41
43
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBeNull();
42
44
  expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBeNull();
@@ -44,17 +46,55 @@ describe('ChatSessionListManager', () => {
44
46
 
45
47
  it('hydrates the draft project root when creating a session inside a project group', () => {
46
48
  const uiManager = {
47
- goToChatRoot: vi.fn()
49
+ goToSession: vi.fn()
48
50
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
49
51
  const streamActionsManager = {
50
52
  resetStreamState: vi.fn()
51
53
  } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
52
54
 
53
- const manager = new ChatSessionListManager(uiManager, streamActionsManager, () => 'draft-session-9');
55
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
54
56
  manager.createSession('native', '/tmp/project-alpha');
55
57
 
56
58
  expect(useChatInputStore.getState().snapshot.pendingProjectRoot).toBe('/tmp/project-alpha');
57
- expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-session-9');
59
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
60
+ });
61
+
62
+ it('promotes the current root draft when send flow needs a concrete session key', () => {
63
+ useChatSessionListStore.setState({
64
+ snapshot: {
65
+ ...useChatSessionListStore.getState().snapshot,
66
+ selectedSessionKey: null,
67
+ draftSessionKey: 'draft-root-2'
68
+ }
69
+ });
70
+ const uiManager = {
71
+ goToSession: vi.fn()
72
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
73
+ const streamActionsManager = {
74
+ resetStreamState: vi.fn()
75
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
76
+
77
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
78
+ const sessionKey = manager.ensureDraftSession('native');
79
+
80
+ expect(sessionKey).toBe('draft-root-2');
81
+ expect(uiManager.goToSession).toHaveBeenCalledWith('draft-root-2');
82
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBeNull();
83
+ });
84
+
85
+ it('does not eagerly replace the old selected session before the route finishes switching', () => {
86
+ const uiManager = {
87
+ goToSession: vi.fn()
88
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
89
+ const streamActionsManager = {
90
+ resetStreamState: vi.fn()
91
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
92
+
93
+ const manager = new ChatSessionListManager(uiManager, streamActionsManager);
94
+ manager.createSession('native', '/tmp/project-alpha');
95
+
96
+ expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
97
+ expect(useChatInputStore.getState().snapshot.pendingProjectRootSessionKey).toBe('draft-root-1');
58
98
  });
59
99
 
60
100
  it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
@@ -4,12 +4,12 @@ import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
4
4
  import type { SetStateAction } from 'react';
5
5
  import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
6
6
  import { normalizeSessionProjectRootValue } from '@/lib/session-project/session-project.utils';
7
+ import { createNcpSessionId } from '@/components/chat/ncp/ncp-session-adapter';
7
8
 
8
9
  export class ChatSessionListManager {
9
10
  constructor(
10
11
  private uiManager: ChatUiManager,
11
- private streamActionsManager: ChatStreamActionsManager,
12
- private getDraftSessionId: () => string = () => ''
12
+ private streamActionsManager: ChatStreamActionsManager
13
13
  ) {}
14
14
 
15
15
  private resolveUpdateValue = <T>(prev: T, next: SetStateAction<T>): T => {
@@ -46,8 +46,9 @@ export class ChatSessionListManager {
46
46
  useChatSessionListStore.getState().setSnapshot({ listMode: value });
47
47
  };
48
48
 
49
- createSession = (sessionType?: string, projectRoot?: string | null) => {
49
+ createSession = (sessionType?: string, projectRoot?: string | null): string => {
50
50
  const { snapshot } = useChatInputStore.getState();
51
+ const { snapshot: sessionListSnapshot } = useChatSessionListStore.getState();
51
52
  const { defaultSessionType: configuredDefaultSessionType } = snapshot;
52
53
  const defaultSessionType = configuredDefaultSessionType || 'native';
53
54
  const nextSessionType =
@@ -55,14 +56,26 @@ export class ChatSessionListManager {
55
56
  ? sessionType.trim()
56
57
  : defaultSessionType;
57
58
  const normalizedProjectRoot = normalizeSessionProjectRootValue(projectRoot);
58
- const draftSessionId = normalizedProjectRoot ? this.getDraftSessionId() : null;
59
+ const nextSessionKey = sessionListSnapshot.draftSessionKey;
59
60
  this.streamActionsManager.resetStreamState();
61
+ useChatSessionListStore.getState().setSnapshot({
62
+ draftSessionKey: createNcpSessionId()
63
+ });
60
64
  useChatInputStore.getState().setSnapshot({
61
65
  pendingSessionType: nextSessionType,
62
66
  pendingProjectRoot: normalizedProjectRoot,
63
- pendingProjectRootSessionKey: draftSessionId
67
+ pendingProjectRootSessionKey: normalizedProjectRoot ? nextSessionKey : null
64
68
  });
65
- this.uiManager.goToChatRoot();
69
+ this.uiManager.goToSession(nextSessionKey);
70
+ return nextSessionKey;
71
+ };
72
+
73
+ ensureDraftSession = (sessionType?: string): string => {
74
+ const { snapshot } = useChatSessionListStore.getState();
75
+ if (snapshot.selectedSessionKey) {
76
+ return snapshot.selectedSessionKey;
77
+ }
78
+ return this.createSession(sessionType);
66
79
  };
67
80
 
68
81
  selectSession = (sessionKey: string) => {