@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/assets/{ChannelsList-BX7KqEk7.js → ChannelsList-2FjU5fiD.js} +1 -1
  3. package/dist/assets/{ChatPage-zXLBKIAY.js → ChatPage-ugiGAeYI.js} +19 -19
  4. package/dist/assets/{DocBrowser-Cdbh4cVD.js → DocBrowser-tH07yTO3.js} +1 -1
  5. package/dist/assets/{LogoBadge-4801esOJ.js → LogoBadge-BHszLcFS.js} +1 -1
  6. package/dist/assets/{MarketplacePage-GZgus0Or.js → MarketplacePage-C7sTQxnk.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-CAGGvoMo.js → McpMarketplacePage-6pG1exmL.js} +1 -1
  8. package/dist/assets/{ModelConfig-CfLYjQM3.js → ModelConfig-ChXV-3uT.js} +1 -1
  9. package/dist/assets/{ProvidersList-CEo1kdf-.js → ProvidersList-Bq6v0Arn.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-6GYzD7cc.js → RemoteAccessPage-BOWUBcqS.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-BZdbp8mH.js → RuntimeConfig-DyVKq5bp.js} +1 -1
  12. package/dist/assets/{SearchConfig-ifvYKix-.js → SearchConfig-DLKJzszy.js} +1 -1
  13. package/dist/assets/{SecretsConfig-tDPbhTeR.js → SecretsConfig-D1fC-5yG.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DhkAIzGm.js → SessionsConfig-CAUcd5m1.js} +1 -1
  15. package/dist/assets/{chat-message-C5Gl-dCH.js → chat-message-BEmJpaTS.js} +1 -1
  16. package/dist/assets/index-B3MjcTn7.css +1 -0
  17. package/dist/assets/index-L3D03lUH.js +8 -0
  18. package/dist/assets/{label-D8zWKdqp.js → label-B1XIyXpX.js} +1 -1
  19. package/dist/assets/{page-layout-qAJ47LNQ.js → page-layout-x14rIiYp.js} +1 -1
  20. package/dist/assets/{popover-hyBGxpxS.js → popover-irxrNZ0V.js} +1 -1
  21. package/dist/assets/{security-config-BJYZSnCA.js → security-config-DsSj-9rH.js} +1 -1
  22. package/dist/assets/{skeleton-CUQLsNsM.js → skeleton-B46IL2X6.js} +1 -1
  23. package/dist/assets/{status-dot-DKcoD-iY.js → status-dot-CKkoylcD.js} +1 -1
  24. package/dist/assets/{switch-DtUdQxr_.js → switch-lU9yQaD-.js} +1 -1
  25. package/dist/assets/{tabs-custom-Dj1BWHGK.js → tabs-custom-0ADOTWdk.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-nZdrtETU.js → useConfirmDialog-B5VIsGQY.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +3 -3
  29. package/src/App.test.tsx +59 -0
  30. package/src/App.tsx +59 -1
  31. package/src/api/client.test.ts +12 -0
  32. package/src/api/client.ts +4 -2
  33. package/src/api/config.ts +1 -1
  34. package/src/components/chat/chat-composer-state.test.ts +74 -0
  35. package/src/components/chat/chat-composer-state.ts +41 -15
  36. package/src/components/chat/chat-stream/types.ts +2 -0
  37. package/src/components/chat/containers/chat-input-bar.container.tsx +12 -2
  38. package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
  39. package/src/components/chat/ncp/ncp-chat-input.manager.ts +26 -9
  40. package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -0
  41. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -1
  42. package/src/hooks/use-auth.test.ts +15 -0
  43. package/src/hooks/use-auth.ts +22 -2
  44. package/src/lib/i18n.ts +2 -0
  45. package/src/transport/local.transport.ts +28 -7
  46. package/src/transport/remote.transport.test.ts +1 -1
  47. package/src/transport/remote.transport.ts +5 -2
  48. package/src/transport/transport.types.ts +1 -0
  49. package/dist/assets/index-BTt_JlNV.css +0 -1
  50. package/dist/assets/index-JN3V84h_.js +0 -8
@@ -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
- return <RouteFallback />;
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) {
@@ -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) => requestApiResponse<T>(path, { method: 'GET' }),
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
- const prunedNodes = removeChatComposerTokenNodes(
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,
@@ -194,6 +194,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
194
194
  sessionId: payload.sessionKey,
195
195
  text: payload.message,
196
196
  attachments: payload.attachments,
197
+ parts: payload.parts,
197
198
  metadata
198
199
  });
199
200
  if (!envelope) {
@@ -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
- if (!message && attachments.length === 0) {
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[]) => void;
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
+ });
@@ -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: 3,
19
- retryDelay: (attempt) => Math.min(1000 * attempt, 3000),
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 response = await requestRawApiResponse<T>(input.path, {
118
- method: input.method,
119
- ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {})
120
- });
121
- if (!response.ok) {
122
- throw createTransportError(response, `Request failed for ${input.method} ${input.path}`);
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];