@nextclaw/ui 0.9.11 → 0.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/assets/{ChannelsList-Brc1qLSU.js → ChannelsList-bROKR37R.js} +1 -1
- package/dist/assets/ChatPage-B9dHVmrV.js +41 -0
- package/dist/assets/{DocBrowser-xLVf1p4L.js → DocBrowser-S-1-qnZQ.js} +1 -1
- package/dist/assets/{LogoBadge-CcTyimdr.js → LogoBadge-t1JzzCtI.js} +1 -1
- package/dist/assets/{MarketplacePage-Bk-qXxyh.js → MarketplacePage-CzIHYJpM.js} +2 -2
- package/dist/assets/{McpMarketplacePage-gFqAYekc.js → McpMarketplacePage-BTJdjNQ1.js} +1 -1
- package/dist/assets/{ModelConfig-DnKNTuw6.js → ModelConfig-BD4o3Kna.js} +1 -1
- package/dist/assets/{ProvidersList-Cjr8EFu_.js → ProvidersList-BOQArFRk.js} +1 -1
- package/dist/assets/RemoteAccessPage-CYNQ53xu.js +1 -0
- package/dist/assets/{RuntimeConfig-CttN--Tv.js → RuntimeConfig-B0B73pye.js} +1 -1
- package/dist/assets/{SearchConfig-D-GzinsL.js → SearchConfig-CKy2QkAP.js} +1 -1
- package/dist/assets/{SecretsConfig-BvqQq4Ds.js → SecretsConfig-BpZLUu88.js} +2 -2
- package/dist/assets/{SessionsConfig-DbtnLmI6.js → SessionsConfig-CoFI6Fa2.js} +1 -1
- package/dist/assets/{chat-message-DYQjL1tD.js → chat-message-D3jZIASl.js} +1 -1
- package/dist/assets/index-CmGwUgcl.js +8 -0
- package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
- package/dist/assets/{label-DBSKOMGE.js → label-BOvIOmQx.js} +1 -1
- package/dist/assets/{page-layout-B5th9UzR.js → page-layout-PG3cwSpz.js} +1 -1
- package/dist/assets/popover-BB-kINz7.js +1 -0
- package/dist/assets/{security-config-D72JskP5.js → security-config-Bb6l-viE.js} +1 -1
- package/dist/assets/skeleton-CLSc5FYO.js +1 -0
- package/dist/assets/{status-dot-CU5ZpOn1.js → status-dot-Behu7kDZ.js} +1 -1
- package/dist/assets/{switch-BdaXEtXk.js → switch-CvNG9775.js} +1 -1
- package/dist/assets/{tabs-custom-BVhSoteN.js → tabs-custom-CUdBQO_7.js} +1 -1
- package/dist/assets/{useConfirmDialog-Dugi9V-Z.js → useConfirmDialog-CLLe2uIJ.js} +1 -1
- package/dist/assets/{vendor-CmQZsDAE.js → vendor-TJ2hy_Lv.js} +87 -82
- package/dist/index.html +3 -3
- package/package.json +4 -4
- package/src/account/managers/account.manager.ts +8 -1
- package/src/account/stores/account.store.ts +3 -0
- package/src/api/api-base.ts +16 -0
- package/src/api/client.test.ts +69 -0
- package/src/api/client.ts +29 -87
- package/src/api/config.stream.test.ts +115 -0
- package/src/api/config.ts +49 -121
- package/src/api/raw-client.ts +87 -0
- package/src/components/chat/ChatSidebar.test.tsx +134 -1
- package/src/components/chat/ChatSidebar.tsx +87 -37
- package/src/components/chat/chat-session-label.service.ts +34 -0
- package/src/components/chat/chat-sidebar-session-item.tsx +147 -0
- package/src/components/chat/ncp/NcpChatPage.tsx +3 -10
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +69 -0
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +127 -0
- package/src/components/remote/RemoteAccessPage.test.tsx +103 -0
- package/src/components/remote/RemoteAccessPage.tsx +28 -93
- package/src/lib/i18n.remote.ts +20 -8
- package/src/remote/managers/remote-access.manager.ts +13 -0
- package/src/remote/remote-access-feedback.service.test.ts +75 -0
- package/src/remote/remote-access-feedback.service.ts +195 -0
- package/src/transport/app-client.test.ts +49 -0
- package/src/transport/app-client.ts +23 -7
- package/src/transport/local.transport.ts +3 -2
- package/src/transport/remote.transport.ts +7 -2
- package/dist/assets/ChatPage-DmGI776q.js +0 -38
- package/dist/assets/RemoteAccessPage-Rzi5a6Gc.js +0 -1
- package/dist/assets/index-ClLy_7T2.js +0 -8
- package/dist/assets/popover-BEIWRoeP.js +0 -1
- package/dist/assets/skeleton-B_Pn9x0i.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 {
|
|
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,
|
|
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 {
|
|
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
|
-
<
|
|
340
|
+
<ChatSidebarSessionItem
|
|
273
341
|
key={session.key}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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/
|
|
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:
|
|
109
|
+
fetchImpl: createNcpAppClientFetch()
|
|
117
110
|
})
|
|
118
111
|
);
|
|
119
112
|
|