@nextclaw/ui 0.10.2 → 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 +9 -0
- package/dist/assets/{ChannelsList-DSMuOmMG.js → ChannelsList-2FjU5fiD.js} +1 -1
- package/dist/assets/{ChatPage-do9TwNxj.js → ChatPage-ugiGAeYI.js} +19 -19
- package/dist/assets/{DocBrowser-BjoTblYl.js → DocBrowser-tH07yTO3.js} +1 -1
- package/dist/assets/{LogoBadge-2yDaYdxw.js → LogoBadge-BHszLcFS.js} +1 -1
- package/dist/assets/{MarketplacePage-DVVk4dlH.js → MarketplacePage-C7sTQxnk.js} +1 -1
- package/dist/assets/{McpMarketplacePage-B4WUzuLw.js → McpMarketplacePage-6pG1exmL.js} +1 -1
- package/dist/assets/{ModelConfig-Dr0eI9nN.js → ModelConfig-ChXV-3uT.js} +1 -1
- package/dist/assets/{ProvidersList-C7A-mIbe.js → ProvidersList-Bq6v0Arn.js} +1 -1
- package/dist/assets/{RemoteAccessPage-CI3Am3w1.js → RemoteAccessPage-BOWUBcqS.js} +1 -1
- package/dist/assets/{RuntimeConfig-DvSNVSs8.js → RuntimeConfig-DyVKq5bp.js} +1 -1
- package/dist/assets/{SearchConfig-B6TGIZow.js → SearchConfig-DLKJzszy.js} +1 -1
- package/dist/assets/{SecretsConfig-CpxaKU1j.js → SecretsConfig-D1fC-5yG.js} +1 -1
- package/dist/assets/{SessionsConfig-B-VHnv4G.js → SessionsConfig-CAUcd5m1.js} +1 -1
- package/dist/assets/{chat-message-BMqngrjp.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-s2ILtQeP.js → label-B1XIyXpX.js} +1 -1
- package/dist/assets/{page-layout-BX5Ro4Sj.js → page-layout-x14rIiYp.js} +1 -1
- package/dist/assets/{popover-qmNpQSIy.js → popover-irxrNZ0V.js} +1 -1
- package/dist/assets/{security-config--F-f-nDl.js → security-config-DsSj-9rH.js} +1 -1
- package/dist/assets/{skeleton-DthPOKSc.js → skeleton-B46IL2X6.js} +1 -1
- package/dist/assets/{status-dot-DWj7aUy8.js → status-dot-CKkoylcD.js} +1 -1
- package/dist/assets/{switch-62r7L4Lj.js → switch-lU9yQaD-.js} +1 -1
- package/dist/assets/{tabs-custom-DEmoGMsc.js → tabs-custom-0ADOTWdk.js} +1 -1
- package/dist/assets/{useConfirmDialog-DzT94nC_.js → useConfirmDialog-B5VIsGQY.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/App.test.tsx +18 -0
- package/src/App.tsx +22 -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 -1
- package/src/lib/i18n.ts +2 -0
- package/dist/assets/index-C6MeoecJ.js +0 -8
- package/dist/assets/index-DdXzLuNG.css +0 -1
package/src/App.tsx
CHANGED
|
@@ -56,6 +56,23 @@ function AuthBootstrapErrorState(props: {
|
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
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
|
+
|
|
59
76
|
function ProtectedApp() {
|
|
60
77
|
useRealtimeQueryBridge(appQueryClient);
|
|
61
78
|
|
|
@@ -97,7 +114,11 @@ function AuthGate() {
|
|
|
97
114
|
const authStatus = useAuthStatus();
|
|
98
115
|
|
|
99
116
|
if (authStatus.isLoading && !authStatus.isError) {
|
|
100
|
-
|
|
117
|
+
const failureMessage =
|
|
118
|
+
authStatus.failureCount > 0 && authStatus.failureReason instanceof Error
|
|
119
|
+
? authStatus.failureReason.message
|
|
120
|
+
: undefined;
|
|
121
|
+
return <AuthBootstrapLoadingState message={failureMessage} />;
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
if (authStatus.isError) {
|
|
@@ -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,12 +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:
|
|
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)],
|
|
19
40
|
refetchOnWindowFocus: true
|
|
20
41
|
});
|
|
21
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' },
|