@nextclaw/ui 0.9.10 → 0.9.12

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 (61) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/assets/{ChannelsList-BgJbR6E9.js → ChannelsList-CJy2GG1a.js} +1 -1
  3. package/dist/assets/ChatPage-C7WxI8VY.js +41 -0
  4. package/dist/assets/{DocBrowser-Dw9BGO1m.js → DocBrowser-Nu-ae-eS.js} +1 -1
  5. package/dist/assets/{LogoBadge-CLc2B6st.js → LogoBadge-DbbMxPlr.js} +1 -1
  6. package/dist/assets/{MarketplacePage-ChqCNL7k.js → MarketplacePage-BQYQPeg2.js} +2 -2
  7. package/dist/assets/{McpMarketplacePage-B3PF-7ED.js → McpMarketplacePage-kiMJbS8r.js} +1 -1
  8. package/dist/assets/{ModelConfig-Dqz_NOow.js → ModelConfig-DRQ07Snj.js} +1 -1
  9. package/dist/assets/{ProvidersList-D2WaZShJ.js → ProvidersList-C0NjzKX1.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-DVJ5hBNJ.js +1 -0
  11. package/dist/assets/{RuntimeConfig-TDxQLuGy.js → RuntimeConfig-BkYWyRW7.js} +1 -1
  12. package/dist/assets/{SearchConfig-gba64nGn.js → SearchConfig-DZTW8Wnq.js} +1 -1
  13. package/dist/assets/{SecretsConfig-DpL8wgly.js → SecretsConfig-WMcwg5KV.js} +2 -2
  14. package/dist/assets/{SessionsConfig-CAODVTNW.js → SessionsConfig-CWtCXQRn.js} +1 -1
  15. package/dist/assets/{chat-message-CSG50nNb.js → chat-message-BcjCODYN.js} +1 -1
  16. package/dist/assets/index-BOhlxC12.js +8 -0
  17. package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
  18. package/dist/assets/{label-3T28q3PJ.js → label-DOWMfYPL.js} +1 -1
  19. package/dist/assets/{page-layout-BrXOQeua.js → page-layout-DQtmTgqR.js} +1 -1
  20. package/dist/assets/popover-k11l1-ko.js +1 -0
  21. package/dist/assets/{security-config-oGAhN4Zf.js → security-config-FFy-bOJb.js} +1 -1
  22. package/dist/assets/skeleton-DQ4QRdSe.js +1 -0
  23. package/dist/assets/{status-dot-QL3hmT1d.js → status-dot-CsZRxe8p.js} +1 -1
  24. package/dist/assets/{switch-Dbt2kUg2.js → switch-DfMy8G96.js} +1 -1
  25. package/dist/assets/{tabs-custom-y5hdkzXk.js → tabs-custom-CITPDGXY.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-B4zwBVbl.js → useConfirmDialog-Dr39o-0I.js} +1 -1
  27. package/dist/assets/{vendor-CmQZsDAE.js → vendor-TJ2hy_Lv.js} +87 -82
  28. package/dist/index.html +3 -3
  29. package/package.json +3 -3
  30. package/src/account/managers/account.manager.ts +8 -1
  31. package/src/account/stores/account.store.ts +3 -0
  32. package/src/api/api-base.ts +16 -0
  33. package/src/api/client.test.ts +69 -0
  34. package/src/api/client.ts +29 -87
  35. package/src/api/config.stream.test.ts +115 -0
  36. package/src/api/config.ts +50 -125
  37. package/src/api/raw-client.ts +87 -0
  38. package/src/components/chat/ChatSidebar.test.tsx +134 -1
  39. package/src/components/chat/ChatSidebar.tsx +87 -37
  40. package/src/components/chat/chat-session-label.service.ts +34 -0
  41. package/src/components/chat/chat-sidebar-session-item.tsx +147 -0
  42. package/src/components/chat/ncp/NcpChatPage.tsx +3 -10
  43. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +69 -0
  44. package/src/components/chat/ncp/ncp-app-client-fetch.ts +127 -0
  45. package/src/components/remote/RemoteAccessPage.test.tsx +103 -0
  46. package/src/components/remote/RemoteAccessPage.tsx +28 -93
  47. package/src/hooks/use-realtime-query-bridge.ts +77 -71
  48. package/src/lib/i18n.remote.ts +20 -8
  49. package/src/remote/managers/remote-access.manager.ts +13 -0
  50. package/src/remote/remote-access-feedback.service.test.ts +75 -0
  51. package/src/remote/remote-access-feedback.service.ts +195 -0
  52. package/src/transport/app-client.ts +1 -1
  53. package/src/transport/local.transport.ts +8 -125
  54. package/src/transport/remote.transport.ts +44 -74
  55. package/src/transport/sse-stream.ts +114 -0
  56. package/src/transport/transport-websocket-url.ts +24 -0
  57. package/dist/assets/ChatPage-Bv9UJPse.js +0 -38
  58. package/dist/assets/RemoteAccessPage-D_l9irp4.js +0 -1
  59. package/dist/assets/index-DaEflNCE.js +0 -8
  60. package/dist/assets/popover-BrBJjElY.js +0 -1
  61. package/dist/assets/skeleton-CIPQUKo2.js +0 -1
@@ -0,0 +1,87 @@
1
+ import { API_BASE } from './api-base';
2
+ import type { ApiResponse } from './types';
3
+
4
+ function compactSnippet(text: string) {
5
+ return text.replace(/\s+/g, ' ').trim().slice(0, 200);
6
+ }
7
+
8
+ function inferNonJsonHint(endpoint: string, status: number): string | undefined {
9
+ if (
10
+ status === 404 &&
11
+ endpoint.startsWith('/api/config/providers/') &&
12
+ endpoint.endsWith('/test')
13
+ ) {
14
+ return 'Provider test endpoint is missing. This usually means nextclaw runtime version is outdated.';
15
+ }
16
+ if (status === 401 || status === 403) {
17
+ return 'Authentication failed. Check apiKey and custom headers.';
18
+ }
19
+ if (status === 429) {
20
+ return 'Rate limited by upstream provider. Retry later or switch model/provider.';
21
+ }
22
+ if (status >= 500) {
23
+ return 'Upstream service error. Retry later and inspect server logs if it persists.';
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ export async function requestRawApiResponse<T>(
29
+ endpoint: string,
30
+ options: RequestInit = {}
31
+ ): Promise<ApiResponse<T>> {
32
+ const url = `${API_BASE}${endpoint}`;
33
+ const method = (options.method || 'GET').toUpperCase();
34
+
35
+ const response = await fetch(url, {
36
+ credentials: 'include',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ ...options.headers
40
+ },
41
+ ...options
42
+ });
43
+
44
+ const text = await response.text();
45
+ let data: ApiResponse<T> | null = null;
46
+ if (text) {
47
+ try {
48
+ data = JSON.parse(text) as ApiResponse<T>;
49
+ } catch {
50
+ // fall through to build a synthetic error response
51
+ }
52
+ }
53
+
54
+ if (!data) {
55
+ const snippet = text ? compactSnippet(text) : '';
56
+ const hint = inferNonJsonHint(endpoint, response.status);
57
+ const parts = [`Non-JSON response (${response.status} ${response.statusText}) on ${method} ${endpoint}`];
58
+ if (snippet) {
59
+ parts.push(`body=${snippet}`);
60
+ }
61
+ if (hint) {
62
+ parts.push(`hint=${hint}`);
63
+ }
64
+ return {
65
+ ok: false,
66
+ error: {
67
+ code: 'INVALID_RESPONSE',
68
+ message: parts.join(' | '),
69
+ details: {
70
+ status: response.status,
71
+ statusText: response.statusText,
72
+ method,
73
+ endpoint,
74
+ url,
75
+ bodySnippet: snippet || undefined,
76
+ hint
77
+ }
78
+ }
79
+ };
80
+ }
81
+
82
+ if (!response.ok) {
83
+ return data as ApiResponse<T>;
84
+ }
85
+
86
+ return data as ApiResponse<T>;
87
+ }
@@ -10,7 +10,9 @@ const mocks = vi.hoisted(() => ({
10
10
  createSession: vi.fn(),
11
11
  setQuery: vi.fn(),
12
12
  selectSession: vi.fn(),
13
- docOpen: vi.fn()
13
+ docOpen: vi.fn(),
14
+ updateSession: vi.fn(),
15
+ updateNcpSession: vi.fn()
14
16
  }));
15
17
 
16
18
  vi.mock('@/components/chat/presenter/chat-presenter-context', () => ({
@@ -29,6 +31,19 @@ vi.mock('@/components/doc-browser', () => ({
29
31
  })
30
32
  }));
31
33
 
34
+ vi.mock('@/components/chat/chat-session-label.service', () => ({
35
+ useChatSessionLabelService: () => async (params: {
36
+ chatChain: 'legacy' | 'ncp';
37
+ sessionKey: string;
38
+ label: string | null;
39
+ }) => {
40
+ if (params.chatChain === 'ncp') {
41
+ return mocks.updateNcpSession(params.sessionKey, { label: params.label });
42
+ }
43
+ return mocks.updateSession(params.sessionKey, { label: params.label });
44
+ }
45
+ }));
46
+
32
47
  vi.mock('@/components/common/BrandHeader', () => ({
33
48
  BrandHeader: () => <div data-testid="brand-header" />
34
49
  }));
@@ -62,6 +77,10 @@ describe('ChatSidebar', () => {
62
77
  mocks.setQuery.mockReset();
63
78
  mocks.selectSession.mockReset();
64
79
  mocks.docOpen.mockReset();
80
+ mocks.updateSession.mockReset();
81
+ mocks.updateNcpSession.mockReset();
82
+ mocks.updateSession.mockResolvedValue({});
83
+ mocks.updateNcpSession.mockResolvedValue({});
65
84
 
66
85
  useChatInputStore.setState({
67
86
  snapshot: {
@@ -160,6 +179,7 @@ describe('ChatSidebar', () => {
160
179
 
161
180
  expect(screen.getByText('Codex Task')).not.toBeNull();
162
181
  expect(screen.getByText('Codex')).not.toBeNull();
182
+ expect(screen.getByText('session:codex-1')).not.toBeNull();
163
183
  });
164
184
 
165
185
  it('formats non-native session badges generically when the type is no longer in the available options', () => {
@@ -229,4 +249,117 @@ describe('ChatSidebar', () => {
229
249
  expect(screen.getByText('Native Task')).not.toBeNull();
230
250
  expect(screen.queryByText('Native')).toBeNull();
231
251
  });
252
+
253
+ it('edits the session label inline and saves through the ncp session api by default', async () => {
254
+ useChatSessionListStore.setState({
255
+ snapshot: {
256
+ ...useChatSessionListStore.getState().snapshot,
257
+ sessions: [
258
+ {
259
+ key: 'session:ncp-1',
260
+ createdAt: '2026-03-19T09:00:00.000Z',
261
+ updatedAt: '2026-03-19T09:05:00.000Z',
262
+ label: 'Initial Label',
263
+ sessionType: 'native',
264
+ sessionTypeMutable: false,
265
+ messageCount: 1
266
+ }
267
+ ]
268
+ }
269
+ });
270
+
271
+ render(
272
+ <MemoryRouter>
273
+ <ChatSidebar />
274
+ </MemoryRouter>
275
+ );
276
+
277
+ fireEvent.click(screen.getByLabelText('Edit'));
278
+ fireEvent.change(screen.getByPlaceholderText('Session label (optional)'), {
279
+ target: { value: 'Renamed Label' }
280
+ });
281
+ fireEvent.click(screen.getByLabelText('Save'));
282
+
283
+ await waitFor(() => {
284
+ expect(mocks.updateNcpSession).toHaveBeenCalledWith('session:ncp-1', {
285
+ label: 'Renamed Label'
286
+ });
287
+ });
288
+ expect(mocks.updateSession).not.toHaveBeenCalled();
289
+ expect(screen.getByText('Renamed Label')).not.toBeNull();
290
+ });
291
+
292
+ it('routes inline session label edits to the legacy session api when chatChain=legacy', async () => {
293
+ useChatSessionListStore.setState({
294
+ snapshot: {
295
+ ...useChatSessionListStore.getState().snapshot,
296
+ sessions: [
297
+ {
298
+ key: 'session:legacy-1',
299
+ createdAt: '2026-03-19T09:00:00.000Z',
300
+ updatedAt: '2026-03-19T09:05:00.000Z',
301
+ label: 'Legacy Label',
302
+ sessionType: 'native',
303
+ sessionTypeMutable: false,
304
+ messageCount: 1
305
+ }
306
+ ]
307
+ }
308
+ });
309
+
310
+ render(
311
+ <MemoryRouter initialEntries={['/chat?chatChain=legacy']}>
312
+ <ChatSidebar />
313
+ </MemoryRouter>
314
+ );
315
+
316
+ fireEvent.click(screen.getByLabelText('Edit'));
317
+ fireEvent.change(screen.getByPlaceholderText('Session label (optional)'), {
318
+ target: { value: 'Legacy Renamed' }
319
+ });
320
+ fireEvent.click(screen.getByLabelText('Save'));
321
+
322
+ await waitFor(() => {
323
+ expect(mocks.updateSession).toHaveBeenCalledWith('session:legacy-1', {
324
+ label: 'Legacy Renamed'
325
+ });
326
+ });
327
+ expect(mocks.updateNcpSession).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it('cancels inline session label editing without saving', () => {
331
+ useChatSessionListStore.setState({
332
+ snapshot: {
333
+ ...useChatSessionListStore.getState().snapshot,
334
+ sessions: [
335
+ {
336
+ key: 'session:ncp-2',
337
+ createdAt: '2026-03-19T09:00:00.000Z',
338
+ updatedAt: '2026-03-19T09:05:00.000Z',
339
+ label: 'Cancelable Label',
340
+ sessionType: 'native',
341
+ sessionTypeMutable: false,
342
+ messageCount: 1
343
+ }
344
+ ]
345
+ }
346
+ });
347
+
348
+ render(
349
+ <MemoryRouter>
350
+ <ChatSidebar />
351
+ </MemoryRouter>
352
+ );
353
+
354
+ fireEvent.click(screen.getByLabelText('Edit'));
355
+ fireEvent.change(screen.getByPlaceholderText('Session label (optional)'), {
356
+ target: { value: 'Should Not Persist' }
357
+ });
358
+ fireEvent.click(screen.getByLabelText('Cancel'));
359
+
360
+ expect(mocks.updateSession).not.toHaveBeenCalled();
361
+ expect(mocks.updateNcpSession).not.toHaveBeenCalled();
362
+ expect(screen.queryByDisplayValue('Should Not Persist')).toBeNull();
363
+ expect(screen.getByText('Cancelable Label')).not.toBeNull();
364
+ });
232
365
  });
@@ -6,20 +6,33 @@ import { StatusBadge } from '@/components/common/StatusBadge';
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
8
8
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
9
- import { SessionRunBadge } from '@/components/common/SessionRunBadge';
9
+ import { ChatSidebarSessionItem } from '@/components/chat/chat-sidebar-session-item';
10
+ import { useChatSessionLabelService } from '@/components/chat/chat-session-label.service';
10
11
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
11
12
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
12
13
  import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
13
14
  import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
15
+ import { resolveChatChain } from '@/components/chat/chat-chain';
14
16
  import { cn } from '@/lib/utils';
15
- import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
17
+ import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
16
18
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
17
19
  import { useI18n } from '@/components/providers/I18nProvider';
18
20
  import { useTheme } from '@/components/providers/ThemeProvider';
19
21
  import { useDocBrowser } from '@/components/doc-browser';
20
22
  import { useUiStore } from '@/stores/ui.store';
21
- import { NavLink } from 'react-router-dom';
22
- import { AlarmClock, BookOpen, BrainCircuit, ChevronDown, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
23
+ import { NavLink, useLocation } from 'react-router-dom';
24
+ import {
25
+ AlarmClock,
26
+ BookOpen,
27
+ BrainCircuit,
28
+ ChevronDown,
29
+ Languages,
30
+ MessageSquareText,
31
+ Palette,
32
+ Plus,
33
+ Search,
34
+ Settings
35
+ } from 'lucide-react';
23
36
 
24
37
  type DateGroup = {
25
38
  label: string;
@@ -103,13 +116,19 @@ const navItems = [
103
116
  export function ChatSidebar() {
104
117
  const presenter = usePresenter();
105
118
  const docBrowser = useDocBrowser();
119
+ const location = useLocation();
106
120
  const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
121
+ const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
122
+ const [draftLabel, setDraftLabel] = useState('');
123
+ const [savingSessionKey, setSavingSessionKey] = useState<string | null>(null);
107
124
  const inputSnapshot = useChatInputStore((state) => state.snapshot);
108
125
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
109
126
  const runSnapshot = useChatRunStatusStore((state) => state.snapshot);
110
127
  const connectionStatus = useUiStore((state) => state.connectionStatus);
111
128
  const { language, setLanguage } = useI18n();
112
129
  const { theme, setTheme } = useTheme();
130
+ const updateSessionLabel = useChatSessionLabelService();
131
+ const chatChain = resolveChatChain(location.search);
113
132
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
114
133
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
115
134
 
@@ -126,6 +145,53 @@ export function ChatSidebar() {
126
145
  window.location.reload();
127
146
  };
128
147
 
148
+ const patchSessionLabelInStore = (sessionKey: string, label: string | undefined) => {
149
+ const { sessions } = useChatSessionListStore.getState().snapshot;
150
+ useChatSessionListStore.getState().setSnapshot({
151
+ sessions: sessions.map((session) =>
152
+ session.key === sessionKey
153
+ ? {
154
+ ...session,
155
+ ...(label ? { label } : { label: undefined })
156
+ }
157
+ : session
158
+ )
159
+ });
160
+ };
161
+
162
+ const startEditingSessionLabel = (session: SessionEntryView) => {
163
+ setEditingSessionKey(session.key);
164
+ setDraftLabel(session.label?.trim() ?? '');
165
+ };
166
+
167
+ const cancelEditingSessionLabel = () => {
168
+ setEditingSessionKey(null);
169
+ setDraftLabel('');
170
+ setSavingSessionKey(null);
171
+ };
172
+
173
+ const saveSessionLabel = async (session: SessionEntryView) => {
174
+ const normalizedLabel = draftLabel.trim();
175
+ const currentLabel = session.label?.trim() ?? '';
176
+ if (normalizedLabel === currentLabel) {
177
+ cancelEditingSessionLabel();
178
+ return;
179
+ }
180
+
181
+ setSavingSessionKey(session.key);
182
+ try {
183
+ await updateSessionLabel({
184
+ chatChain,
185
+ sessionKey: session.key,
186
+ label: normalizedLabel || null
187
+ });
188
+ patchSessionLabelInStore(session.key, normalizedLabel || undefined);
189
+ cancelEditingSessionLabel();
190
+ } catch {
191
+ setSavingSessionKey(null);
192
+ }
193
+ };
194
+
129
195
  return (
130
196
  <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
131
197
  <div className="px-5 pt-5 pb-3">
@@ -268,41 +334,25 @@ export function ChatSidebar() {
268
334
  const active = listSnapshot.selectedSessionKey === session.key;
269
335
  const runStatus = runSnapshot.sessionRunStatusByKey.get(session.key);
270
336
  const sessionTypeLabel = resolveSessionTypeLabel(session.sessionType, inputSnapshot.sessionTypeOptions);
337
+ const isEditing = editingSessionKey === session.key;
338
+ const isSaving = savingSessionKey === session.key;
271
339
  return (
272
- <button
340
+ <ChatSidebarSessionItem
273
341
  key={session.key}
274
- onClick={() => presenter.chatSessionListManager.selectSession(session.key)}
275
- className={cn(
276
- 'w-full rounded-xl px-3 py-2 text-left transition-all text-[13px]',
277
- active
278
- ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
279
- : 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
280
- )}
281
- >
282
- <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5">
283
- <span className="flex min-w-0 items-center gap-1.5">
284
- <span className="truncate font-medium">{sessionTitle(session)}</span>
285
- {sessionTypeLabel ? (
286
- <span
287
- className={cn(
288
- 'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
289
- active
290
- ? 'border-gray-300 bg-white/80 text-gray-700'
291
- : 'border-gray-200 bg-gray-100 text-gray-500'
292
- )}
293
- >
294
- {sessionTypeLabel}
295
- </span>
296
- ) : null}
297
- </span>
298
- <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
299
- {runStatus ? <SessionRunBadge status={runStatus} /> : null}
300
- </span>
301
- </div>
302
- <div className="mt-0.5 text-[11px] text-gray-400 truncate">
303
- {session.messageCount} · {formatDateTime(session.updatedAt)}
304
- </div>
305
- </button>
342
+ session={session}
343
+ active={active}
344
+ runStatus={runStatus}
345
+ sessionTypeLabel={sessionTypeLabel}
346
+ title={sessionTitle(session)}
347
+ isEditing={isEditing}
348
+ draftLabel={draftLabel}
349
+ isSaving={isSaving}
350
+ onSelect={() => presenter.chatSessionListManager.selectSession(session.key)}
351
+ onStartEditing={() => startEditingSessionLabel(session)}
352
+ onDraftLabelChange={setDraftLabel}
353
+ onSave={() => saveSessionLabel(session)}
354
+ onCancel={cancelEditingSessionLabel}
355
+ />
306
356
  );
307
357
  })}
308
358
  </div>
@@ -0,0 +1,34 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import { toast } from 'sonner';
3
+ import { updateSession } from '@/api/config';
4
+ import { updateNcpSession } from '@/api/ncp-session';
5
+ import type { ChatChain } from '@/components/chat/chat-chain';
6
+ import { t } from '@/lib/i18n';
7
+
8
+ type UpdateChatSessionLabelParams = {
9
+ chatChain: ChatChain;
10
+ sessionKey: string;
11
+ label: string | null;
12
+ };
13
+
14
+ export function useChatSessionLabelService() {
15
+ const queryClient = useQueryClient();
16
+
17
+ return async (params: UpdateChatSessionLabelParams): Promise<void> => {
18
+ try {
19
+ if (params.chatChain === 'ncp') {
20
+ await updateNcpSession(params.sessionKey, { label: params.label });
21
+ queryClient.invalidateQueries({ queryKey: ['ncp-sessions'] });
22
+ queryClient.invalidateQueries({ queryKey: ['ncp-session-messages', params.sessionKey] });
23
+ } else {
24
+ await updateSession(params.sessionKey, { label: params.label });
25
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
26
+ queryClient.invalidateQueries({ queryKey: ['session-history', params.sessionKey] });
27
+ }
28
+ toast.success(t('configSavedApplied'));
29
+ } catch (error) {
30
+ toast.error(t('configSaveFailed') + ': ' + (error instanceof Error ? error.message : String(error)));
31
+ throw error;
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,147 @@
1
+ import type { SessionEntryView } from '@/api/types';
2
+ import { SessionRunBadge } from '@/components/common/SessionRunBadge';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import type { SessionRunStatus } from '@/lib/session-run-status';
6
+ import { cn } from '@/lib/utils';
7
+ import { formatDateTime, t } from '@/lib/i18n';
8
+ import { Check, Pencil, X } from 'lucide-react';
9
+
10
+ type ChatSidebarSessionItemProps = {
11
+ session: SessionEntryView;
12
+ active: boolean;
13
+ runStatus?: SessionRunStatus;
14
+ sessionTypeLabel: string | null;
15
+ title: string;
16
+ isEditing: boolean;
17
+ draftLabel: string;
18
+ isSaving: boolean;
19
+ onSelect: () => void;
20
+ onStartEditing: () => void;
21
+ onDraftLabelChange: (value: string) => void;
22
+ onSave: () => void | Promise<void>;
23
+ onCancel: () => void;
24
+ };
25
+
26
+ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
27
+ const {
28
+ session,
29
+ active,
30
+ runStatus,
31
+ sessionTypeLabel,
32
+ title,
33
+ isEditing,
34
+ draftLabel,
35
+ isSaving,
36
+ onSelect,
37
+ onStartEditing,
38
+ onDraftLabelChange,
39
+ onSave,
40
+ onCancel
41
+ } = props;
42
+
43
+ return (
44
+ <div
45
+ className={cn(
46
+ 'w-full rounded-xl px-3 py-2 text-left transition-all text-[13px]',
47
+ active
48
+ ? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
49
+ : 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
50
+ )}
51
+ >
52
+ {isEditing ? (
53
+ <div className="space-y-2">
54
+ <Input
55
+ value={draftLabel}
56
+ onChange={(event) => onDraftLabelChange(event.target.value)}
57
+ onKeyDown={(event) => {
58
+ if (event.key === 'Enter') {
59
+ event.preventDefault();
60
+ void onSave();
61
+ } else if (event.key === 'Escape') {
62
+ event.preventDefault();
63
+ onCancel();
64
+ }
65
+ }}
66
+ placeholder={t('sessionsLabelPlaceholder')}
67
+ className="h-8 rounded-lg border-gray-300 bg-white text-xs"
68
+ autoFocus
69
+ disabled={isSaving}
70
+ />
71
+ <div className="flex items-center justify-between gap-2">
72
+ <div className="min-w-0 text-[11px] text-gray-400 truncate">{session.key}</div>
73
+ <div className="flex items-center gap-1">
74
+ <Button
75
+ type="button"
76
+ size="icon"
77
+ variant="ghost"
78
+ className="h-7 w-7 rounded-lg text-gray-500 hover:bg-white hover:text-gray-900"
79
+ onClick={() => void onSave()}
80
+ disabled={isSaving}
81
+ aria-label={t('save')}
82
+ >
83
+ <Check className="h-3.5 w-3.5" />
84
+ </Button>
85
+ <Button
86
+ type="button"
87
+ size="icon"
88
+ variant="ghost"
89
+ className="h-7 w-7 rounded-lg text-gray-500 hover:bg-white hover:text-gray-900"
90
+ onClick={onCancel}
91
+ disabled={isSaving}
92
+ aria-label={t('cancel')}
93
+ >
94
+ <X className="h-3.5 w-3.5" />
95
+ </Button>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ ) : (
100
+ <div className="group/session relative">
101
+ <button type="button" onClick={onSelect} className="w-full text-left">
102
+ <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5 pr-8">
103
+ <span className="flex min-w-0 items-center gap-1.5">
104
+ <span className="truncate font-medium">{title}</span>
105
+ {sessionTypeLabel ? (
106
+ <span
107
+ className={cn(
108
+ 'shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold leading-none',
109
+ active
110
+ ? 'border-gray-300 bg-white/80 text-gray-700'
111
+ : 'border-gray-200 bg-gray-100 text-gray-500'
112
+ )}
113
+ >
114
+ {sessionTypeLabel}
115
+ </span>
116
+ ) : null}
117
+ </span>
118
+ <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
119
+ {runStatus ? <SessionRunBadge status={runStatus} /> : null}
120
+ </span>
121
+ </div>
122
+ <div className="mt-0.5 text-[11px] text-gray-400 truncate">{session.key}</div>
123
+ <div className="mt-0.5 text-[11px] text-gray-400 truncate">
124
+ {session.messageCount} · {formatDateTime(session.updatedAt)}
125
+ </div>
126
+ </button>
127
+ <button
128
+ type="button"
129
+ onClick={(event) => {
130
+ event.stopPropagation();
131
+ onStartEditing();
132
+ }}
133
+ className={cn(
134
+ 'absolute right-0 top-0 inline-flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition-all hover:bg-white hover:text-gray-900',
135
+ active
136
+ ? 'opacity-100'
137
+ : 'opacity-0 group-hover/session:opacity-100 group-focus-within/session:opacity-100'
138
+ )}
139
+ aria-label={t('edit')}
140
+ >
141
+ <Pencil className="h-3.5 w-3.5" />
142
+ </button>
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ }
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { NcpHttpAgentClientEndpoint } from '@nextclaw/ncp-http-agent-client';
3
3
  import { useHydratedNcpAgent, type NcpConversationSeed } from '@nextclaw/ncp-react';
4
4
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
5
- import { API_BASE } from '@/api/client';
5
+ import { API_BASE } from '@/api/api-base';
6
6
  import { fetchNcpSessionMessages } from '@/api/ncp-session';
7
7
  import type { ChatRunView } from '@/api/types';
8
8
  import { sessionDisplayName } from '@/components/chat/chat-page-data';
9
9
  import { ChatPageLayout, type ChatPageProps, useChatSessionSync } from '@/components/chat/chat-page-shell';
10
+ import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
10
11
  import { parseSessionKeyFromRoute, resolveAgentIdFromSessionKey } from '@/components/chat/chat-session-route';
11
12
  import { useNcpChatPageData } from '@/components/chat/ncp/ncp-chat-page-data';
12
13
  import { NcpChatPresenter } from '@/components/chat/ncp/ncp-chat.presenter';
@@ -18,14 +19,6 @@ import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeSta
18
19
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
19
20
  import { normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
20
21
 
21
- function createFetchWithCredentials(): typeof fetch {
22
- return (input, init) =>
23
- fetch(input, {
24
- credentials: 'include',
25
- ...init
26
- });
27
- }
28
-
29
22
  function buildNcpSendMetadata(payload: {
30
23
  model?: string;
31
24
  thinkingLevel?: string;
@@ -113,7 +106,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
113
106
  new NcpHttpAgentClientEndpoint({
114
107
  baseUrl: API_BASE,
115
108
  basePath: '/api/ncp/agent',
116
- fetchImpl: createFetchWithCredentials()
109
+ fetchImpl: createNcpAppClientFetch()
117
110
  })
118
111
  );
119
112