@nextclaw/ui 0.10.1 → 0.10.3
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 +15 -0
- package/dist/assets/{ChannelsList-BX7KqEk7.js → ChannelsList-2FjU5fiD.js} +1 -1
- package/dist/assets/{ChatPage-zXLBKIAY.js → ChatPage-ugiGAeYI.js} +19 -19
- package/dist/assets/{DocBrowser-Cdbh4cVD.js → DocBrowser-tH07yTO3.js} +1 -1
- package/dist/assets/{LogoBadge-4801esOJ.js → LogoBadge-BHszLcFS.js} +1 -1
- package/dist/assets/{MarketplacePage-GZgus0Or.js → MarketplacePage-C7sTQxnk.js} +1 -1
- package/dist/assets/{McpMarketplacePage-CAGGvoMo.js → McpMarketplacePage-6pG1exmL.js} +1 -1
- package/dist/assets/{ModelConfig-CfLYjQM3.js → ModelConfig-ChXV-3uT.js} +1 -1
- package/dist/assets/{ProvidersList-CEo1kdf-.js → ProvidersList-Bq6v0Arn.js} +1 -1
- package/dist/assets/{RemoteAccessPage-6GYzD7cc.js → RemoteAccessPage-BOWUBcqS.js} +1 -1
- package/dist/assets/{RuntimeConfig-BZdbp8mH.js → RuntimeConfig-DyVKq5bp.js} +1 -1
- package/dist/assets/{SearchConfig-ifvYKix-.js → SearchConfig-DLKJzszy.js} +1 -1
- package/dist/assets/{SecretsConfig-tDPbhTeR.js → SecretsConfig-D1fC-5yG.js} +1 -1
- package/dist/assets/{SessionsConfig-DhkAIzGm.js → SessionsConfig-CAUcd5m1.js} +1 -1
- package/dist/assets/{chat-message-C5Gl-dCH.js → chat-message-BEmJpaTS.js} +1 -1
- package/dist/assets/index-B3MjcTn7.css +1 -0
- package/dist/assets/index-L3D03lUH.js +8 -0
- package/dist/assets/{label-D8zWKdqp.js → label-B1XIyXpX.js} +1 -1
- package/dist/assets/{page-layout-qAJ47LNQ.js → page-layout-x14rIiYp.js} +1 -1
- package/dist/assets/{popover-hyBGxpxS.js → popover-irxrNZ0V.js} +1 -1
- package/dist/assets/{security-config-BJYZSnCA.js → security-config-DsSj-9rH.js} +1 -1
- package/dist/assets/{skeleton-CUQLsNsM.js → skeleton-B46IL2X6.js} +1 -1
- package/dist/assets/{status-dot-DKcoD-iY.js → status-dot-CKkoylcD.js} +1 -1
- package/dist/assets/{switch-DtUdQxr_.js → switch-lU9yQaD-.js} +1 -1
- package/dist/assets/{tabs-custom-Dj1BWHGK.js → tabs-custom-0ADOTWdk.js} +1 -1
- package/dist/assets/{useConfirmDialog-nZdrtETU.js → useConfirmDialog-B5VIsGQY.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/App.test.tsx +59 -0
- package/src/App.tsx +59 -1
- package/src/api/client.test.ts +12 -0
- package/src/api/client.ts +4 -2
- package/src/api/config.ts +1 -1
- package/src/components/chat/chat-composer-state.test.ts +74 -0
- package/src/components/chat/chat-composer-state.ts +41 -15
- package/src/components/chat/chat-stream/types.ts +2 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +12 -2
- package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +26 -9
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +1 -1
- package/src/hooks/use-auth.test.ts +15 -0
- package/src/hooks/use-auth.ts +22 -2
- package/src/lib/i18n.ts +2 -0
- package/src/transport/local.transport.ts +28 -7
- package/src/transport/remote.transport.test.ts +1 -1
- package/src/transport/remote.transport.ts +5 -2
- package/src/transport/transport.types.ts +1 -0
- package/dist/assets/index-BTt_JlNV.css +0 -1
- package/dist/assets/index-JN3V84h_.js +0 -8
package/src/App.test.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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 AppContent from '@/App';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
refetch: vi.fn(),
|
|
8
|
+
useAuthStatus: vi.fn()
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('@/hooks/use-auth', () => ({
|
|
12
|
+
useAuthStatus: mocks.useAuthStatus
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe('App auth bootstrap', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mocks.refetch.mockReset();
|
|
18
|
+
mocks.useAuthStatus.mockReset();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('shows an actionable error state instead of staying blank when auth bootstrap fails', async () => {
|
|
22
|
+
const user = userEvent.setup();
|
|
23
|
+
mocks.useAuthStatus.mockReturnValue({
|
|
24
|
+
isLoading: false,
|
|
25
|
+
isError: true,
|
|
26
|
+
isRefetching: false,
|
|
27
|
+
error: new Error('Timed out waiting for remote request response after 5000ms: GET /api/auth/status'),
|
|
28
|
+
refetch: mocks.refetch,
|
|
29
|
+
data: undefined
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
render(<AppContent />);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByRole('heading', { name: /load authentication status/i })).toBeTruthy();
|
|
35
|
+
expect(screen.getByText('Timed out waiting for remote request response after 5000ms: GET /api/auth/status')).toBeTruthy();
|
|
36
|
+
|
|
37
|
+
await user.click(screen.getByRole('button', { name: /retry/i }));
|
|
38
|
+
|
|
39
|
+
expect(mocks.refetch).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows a startup state while the local UI service is still booting', () => {
|
|
43
|
+
mocks.useAuthStatus.mockReturnValue({
|
|
44
|
+
isLoading: true,
|
|
45
|
+
isError: false,
|
|
46
|
+
isRefetching: false,
|
|
47
|
+
failureCount: 1,
|
|
48
|
+
failureReason: new Error('Failed to fetch'),
|
|
49
|
+
error: null,
|
|
50
|
+
refetch: mocks.refetch,
|
|
51
|
+
data: undefined
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
render(<AppContent />);
|
|
55
|
+
|
|
56
|
+
expect(screen.getByRole('heading', { name: /waiting for the local ui service to start/i })).toBeTruthy();
|
|
57
|
+
expect(screen.getByText('Failed to fetch')).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -3,8 +3,10 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|
|
3
3
|
import { AccountPanel } from '@/account/components/account-panel';
|
|
4
4
|
import { appQueryClient } from '@/app-query-client';
|
|
5
5
|
import { LoginPage } from '@/components/auth/login-page';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
6
7
|
import { AppLayout } from '@/components/layout/AppLayout';
|
|
7
8
|
import { useAuthStatus } from '@/hooks/use-auth';
|
|
9
|
+
import { t } from '@/lib/i18n';
|
|
8
10
|
import { useRealtimeQueryBridge } from '@/hooks/use-realtime-query-bridge';
|
|
9
11
|
import { AppPresenterProvider } from '@/presenter/app-presenter-context';
|
|
10
12
|
import { Toaster } from 'sonner';
|
|
@@ -31,6 +33,46 @@ function LazyRoute({ children }: { children: JSX.Element }) {
|
|
|
31
33
|
return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function AuthBootstrapErrorState(props: {
|
|
37
|
+
message: string;
|
|
38
|
+
retrying: boolean;
|
|
39
|
+
onRetry: () => void;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<main className="flex min-h-screen items-center justify-center bg-secondary px-6 py-10">
|
|
43
|
+
<div className="w-full max-w-lg rounded-3xl border border-gray-200 bg-white p-8 shadow-card">
|
|
44
|
+
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">{t('authBrand')}</p>
|
|
45
|
+
<h1 className="mt-3 text-2xl font-semibold text-gray-900">{t('authStatusLoadFailed')}</h1>
|
|
46
|
+
<p className="mt-3 text-sm leading-6 text-gray-600">
|
|
47
|
+
{props.message}
|
|
48
|
+
</p>
|
|
49
|
+
<div className="mt-6 flex gap-3">
|
|
50
|
+
<Button onClick={props.onRetry} disabled={props.retrying}>
|
|
51
|
+
{t('authRetryStatus')}
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</main>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function AuthBootstrapLoadingState(props: { message?: string }) {
|
|
60
|
+
return (
|
|
61
|
+
<main className="flex min-h-screen items-center justify-center bg-secondary px-6 py-10">
|
|
62
|
+
<div className="w-full max-w-lg rounded-3xl border border-gray-200 bg-white p-8 shadow-card">
|
|
63
|
+
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">{t('authBrand')}</p>
|
|
64
|
+
<h1 className="mt-3 text-2xl font-semibold text-gray-900">{t('authStatusStarting')}</h1>
|
|
65
|
+
<p className="mt-3 text-sm leading-6 text-gray-600">{t('authStatusStartingHint')}</p>
|
|
66
|
+
{props.message ? (
|
|
67
|
+
<p className="mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-xs leading-5 text-gray-500">
|
|
68
|
+
{props.message}
|
|
69
|
+
</p>
|
|
70
|
+
) : null}
|
|
71
|
+
</div>
|
|
72
|
+
</main>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
34
76
|
function ProtectedApp() {
|
|
35
77
|
useRealtimeQueryBridge(appQueryClient);
|
|
36
78
|
|
|
@@ -72,7 +114,23 @@ function AuthGate() {
|
|
|
72
114
|
const authStatus = useAuthStatus();
|
|
73
115
|
|
|
74
116
|
if (authStatus.isLoading && !authStatus.isError) {
|
|
75
|
-
|
|
117
|
+
const failureMessage =
|
|
118
|
+
authStatus.failureCount > 0 && authStatus.failureReason instanceof Error
|
|
119
|
+
? authStatus.failureReason.message
|
|
120
|
+
: undefined;
|
|
121
|
+
return <AuthBootstrapLoadingState message={failureMessage} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (authStatus.isError) {
|
|
125
|
+
return (
|
|
126
|
+
<AuthBootstrapErrorState
|
|
127
|
+
message={authStatus.error instanceof Error ? authStatus.error.message : t('authStatusLoadFailed')}
|
|
128
|
+
retrying={authStatus.isRefetching}
|
|
129
|
+
onRetry={() => {
|
|
130
|
+
void authStatus.refetch();
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
76
134
|
}
|
|
77
135
|
|
|
78
136
|
if (authStatus.data?.enabled && !authStatus.data.authenticated) {
|
package/src/api/client.test.ts
CHANGED
|
@@ -30,6 +30,18 @@ describe('api/client', () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it('forwards timeout overrides to appClient.request', async () => {
|
|
34
|
+
mocks.request.mockResolvedValue({ ok: true });
|
|
35
|
+
|
|
36
|
+
await api.get<{ ok: boolean }>('/api/auth/status', { timeoutMs: 5000 });
|
|
37
|
+
|
|
38
|
+
expect(mocks.request).toHaveBeenCalledWith({
|
|
39
|
+
method: 'GET',
|
|
40
|
+
path: '/api/auth/status',
|
|
41
|
+
timeoutMs: 5000
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
it('parses JSON request bodies before sending to appClient.request', async () => {
|
|
34
46
|
mocks.request.mockResolvedValue({ success: true });
|
|
35
47
|
|
package/src/api/client.ts
CHANGED
|
@@ -3,13 +3,14 @@ import type { ApiResponse } from './types';
|
|
|
3
3
|
|
|
4
4
|
export async function requestApiResponse<T>(
|
|
5
5
|
endpoint: string,
|
|
6
|
-
options: RequestInit = {}
|
|
6
|
+
options: RequestInit & { timeoutMs?: number } = {}
|
|
7
7
|
): Promise<ApiResponse<T>> {
|
|
8
8
|
const method = (options.method || 'GET').toUpperCase();
|
|
9
9
|
try {
|
|
10
10
|
const data = await appClient.request<T>({
|
|
11
11
|
method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
12
12
|
path: endpoint,
|
|
13
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
13
14
|
...(options.body !== undefined ? { body: parseRequestBody(options.body) } : {})
|
|
14
15
|
});
|
|
15
16
|
return {
|
|
@@ -32,7 +33,8 @@ export async function requestApiResponse<T>(
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export const api = {
|
|
35
|
-
get: <T>(path: string
|
|
36
|
+
get: <T>(path: string, options: RequestInit & { timeoutMs?: number } = {}) =>
|
|
37
|
+
requestApiResponse<T>(path, { ...options, method: 'GET' }),
|
|
36
38
|
put: <T>(path: string, body: unknown) =>
|
|
37
39
|
requestApiResponse<T>(path, {
|
|
38
40
|
method: 'PUT',
|
package/src/api/config.ts
CHANGED
|
@@ -54,7 +54,7 @@ import type {
|
|
|
54
54
|
|
|
55
55
|
// GET /api/auth/status
|
|
56
56
|
export async function fetchAuthStatus(): Promise<AuthStatusView> {
|
|
57
|
-
const response = await api.get<AuthStatusView>('/api/auth/status');
|
|
57
|
+
const response = await api.get<AuthStatusView>('/api/auth/status', { timeoutMs: 5_000 });
|
|
58
58
|
if (!response.ok) {
|
|
59
59
|
throw new Error(response.error.message);
|
|
60
60
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createChatComposerTextNode, createChatComposerTokenNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import { deriveNcpMessagePartsFromComposer } from '@/components/chat/chat-composer-state';
|
|
3
|
+
|
|
4
|
+
describe('deriveNcpMessagePartsFromComposer', () => {
|
|
5
|
+
it('preserves interleaved text and image token order while skipping skill tokens', () => {
|
|
6
|
+
const parts = deriveNcpMessagePartsFromComposer(
|
|
7
|
+
[
|
|
8
|
+
createChatComposerTextNode('before '),
|
|
9
|
+
createChatComposerTokenNode({
|
|
10
|
+
tokenKind: 'file',
|
|
11
|
+
tokenKey: 'image-1',
|
|
12
|
+
label: 'one.png'
|
|
13
|
+
}),
|
|
14
|
+
createChatComposerTextNode(' between '),
|
|
15
|
+
createChatComposerTokenNode({
|
|
16
|
+
tokenKind: 'skill',
|
|
17
|
+
tokenKey: 'web-search',
|
|
18
|
+
label: 'Web Search'
|
|
19
|
+
}),
|
|
20
|
+
createChatComposerTextNode('after'),
|
|
21
|
+
createChatComposerTokenNode({
|
|
22
|
+
tokenKind: 'file',
|
|
23
|
+
tokenKey: 'image-2',
|
|
24
|
+
label: 'two.png'
|
|
25
|
+
})
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
id: 'image-1',
|
|
30
|
+
name: 'one.png',
|
|
31
|
+
mimeType: 'image/png',
|
|
32
|
+
contentBase64: 'aW1hZ2UtMQ==',
|
|
33
|
+
sizeBytes: 10
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'image-2',
|
|
37
|
+
name: 'two.png',
|
|
38
|
+
mimeType: 'image/png',
|
|
39
|
+
contentBase64: 'aW1hZ2UtMg==',
|
|
40
|
+
sizeBytes: 12
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(parts).toEqual([
|
|
46
|
+
{
|
|
47
|
+
type: 'text',
|
|
48
|
+
text: 'before '
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: 'file',
|
|
52
|
+
name: 'one.png',
|
|
53
|
+
mimeType: 'image/png',
|
|
54
|
+
contentBase64: 'aW1hZ2UtMQ==',
|
|
55
|
+
sizeBytes: 10
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: 'text',
|
|
59
|
+
text: ' between '
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: 'after'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'file',
|
|
67
|
+
name: 'two.png',
|
|
68
|
+
mimeType: 'image/png',
|
|
69
|
+
contentBase64: 'aW1hZ2UtMg==',
|
|
70
|
+
sizeBytes: 12
|
|
71
|
+
}
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpMessagePart } from '@nextclaw/ncp';
|
|
2
3
|
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
3
4
|
import {
|
|
4
5
|
createChatComposerTokenNode,
|
|
@@ -62,24 +63,10 @@ export function syncComposerAttachments(
|
|
|
62
63
|
attachments: readonly NcpDraftAttachment[]
|
|
63
64
|
): ChatComposerNode[] {
|
|
64
65
|
const nextAttachmentIds = new Set(attachments.map((attachment) => attachment.id));
|
|
65
|
-
|
|
66
|
+
return removeChatComposerTokenNodes(
|
|
66
67
|
nodes,
|
|
67
68
|
(node) => node.tokenKind === 'file' && !nextAttachmentIds.has(node.tokenKey)
|
|
68
69
|
);
|
|
69
|
-
const existingAttachmentIds = extractChatComposerTokenKeys(prunedNodes, 'file');
|
|
70
|
-
const appendedNodes = attachments
|
|
71
|
-
.filter((attachment) => !existingAttachmentIds.includes(attachment.id))
|
|
72
|
-
.map((attachment) =>
|
|
73
|
-
createChatComposerTokenNode({
|
|
74
|
-
tokenKind: 'file',
|
|
75
|
-
tokenKey: attachment.id,
|
|
76
|
-
label: attachment.name
|
|
77
|
-
})
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
return appendedNodes.length === 0
|
|
81
|
-
? prunedNodes
|
|
82
|
-
: normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
|
|
83
70
|
}
|
|
84
71
|
|
|
85
72
|
export function pruneComposerAttachments(
|
|
@@ -89,3 +76,42 @@ export function pruneComposerAttachments(
|
|
|
89
76
|
const selectedIds = new Set(deriveSelectedAttachmentIdsFromComposer(nodes));
|
|
90
77
|
return attachments.filter((attachment) => selectedIds.has(attachment.id));
|
|
91
78
|
}
|
|
79
|
+
|
|
80
|
+
export function deriveNcpMessagePartsFromComposer(
|
|
81
|
+
nodes: ChatComposerNode[],
|
|
82
|
+
attachments: readonly NcpDraftAttachment[]
|
|
83
|
+
): NcpMessagePart[] {
|
|
84
|
+
const attachmentById = new Map(attachments.map((attachment) => [attachment.id, attachment]));
|
|
85
|
+
const parts: NcpMessagePart[] = [];
|
|
86
|
+
|
|
87
|
+
for (const node of nodes) {
|
|
88
|
+
if (node.type === 'text') {
|
|
89
|
+
if (node.text.length > 0) {
|
|
90
|
+
parts.push({
|
|
91
|
+
type: 'text',
|
|
92
|
+
text: node.text
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (node.tokenKind !== 'file') {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const attachment = attachmentById.get(node.tokenKey);
|
|
103
|
+
if (!attachment) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
parts.push({
|
|
108
|
+
type: 'file',
|
|
109
|
+
name: attachment.name,
|
|
110
|
+
mimeType: attachment.mimeType,
|
|
111
|
+
contentBase64: attachment.contentBase64,
|
|
112
|
+
sizeBytes: attachment.sizeBytes
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return parts;
|
|
117
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpMessagePart } from '@nextclaw/ncp';
|
|
2
3
|
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
3
4
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
4
5
|
import type {
|
|
@@ -20,6 +21,7 @@ export type SendMessageParams = {
|
|
|
20
21
|
thinkingLevel?: ThinkingLevel;
|
|
21
22
|
requestedSkills?: string[];
|
|
22
23
|
attachments?: NcpDraftAttachment[];
|
|
24
|
+
parts?: NcpMessagePart[];
|
|
23
25
|
stopSupported?: boolean;
|
|
24
26
|
stopReason?: string;
|
|
25
27
|
restoreDraftOnError?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { ChatInputBar } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import { ChatInputBar, type ChatInputBarHandle } from '@nextclaw/agent-chat-ui';
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT,
|
|
5
5
|
DEFAULT_NCP_IMAGE_ATTACHMENT_MAX_BYTES,
|
|
@@ -76,6 +76,7 @@ export function ChatInputBarContainer() {
|
|
|
76
76
|
const { language } = useI18n();
|
|
77
77
|
const snapshot = useChatInputStore((state) => state.snapshot);
|
|
78
78
|
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
|
79
|
+
const inputBarRef = useRef<ChatInputBarHandle | null>(null);
|
|
79
80
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
80
81
|
|
|
81
82
|
const officialSkillBadgeLabel = useMemo(() => {
|
|
@@ -150,7 +151,15 @@ export function ChatInputBarContainer() {
|
|
|
150
151
|
}
|
|
151
152
|
const result = await readFilesAsNcpDraftAttachments(files);
|
|
152
153
|
if (result.attachments.length > 0) {
|
|
153
|
-
presenter.chatInputManager.addAttachments?.(result.attachments);
|
|
154
|
+
const insertedAttachments = presenter.chatInputManager.addAttachments?.(result.attachments) ?? [];
|
|
155
|
+
if (insertedAttachments.length > 0) {
|
|
156
|
+
inputBarRef.current?.insertFileTokens(
|
|
157
|
+
insertedAttachments.map((attachment) => ({
|
|
158
|
+
tokenKey: attachment.id,
|
|
159
|
+
label: attachment.name
|
|
160
|
+
}))
|
|
161
|
+
);
|
|
162
|
+
}
|
|
154
163
|
}
|
|
155
164
|
if (result.rejected.length > 0) {
|
|
156
165
|
showAttachmentError(result.rejected[0].reason);
|
|
@@ -197,6 +206,7 @@ export function ChatInputBarContainer() {
|
|
|
197
206
|
return (
|
|
198
207
|
<>
|
|
199
208
|
<ChatInputBar
|
|
209
|
+
ref={inputBarRef}
|
|
200
210
|
composer={{
|
|
201
211
|
nodes: snapshot.composerNodes,
|
|
202
212
|
placeholder: textareaPlaceholder,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createChatComposerNodesFromDraft,
|
|
8
8
|
createInitialChatComposerNodes,
|
|
9
9
|
deriveChatComposerDraft,
|
|
10
|
+
deriveNcpMessagePartsFromComposer,
|
|
10
11
|
deriveSelectedSkillsFromComposer,
|
|
11
12
|
pruneComposerAttachments,
|
|
12
13
|
syncComposerAttachments,
|
|
@@ -24,6 +25,14 @@ import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState'
|
|
|
24
25
|
export class NcpChatInputManager {
|
|
25
26
|
private readonly sessionPreferenceSync = new ChatSessionPreferenceSync(updateNcpSession);
|
|
26
27
|
|
|
28
|
+
private buildAttachmentSignature = (attachment: NcpDraftAttachment): string =>
|
|
29
|
+
[
|
|
30
|
+
attachment.name,
|
|
31
|
+
attachment.mimeType,
|
|
32
|
+
String(attachment.sizeBytes),
|
|
33
|
+
attachment.contentBase64,
|
|
34
|
+
].join(':');
|
|
35
|
+
|
|
27
36
|
constructor(
|
|
28
37
|
private uiManager: ChatUiManager,
|
|
29
38
|
private streamActionsManager: ChatStreamActionsManager,
|
|
@@ -77,12 +86,7 @@ export class NcpChatInputManager {
|
|
|
77
86
|
const seen = new Set<string>();
|
|
78
87
|
const output: NcpDraftAttachment[] = [];
|
|
79
88
|
for (const attachment of attachments) {
|
|
80
|
-
const signature =
|
|
81
|
-
attachment.name,
|
|
82
|
-
attachment.mimeType,
|
|
83
|
-
String(attachment.sizeBytes),
|
|
84
|
-
attachment.contentBase64,
|
|
85
|
-
].join(':');
|
|
89
|
+
const signature = this.buildAttachmentSignature(attachment);
|
|
86
90
|
if (seen.has(signature)) {
|
|
87
91
|
continue;
|
|
88
92
|
}
|
|
@@ -125,14 +129,22 @@ export class NcpChatInputManager {
|
|
|
125
129
|
this.syncComposerSnapshot(value);
|
|
126
130
|
};
|
|
127
131
|
|
|
128
|
-
addAttachments = (attachments: NcpDraftAttachment[]) => {
|
|
132
|
+
addAttachments = (attachments: NcpDraftAttachment[]): NcpDraftAttachment[] => {
|
|
129
133
|
if (attachments.length === 0) {
|
|
130
|
-
return;
|
|
134
|
+
return [];
|
|
131
135
|
}
|
|
132
136
|
const snapshot = useChatInputStore.getState().snapshot;
|
|
137
|
+
const existingSignatures = new Set(snapshot.attachments.map(this.buildAttachmentSignature));
|
|
133
138
|
const nextAttachments = this.dedupeAttachments([...snapshot.attachments, ...attachments]);
|
|
139
|
+
const insertedAttachments = nextAttachments.filter(
|
|
140
|
+
(attachment) => !existingSignatures.has(this.buildAttachmentSignature(attachment))
|
|
141
|
+
);
|
|
142
|
+
if (insertedAttachments.length === 0) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
134
145
|
const nextNodes = syncComposerAttachments(snapshot.composerNodes, nextAttachments);
|
|
135
146
|
this.syncComposerSnapshotWithAttachments(nextNodes, nextAttachments);
|
|
147
|
+
return insertedAttachments;
|
|
136
148
|
};
|
|
137
149
|
|
|
138
150
|
restoreComposerState = (nodes: ChatComposerNode[], attachments: NcpDraftAttachment[]) => {
|
|
@@ -155,7 +167,11 @@ export class NcpChatInputManager {
|
|
|
155
167
|
const sessionSnapshot = useChatSessionListStore.getState().snapshot;
|
|
156
168
|
const message = inputSnapshot.draft.trim();
|
|
157
169
|
const attachments = inputSnapshot.attachments;
|
|
158
|
-
|
|
170
|
+
const parts = deriveNcpMessagePartsFromComposer(inputSnapshot.composerNodes, attachments);
|
|
171
|
+
const hasSendableContent = parts.some(
|
|
172
|
+
(part) => part.type !== 'text' || part.text.trim().length > 0
|
|
173
|
+
);
|
|
174
|
+
if (!hasSendableContent) {
|
|
159
175
|
return;
|
|
160
176
|
}
|
|
161
177
|
const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
|
|
@@ -174,6 +190,7 @@ export class NcpChatInputManager {
|
|
|
174
190
|
stopSupported: true,
|
|
175
191
|
requestedSkills,
|
|
176
192
|
attachments,
|
|
193
|
+
parts,
|
|
177
194
|
restoreDraftOnError: true,
|
|
178
195
|
composerNodes
|
|
179
196
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
adaptNcpMessageToUiMessage,
|
|
2
3
|
adaptNcpSessionSummary,
|
|
3
4
|
buildNcpSessionRunStatusByKey,
|
|
4
5
|
readNcpSessionPreferredThinking
|
|
@@ -40,6 +41,45 @@ describe('adaptNcpSessionSummary', () => {
|
|
|
40
41
|
});
|
|
41
42
|
});
|
|
42
43
|
|
|
44
|
+
describe('adaptNcpMessageToUiMessage', () => {
|
|
45
|
+
it('preserves mixed text and image part order for message rendering', () => {
|
|
46
|
+
const adapted = adaptNcpMessageToUiMessage({
|
|
47
|
+
id: 'ncp-message-1',
|
|
48
|
+
sessionId: 'ncp-session-1',
|
|
49
|
+
role: 'user',
|
|
50
|
+
status: 'final',
|
|
51
|
+
timestamp: '2026-03-25T00:00:00.000Z',
|
|
52
|
+
parts: [
|
|
53
|
+
{ type: 'text', text: 'before ' },
|
|
54
|
+
{
|
|
55
|
+
type: 'file',
|
|
56
|
+
name: 'sample.png',
|
|
57
|
+
mimeType: 'image/png',
|
|
58
|
+
contentBase64: 'ZmFrZS1pbWFnZQ==',
|
|
59
|
+
sizeBytes: 10
|
|
60
|
+
},
|
|
61
|
+
{ type: 'text', text: ' after' }
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(adapted.parts).toEqual([
|
|
66
|
+
{
|
|
67
|
+
type: 'text',
|
|
68
|
+
text: 'before '
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'file',
|
|
72
|
+
mimeType: 'image/png',
|
|
73
|
+
data: 'ZmFrZS1pbWFnZQ=='
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'text',
|
|
77
|
+
text: ' after'
|
|
78
|
+
}
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
43
83
|
describe('readNcpSessionPreferredThinking', () => {
|
|
44
84
|
it('normalizes persisted thinking metadata for UI hydration', () => {
|
|
45
85
|
const thinking = readNcpSessionPreferredThinking(
|
|
@@ -14,7 +14,7 @@ export type ChatInputManagerLike = {
|
|
|
14
14
|
syncSnapshot: (patch: Record<string, unknown>) => void;
|
|
15
15
|
setDraft: (next: SetStateAction<string>) => void;
|
|
16
16
|
setComposerNodes: (next: SetStateAction<ChatComposerNode[]>) => void;
|
|
17
|
-
addAttachments?: (attachments: NcpDraftAttachment[]) =>
|
|
17
|
+
addAttachments?: (attachments: NcpDraftAttachment[]) => NcpDraftAttachment[];
|
|
18
18
|
restoreComposerState?: (
|
|
19
19
|
nodes: ChatComposerNode[],
|
|
20
20
|
attachments: NcpDraftAttachment[]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isRetryableAuthBootstrapError } from '@/hooks/use-auth';
|
|
3
|
+
|
|
4
|
+
describe('isRetryableAuthBootstrapError', () => {
|
|
5
|
+
it('treats transient bootstrap fetch failures as retryable', () => {
|
|
6
|
+
expect(isRetryableAuthBootstrapError(new Error('Failed to fetch'))).toBe(true);
|
|
7
|
+
expect(isRetryableAuthBootstrapError(new Error('Timed out waiting for remote request response after 5000ms'))).toBe(true);
|
|
8
|
+
expect(isRetryableAuthBootstrapError(new Error('connect ECONNREFUSED 127.0.0.1:18792'))).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('does not retry non-error values or permanent failures', () => {
|
|
12
|
+
expect(isRetryableAuthBootstrapError('Failed to fetch')).toBe(false);
|
|
13
|
+
expect(isRetryableAuthBootstrapError(new Error('Authentication required.'))).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
package/src/hooks/use-auth.ts
CHANGED
|
@@ -10,13 +10,33 @@ import {
|
|
|
10
10
|
import { toast } from 'sonner';
|
|
11
11
|
import { t } from '@/lib/i18n';
|
|
12
12
|
|
|
13
|
+
const AUTH_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 1500, 2000, 3000, 4000] as const;
|
|
14
|
+
|
|
15
|
+
export function isRetryableAuthBootstrapError(error: unknown): boolean {
|
|
16
|
+
if (!(error instanceof Error)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const message = error.message.trim().toLowerCase();
|
|
21
|
+
return (
|
|
22
|
+
message.includes('failed to fetch') ||
|
|
23
|
+
message.includes('networkerror') ||
|
|
24
|
+
message.includes('network request failed') ||
|
|
25
|
+
message.includes('load failed') ||
|
|
26
|
+
message.includes('timed out') ||
|
|
27
|
+
message.includes('econnrefused') ||
|
|
28
|
+
message.includes('socket hang up')
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
export function useAuthStatus() {
|
|
14
33
|
return useQuery({
|
|
15
34
|
queryKey: ['auth-status'],
|
|
16
35
|
queryFn: fetchAuthStatus,
|
|
17
36
|
staleTime: 5_000,
|
|
18
|
-
retry:
|
|
19
|
-
|
|
37
|
+
retry: (failureCount, error) =>
|
|
38
|
+
failureCount < AUTH_BOOTSTRAP_RETRY_DELAYS_MS.length && isRetryableAuthBootstrapError(error),
|
|
39
|
+
retryDelay: (attemptIndex) => AUTH_BOOTSTRAP_RETRY_DELAYS_MS[Math.min(attemptIndex, AUTH_BOOTSTRAP_RETRY_DELAYS_MS.length - 1)],
|
|
20
40
|
refetchOnWindowFocus: true
|
|
21
41
|
});
|
|
22
42
|
}
|
package/src/lib/i18n.ts
CHANGED
|
@@ -390,6 +390,8 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
|
|
|
390
390
|
authDisabledSuccess: { zh: '认证已关闭', en: 'Authentication disabled' },
|
|
391
391
|
authRetryStatus: { zh: '重试', en: 'Retry' },
|
|
392
392
|
authStatusLoadFailed: { zh: '无法获取认证状态,请检查 UI 服务是否正常。', en: 'Failed to load authentication status. Check whether the UI server is healthy.' },
|
|
393
|
+
authStatusStarting: { zh: '正在等待本地 UI 服务启动...', en: 'Waiting for the local UI service to start...' },
|
|
394
|
+
authStatusStartingHint: { zh: '开发环境冷启动时,后端可能还在初始化插件、渠道和 MCP 服务。', en: 'During a cold dev start, the backend may still be initializing plugins, channels, and MCP services.' },
|
|
393
395
|
|
|
394
396
|
// Runtime
|
|
395
397
|
runtimePageTitle: { zh: '路由与运行时', en: 'Routing & Runtime' },
|
|
@@ -114,14 +114,35 @@ export class LocalAppTransport implements AppTransport {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
async request<T>(input: RequestInput): Promise<T> {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
const timeoutMs = Number.isFinite(input.timeoutMs) && (input.timeoutMs ?? 0) > 0
|
|
118
|
+
? Math.trunc(input.timeoutMs as number)
|
|
119
|
+
: null;
|
|
120
|
+
const controller = timeoutMs ? new AbortController() : null;
|
|
121
|
+
const timeoutId = timeoutMs
|
|
122
|
+
? window.setTimeout(() => controller?.abort(`Request timed out after ${timeoutMs}ms: ${input.method} ${input.path}`), timeoutMs)
|
|
123
|
+
: null;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const response = await requestRawApiResponse<T>(input.path, {
|
|
127
|
+
method: input.method,
|
|
128
|
+
...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
|
|
129
|
+
...(controller ? { signal: controller.signal } : {})
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw createTransportError(response, `Request failed for ${input.method} ${input.path}`);
|
|
133
|
+
}
|
|
134
|
+
return response.data;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (controller?.signal.aborted) {
|
|
137
|
+
const reason = controller.signal.reason;
|
|
138
|
+
throw new Error(typeof reason === 'string' && reason.trim() ? reason : `Request timed out: ${input.method} ${input.path}`);
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
} finally {
|
|
142
|
+
if (timeoutId !== null) {
|
|
143
|
+
window.clearTimeout(timeoutId);
|
|
144
|
+
}
|
|
123
145
|
}
|
|
124
|
-
return response.data;
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal> {
|
|
@@ -117,7 +117,7 @@ describe('RemoteSessionMultiplexTransport request path', () => {
|
|
|
117
117
|
path: '/api/sessions'
|
|
118
118
|
});
|
|
119
119
|
const timeoutExpectation = expect(requestPromise).rejects.toThrow(
|
|
120
|
-
'Timed out waiting for remote request response: GET /api/sessions'
|
|
120
|
+
'Timed out waiting for remote request response after 15000ms: GET /api/sessions'
|
|
121
121
|
);
|
|
122
122
|
|
|
123
123
|
const socket = MockWebSocket.instances[0];
|