@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{ChannelsList-Brc1qLSU.js → ChannelsList-bROKR37R.js} +1 -1
  3. package/dist/assets/ChatPage-B9dHVmrV.js +41 -0
  4. package/dist/assets/{DocBrowser-xLVf1p4L.js → DocBrowser-S-1-qnZQ.js} +1 -1
  5. package/dist/assets/{LogoBadge-CcTyimdr.js → LogoBadge-t1JzzCtI.js} +1 -1
  6. package/dist/assets/{MarketplacePage-Bk-qXxyh.js → MarketplacePage-CzIHYJpM.js} +2 -2
  7. package/dist/assets/{McpMarketplacePage-gFqAYekc.js → McpMarketplacePage-BTJdjNQ1.js} +1 -1
  8. package/dist/assets/{ModelConfig-DnKNTuw6.js → ModelConfig-BD4o3Kna.js} +1 -1
  9. package/dist/assets/{ProvidersList-Cjr8EFu_.js → ProvidersList-BOQArFRk.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-CYNQ53xu.js +1 -0
  11. package/dist/assets/{RuntimeConfig-CttN--Tv.js → RuntimeConfig-B0B73pye.js} +1 -1
  12. package/dist/assets/{SearchConfig-D-GzinsL.js → SearchConfig-CKy2QkAP.js} +1 -1
  13. package/dist/assets/{SecretsConfig-BvqQq4Ds.js → SecretsConfig-BpZLUu88.js} +2 -2
  14. package/dist/assets/{SessionsConfig-DbtnLmI6.js → SessionsConfig-CoFI6Fa2.js} +1 -1
  15. package/dist/assets/{chat-message-DYQjL1tD.js → chat-message-D3jZIASl.js} +1 -1
  16. package/dist/assets/index-CmGwUgcl.js +8 -0
  17. package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
  18. package/dist/assets/{label-DBSKOMGE.js → label-BOvIOmQx.js} +1 -1
  19. package/dist/assets/{page-layout-B5th9UzR.js → page-layout-PG3cwSpz.js} +1 -1
  20. package/dist/assets/popover-BB-kINz7.js +1 -0
  21. package/dist/assets/{security-config-D72JskP5.js → security-config-Bb6l-viE.js} +1 -1
  22. package/dist/assets/skeleton-CLSc5FYO.js +1 -0
  23. package/dist/assets/{status-dot-CU5ZpOn1.js → status-dot-Behu7kDZ.js} +1 -1
  24. package/dist/assets/{switch-BdaXEtXk.js → switch-CvNG9775.js} +1 -1
  25. package/dist/assets/{tabs-custom-BVhSoteN.js → tabs-custom-CUdBQO_7.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-Dugi9V-Z.js → useConfirmDialog-CLLe2uIJ.js} +1 -1
  27. package/dist/assets/{vendor-CmQZsDAE.js → vendor-TJ2hy_Lv.js} +87 -82
  28. package/dist/index.html +3 -3
  29. package/package.json +4 -4
  30. package/src/account/managers/account.manager.ts +8 -1
  31. package/src/account/stores/account.store.ts +3 -0
  32. package/src/api/api-base.ts +16 -0
  33. package/src/api/client.test.ts +69 -0
  34. package/src/api/client.ts +29 -87
  35. package/src/api/config.stream.test.ts +115 -0
  36. package/src/api/config.ts +49 -121
  37. package/src/api/raw-client.ts +87 -0
  38. package/src/components/chat/ChatSidebar.test.tsx +134 -1
  39. package/src/components/chat/ChatSidebar.tsx +87 -37
  40. package/src/components/chat/chat-session-label.service.ts +34 -0
  41. package/src/components/chat/chat-sidebar-session-item.tsx +147 -0
  42. package/src/components/chat/ncp/NcpChatPage.tsx +3 -10
  43. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +69 -0
  44. package/src/components/chat/ncp/ncp-app-client-fetch.ts +127 -0
  45. package/src/components/remote/RemoteAccessPage.test.tsx +103 -0
  46. package/src/components/remote/RemoteAccessPage.tsx +28 -93
  47. package/src/lib/i18n.remote.ts +20 -8
  48. package/src/remote/managers/remote-access.manager.ts +13 -0
  49. package/src/remote/remote-access-feedback.service.test.ts +75 -0
  50. package/src/remote/remote-access-feedback.service.ts +195 -0
  51. package/src/transport/app-client.test.ts +49 -0
  52. package/src/transport/app-client.ts +23 -7
  53. package/src/transport/local.transport.ts +3 -2
  54. package/src/transport/remote.transport.ts +7 -2
  55. package/dist/assets/ChatPage-DmGI776q.js +0 -38
  56. package/dist/assets/RemoteAccessPage-Rzi5a6Gc.js +0 -1
  57. package/dist/assets/index-ClLy_7T2.js +0 -8
  58. package/dist/assets/popover-BEIWRoeP.js +0 -1
  59. package/dist/assets/skeleton-B_Pn9x0i.js +0 -1
@@ -0,0 +1,69 @@
1
+ import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ request: vi.fn(),
5
+ openStream: vi.fn()
6
+ }));
7
+
8
+ vi.mock('@/transport', () => ({
9
+ appClient: {
10
+ request: mocks.request,
11
+ openStream: mocks.openStream
12
+ }
13
+ }));
14
+
15
+ describe('ncp-app-client-fetch', () => {
16
+ beforeEach(() => {
17
+ mocks.request.mockReset();
18
+ mocks.openStream.mockReset();
19
+ });
20
+
21
+ it('routes JSON requests through appClient.request', async () => {
22
+ mocks.request.mockResolvedValue({ stopped: true });
23
+ const fetchImpl = createNcpAppClientFetch();
24
+
25
+ const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/abort', {
26
+ method: 'POST',
27
+ headers: {
28
+ accept: 'application/json',
29
+ 'content-type': 'application/json'
30
+ },
31
+ body: JSON.stringify({ sessionId: 's1' })
32
+ });
33
+
34
+ expect(mocks.request).toHaveBeenCalledWith({
35
+ method: 'POST',
36
+ path: '/api/ncp/agent/abort',
37
+ body: { sessionId: 's1' }
38
+ });
39
+ expect(response.ok).toBe(true);
40
+ });
41
+
42
+ it('re-encodes appClient stream events as SSE frames', async () => {
43
+ mocks.openStream.mockImplementation(({ onEvent }) => {
44
+ onEvent({ name: 'ncp-event', payload: { type: 'message.chunk', payload: { text: 'hello' } } });
45
+ return {
46
+ finished: Promise.resolve(undefined),
47
+ cancel: vi.fn()
48
+ };
49
+ });
50
+ const fetchImpl = createNcpAppClientFetch();
51
+
52
+ const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/stream?sessionId=s1', {
53
+ method: 'GET',
54
+ headers: {
55
+ accept: 'text/event-stream'
56
+ }
57
+ });
58
+ const text = await response.text();
59
+
60
+ expect(mocks.openStream).toHaveBeenCalledWith({
61
+ method: 'GET',
62
+ path: '/api/ncp/agent/stream?sessionId=s1',
63
+ signal: undefined,
64
+ onEvent: expect.any(Function)
65
+ });
66
+ expect(text).toContain('event: ncp-event');
67
+ expect(text).toContain('"text":"hello"');
68
+ });
69
+ });
@@ -0,0 +1,127 @@
1
+ import { API_BASE } from '@/api/api-base';
2
+ import { appClient } from '@/transport';
3
+
4
+ type FetchLike = typeof fetch;
5
+
6
+ export function createNcpAppClientFetch(): FetchLike {
7
+ return async (input, init) => {
8
+ const request = toRequestSnapshot(input, init);
9
+ if (isSseRequest(request)) {
10
+ return createSseResponse(request);
11
+ }
12
+
13
+ try {
14
+ const data = await appClient.request<unknown>({
15
+ method: request.method,
16
+ path: request.path,
17
+ ...(request.body !== undefined ? { body: request.body } : {})
18
+ });
19
+ return new Response(JSON.stringify(data ?? {}), {
20
+ status: 200,
21
+ headers: {
22
+ 'content-type': 'application/json'
23
+ }
24
+ });
25
+ } catch (error) {
26
+ return new Response(error instanceof Error ? error.message : String(error), {
27
+ status: 500,
28
+ headers: {
29
+ 'content-type': 'text/plain; charset=utf-8'
30
+ }
31
+ });
32
+ }
33
+ };
34
+ }
35
+
36
+ type RequestSnapshot = {
37
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
38
+ path: string;
39
+ body?: unknown;
40
+ signal?: AbortSignal;
41
+ headers: Headers;
42
+ };
43
+
44
+ function toRequestSnapshot(input: URL | string | Request, init?: RequestInit): RequestSnapshot {
45
+ const request = input instanceof Request ? input : null;
46
+ const url = new URL(
47
+ typeof input === 'string'
48
+ ? input
49
+ : input instanceof URL
50
+ ? input.toString()
51
+ : input.url,
52
+ API_BASE
53
+ );
54
+ const headers = new Headers(init?.headers ?? request?.headers);
55
+ const method = ((init?.method ?? request?.method ?? 'GET').toUpperCase()) as RequestSnapshot['method'];
56
+ return {
57
+ method,
58
+ path: `${url.pathname}${url.search}`,
59
+ body: parseRequestBody(init?.body),
60
+ signal: init?.signal ?? request?.signal ?? undefined,
61
+ headers
62
+ };
63
+ }
64
+
65
+ function parseRequestBody(body: BodyInit | null | undefined): unknown {
66
+ if (body === undefined || body === null) {
67
+ return undefined;
68
+ }
69
+ if (typeof body === 'string') {
70
+ try {
71
+ return JSON.parse(body);
72
+ } catch {
73
+ return body;
74
+ }
75
+ }
76
+ return body;
77
+ }
78
+
79
+ function isSseRequest(request: RequestSnapshot): boolean {
80
+ const accept = request.headers.get('accept')?.toLowerCase() ?? '';
81
+ return accept.includes('text/event-stream');
82
+ }
83
+
84
+ function createSseResponse(request: RequestSnapshot): Response {
85
+ const encoder = new TextEncoder();
86
+ let session: ReturnType<typeof appClient.openStream<unknown>> | null = null;
87
+
88
+ const stream = new ReadableStream<Uint8Array>({
89
+ start(controller) {
90
+ session = appClient.openStream<unknown>({
91
+ method: request.method === 'GET' ? 'GET' : 'POST',
92
+ path: request.path,
93
+ ...(request.body !== undefined ? { body: request.body } : {}),
94
+ signal: request.signal,
95
+ onEvent: (event) => {
96
+ controller.enqueue(encoder.encode(encodeSseFrame(event.name, event.payload)));
97
+ }
98
+ });
99
+
100
+ void session.finished
101
+ .then(() => {
102
+ controller.close();
103
+ })
104
+ .catch((error) => {
105
+ controller.enqueue(encoder.encode(encodeSseFrame('error', {
106
+ message: error instanceof Error ? error.message : String(error)
107
+ })));
108
+ controller.close();
109
+ });
110
+ },
111
+ cancel() {
112
+ session?.cancel();
113
+ }
114
+ });
115
+
116
+ return new Response(stream, {
117
+ status: 200,
118
+ headers: {
119
+ 'content-type': 'text/event-stream'
120
+ }
121
+ });
122
+ }
123
+
124
+ function encodeSseFrame(event: string, payload: unknown): string {
125
+ const data = payload === undefined ? '' : JSON.stringify(payload);
126
+ return `event: ${event}\ndata: ${data}\n\n`;
127
+ }
@@ -0,0 +1,103 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { RemoteAccessPage } from '@/components/remote/RemoteAccessPage';
4
+ import { setLanguage } from '@/lib/i18n';
5
+ import { useRemoteAccessStore } from '@/remote/stores/remote-access.store';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ reauthorizeRemoteAccess: vi.fn(),
9
+ repairRemoteAccess: vi.fn(),
10
+ enableRemoteAccess: vi.fn(),
11
+ disableRemoteAccess: vi.fn(),
12
+ syncStatus: vi.fn(),
13
+ openNextClawWeb: vi.fn(),
14
+ statusQuery: {
15
+ data: undefined as unknown,
16
+ isLoading: false
17
+ }
18
+ }));
19
+
20
+ vi.mock('@/hooks/useRemoteAccess', () => ({
21
+ useRemoteStatus: () => mocks.statusQuery
22
+ }));
23
+
24
+ vi.mock('@/presenter/app-presenter-context', () => ({
25
+ useAppPresenter: () => ({
26
+ remoteAccessManager: {
27
+ reauthorizeRemoteAccess: mocks.reauthorizeRemoteAccess,
28
+ repairRemoteAccess: mocks.repairRemoteAccess,
29
+ enableRemoteAccess: mocks.enableRemoteAccess,
30
+ disableRemoteAccess: mocks.disableRemoteAccess,
31
+ syncStatus: mocks.syncStatus
32
+ },
33
+ accountManager: {
34
+ openNextClawWeb: mocks.openNextClawWeb
35
+ }
36
+ })
37
+ }));
38
+
39
+ describe('RemoteAccessPage', () => {
40
+ beforeEach(() => {
41
+ setLanguage('zh');
42
+ mocks.reauthorizeRemoteAccess.mockReset();
43
+ mocks.repairRemoteAccess.mockReset();
44
+ mocks.enableRemoteAccess.mockReset();
45
+ mocks.disableRemoteAccess.mockReset();
46
+ mocks.syncStatus.mockReset();
47
+ mocks.openNextClawWeb.mockReset();
48
+ useRemoteAccessStore.setState({
49
+ enabled: false,
50
+ deviceName: '',
51
+ platformApiBase: '',
52
+ draftTouched: false,
53
+ advancedOpen: false,
54
+ actionLabel: null,
55
+ doctor: null
56
+ });
57
+ mocks.statusQuery = {
58
+ data: {
59
+ account: {
60
+ loggedIn: true,
61
+ email: 'user@example.com',
62
+ apiBase: 'https://ai-gateway-api.nextclaw.io/v1',
63
+ platformBase: 'https://ai-gateway-api.nextclaw.io'
64
+ },
65
+ settings: {
66
+ enabled: true,
67
+ deviceName: 'MacBook Pro',
68
+ platformApiBase: 'https://ai-gateway-api.nextclaw.io/v1'
69
+ },
70
+ service: {
71
+ running: true,
72
+ currentProcess: false
73
+ },
74
+ localOrigin: 'http://127.0.0.1:55667',
75
+ configuredEnabled: true,
76
+ platformBase: 'https://ai-gateway-api.nextclaw.io',
77
+ runtime: {
78
+ enabled: true,
79
+ mode: 'service',
80
+ state: 'error',
81
+ lastError: 'Invalid or expired token.',
82
+ updatedAt: '2026-03-23T00:00:00.000Z'
83
+ }
84
+ },
85
+ isLoading: false
86
+ };
87
+ });
88
+
89
+ it('shows a user-facing reauthorization flow instead of raw token errors', async () => {
90
+ const user = userEvent.setup();
91
+
92
+ render(<RemoteAccessPage />);
93
+
94
+ expect(screen.getByText('登录已过期,请重新登录 NextClaw')).toBeTruthy();
95
+ expect(screen.getByText('重新登录并恢复远程访问')).toBeTruthy();
96
+ expect(screen.queryByText('Invalid or expired token.')).toBeNull();
97
+
98
+ await user.click(screen.getByRole('button', { name: '重新登录并恢复远程访问' }));
99
+
100
+ expect(mocks.reauthorizeRemoteAccess).toHaveBeenCalledTimes(1);
101
+ expect(mocks.repairRemoteAccess).not.toHaveBeenCalled();
102
+ });
103
+ });
@@ -1,4 +1,3 @@
1
- import type { RemoteAccessView } from '@/api/remote.types';
2
1
  import { PageHeader, PageLayout } from '@/components/layout/page-layout';
3
2
  import { Button } from '@/components/ui/button';
4
3
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -7,102 +6,33 @@ import { useRemoteStatus } from '@/hooks/useRemoteAccess';
7
6
  import { formatDateTime, t } from '@/lib/i18n';
8
7
  import { useAppPresenter } from '@/presenter/app-presenter-context';
9
8
  import { resolveRemoteWebBase } from '@/remote/remote-access.query';
9
+ import { buildRemoteAccessFeedbackView } from '@/remote/remote-access-feedback.service';
10
10
  import { useRemoteAccessStore } from '@/remote/stores/remote-access.store';
11
11
  import { Laptop, RefreshCcw, SquareArrowOutUpRight } from 'lucide-react';
12
12
  import { useEffect, useMemo } from 'react';
13
13
 
14
- type RemoteHeroView = {
15
- badgeStatus: 'active' | 'inactive' | 'ready' | 'setup' | 'warning';
16
- badgeLabel: string;
17
- title: string;
18
- description: string;
19
- };
20
-
21
14
  function KeyValueRow(props: { label: string; value?: string | number | null; muted?: boolean }) {
22
- const value = props.value === undefined || props.value === null || props.value === '' ? '-' : String(props.value);
15
+ const { label, muted, value: rawValue } = props;
16
+ const value = rawValue === undefined || rawValue === null || rawValue === '' ? '-' : String(rawValue);
23
17
  return (
24
18
  <div className="flex items-start justify-between gap-4 py-2 text-sm">
25
- <span className="text-gray-500">{props.label}</span>
26
- <span className={props.muted ? 'text-right text-gray-500' : 'text-right text-gray-900'}>{value}</span>
19
+ <span className="text-gray-500">{label}</span>
20
+ <span className={muted ? 'text-right text-gray-500' : 'text-right text-gray-900'}>{value}</span>
27
21
  </div>
28
22
  );
29
23
  }
30
24
 
31
- function buildHeroView(status: RemoteAccessView | undefined): RemoteHeroView {
32
- if (!status?.account.loggedIn) {
33
- return {
34
- badgeStatus: 'setup',
35
- badgeLabel: t('statusSetup'),
36
- title: t('remoteStatusNeedsSignIn'),
37
- description: t('remoteStatusNeedsSignInDescription')
38
- };
39
- }
40
-
41
- if (!status.settings.enabled) {
42
- return {
43
- badgeStatus: 'inactive',
44
- badgeLabel: t('statusInactive'),
45
- title: t('remoteStatusNeedsEnable'),
46
- description: t('remoteStatusNeedsEnableDescription')
47
- };
48
- }
49
-
50
- if (!status.service.running) {
51
- return {
52
- badgeStatus: 'warning',
53
- badgeLabel: t('remoteServiceStopped'),
54
- title: t('remoteStatusNeedsServiceTitle'),
55
- description: t('remoteStatusNeedsServiceDescription')
56
- };
57
- }
58
-
59
- if (status.runtime?.state === 'connected') {
60
- return {
61
- badgeStatus: 'ready',
62
- badgeLabel: t('statusReady'),
63
- title: t('remoteStatusReadyTitle'),
64
- description: t('remoteStatusReadyDescription')
65
- };
66
- }
67
-
68
- if (status.runtime?.state === 'connecting') {
69
- return {
70
- badgeStatus: 'active',
71
- badgeLabel: t('connecting'),
72
- title: t('remoteStatusConnectingTitle'),
73
- description: t('remoteStatusConnectingDescription')
74
- };
75
- }
76
-
77
- return {
78
- badgeStatus: 'warning',
79
- badgeLabel: t('remoteStateDisconnected'),
80
- title: t('remoteStatusIssueTitle'),
81
- description: t('remoteStatusIssueDescription')
82
- };
83
- }
84
-
85
- function buildIssueHint(status: RemoteAccessView | undefined) {
86
- if (!status?.settings.enabled) {
87
- return null;
88
- }
89
- if (!status.service.running) {
90
- return t('remoteStatusIssueDetailServiceStopped');
91
- }
92
- return status.runtime?.lastError?.trim() || t('remoteStatusIssueDetailGeneric');
93
- }
94
25
 
95
26
  export function RemoteAccessPage() {
96
27
  const presenter = useAppPresenter();
97
28
  const remoteStatus = useRemoteStatus();
98
29
  const status = remoteStatus.data;
99
30
  const actionLabel = useRemoteAccessStore((state) => state.actionLabel);
100
- const heroView = useMemo(() => buildHeroView(status), [status]);
101
- const issueHint = useMemo(() => buildIssueHint(status), [status]);
31
+ const feedbackView = useMemo(() => buildRemoteAccessFeedbackView(status), [status]);
102
32
  const busy = Boolean(actionLabel);
103
33
  const deviceName = status?.runtime?.deviceName?.trim() || status?.settings.deviceName?.trim() || t('remoteDeviceNameAuto');
104
34
  const canOpenDeviceList = Boolean(status?.account.loggedIn && resolveRemoteWebBase(status));
105
- const shouldShowIssueHint = Boolean(status?.settings.enabled && status?.account.loggedIn && heroView.badgeStatus === 'warning');
35
+ const { hero: heroView, issueHint } = feedbackView;
106
36
 
107
37
  useEffect(() => {
108
38
  presenter.remoteAccessManager.syncStatus(status);
@@ -134,20 +64,25 @@ export function RemoteAccessPage() {
134
64
  </div>
135
65
 
136
66
  <div className="flex flex-wrap gap-3">
137
- {!status?.account.loggedIn ? (
138
- <Button onClick={() => void presenter.remoteAccessManager.enableRemoteAccess(status)} disabled={busy}>
139
- {actionLabel || t('remoteSignInAndEnable')}
140
- </Button>
141
- ) : !status.settings.enabled ? (
142
- <Button onClick={() => void presenter.remoteAccessManager.enableRemoteAccess(status)} disabled={busy}>
143
- {actionLabel || t('remoteEnableNow')}
67
+ {feedbackView.primaryAction ? (
68
+ <Button
69
+ onClick={() => {
70
+ if (feedbackView.primaryAction?.kind === 'reauthorize') {
71
+ void presenter.remoteAccessManager.reauthorizeRemoteAccess(status);
72
+ return;
73
+ }
74
+ if (feedbackView.primaryAction?.kind === 'repair') {
75
+ void presenter.remoteAccessManager.repairRemoteAccess(status);
76
+ return;
77
+ }
78
+ void presenter.remoteAccessManager.enableRemoteAccess(status);
79
+ }}
80
+ disabled={busy}
81
+ >
82
+ {feedbackView.primaryAction.showRefreshIcon ? <RefreshCcw className="mr-2 h-4 w-4" /> : null}
83
+ {actionLabel || feedbackView.primaryAction.label}
144
84
  </Button>
145
- ) : (
146
- <Button onClick={() => void presenter.remoteAccessManager.repairRemoteAccess(status)} disabled={busy}>
147
- <RefreshCcw className="mr-2 h-4 w-4" />
148
- {actionLabel || t('remoteReconnectNow')}
149
- </Button>
150
- )}
85
+ ) : null}
151
86
 
152
87
  <Button
153
88
  variant="outline"
@@ -165,10 +100,10 @@ export function RemoteAccessPage() {
165
100
  ) : null}
166
101
  </div>
167
102
 
168
- {shouldShowIssueHint ? (
103
+ {feedbackView.shouldShowIssueHint && issueHint ? (
169
104
  <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
170
- <p className="text-sm font-medium text-amber-900">{t('remoteStatusIssueDetailTitle')}</p>
171
- <p className="mt-1 text-sm leading-6 text-amber-800">{issueHint}</p>
105
+ <p className="text-sm font-medium text-amber-900">{issueHint.title}</p>
106
+ <p className="mt-1 text-sm leading-6 text-amber-800">{issueHint.body}</p>
172
107
  </div>
173
108
  ) : null}
174
109
 
@@ -40,23 +40,34 @@ export const REMOTE_LABELS: Record<string, { zh: string; en: string }> = {
40
40
  zh: '远程访问已经开启,但后台服务没有运行。拉起后才会真正连到网页版。',
41
41
  en: 'Remote access is enabled, but the managed service is not running yet. Start it to connect to the web app.'
42
42
  },
43
- remoteStatusIssueTitle: { zh: '远程连接当前有异常', en: 'The remote connection needs attention' },
43
+ remoteStatusReauthorizationTitle: { zh: '登录已过期,请重新登录 NextClaw', en: 'Your sign-in expired. Sign in to NextClaw again.' },
44
+ remoteStatusReauthorizationDescription: {
45
+ zh: '为了保护你的账号安全,远程访问已暂停。重新登录后会自动恢复,不需要重新配置设备。',
46
+ en: 'Remote access is paused to protect your account. Sign in again and it will recover automatically without reconfiguring this device.'
47
+ },
48
+ remoteStatusIssueTitle: { zh: '远程访问暂时没有连上', en: 'Remote access is temporarily offline' },
44
49
  remoteStatusIssueDescription: {
45
- zh: '账号和设备配置都还在,但当前没有稳定连上平台。你可以重新连接,或先去设备列表确认这台设备的状态。',
46
- en: 'Your account and device settings are still there, but this device is not stably connected to the platform right now. Reconnect it or check the device list first.'
50
+ zh: '设备配置还在,但当前没有稳定连上平台。你可以先重新连接;如果问题持续,再重新登录或稍后再试。',
51
+ en: 'Your device settings are still there, but this device is not stably connected to the platform right now. Reconnect first, then sign in again or try later if it keeps happening.'
47
52
  },
48
- remoteStatusIssueDetailTitle: { zh: '当前提示', en: 'Current Hint' },
53
+ remoteStatusIssueDetailTitle: { zh: '下一步', en: 'Next Step' },
54
+ remoteStatusRecoveryTitle: { zh: '推荐操作', en: 'Recommended Next Step' },
49
55
  remoteStatusIssueDetailGeneric: {
50
- zh: '连接曾经建立,但随后被平台侧主动关闭。常见原因包括登录态失效、平台侧中继不可用,或云端配额暂时触顶。',
51
- en: 'The connection was established and then closed by the platform. Common causes include an expired session, an unavailable relay, or a temporary cloud quota limit.'
56
+ zh: '远程访问暂时不可用。你可以先重新连接;如果问题持续,再重新登录或稍后再试。',
57
+ en: 'Remote access is temporarily unavailable. Reconnect first, then sign in again or try later if the issue continues.'
52
58
  },
53
59
  remoteStatusIssueDetailServiceStopped: {
54
- zh: '本地托管服务没有在运行,所以远程连接不会保持在线。',
55
- en: 'The local managed service is not running, so the remote connection cannot stay online.'
60
+ zh: '后台服务当前没有运行。启动后,这台设备才会重新出现在网页版设备列表里。',
61
+ en: 'The managed service is not running right now. Start it so this device can show up in the web device list again.'
62
+ },
63
+ remoteStatusReauthorizationHint: {
64
+ zh: '点击下方按钮后会打开登录页。完成登录后,这台设备会自动恢复远程访问,不需要重新配置。',
65
+ en: 'Use the button below to open the sign-in page. Once you finish signing in, this device will recover remote access automatically.'
56
66
  },
57
67
  remoteSignInAndEnable: { zh: '登录并开启远程访问', en: 'Sign In and Enable Remote Access' },
58
68
  remoteEnableNow: { zh: '开启远程访问', en: 'Enable Remote Access' },
59
69
  remoteReconnectNow: { zh: '重新连接', en: 'Reconnect' },
70
+ remoteReauthorizeNow: { zh: '重新登录并恢复远程访问', en: 'Sign In Again and Restore Remote Access' },
60
71
  remoteDisable: { zh: '关闭远程访问', en: 'Disable Remote Access' },
61
72
  remoteDeviceSummaryTitle: { zh: '当前设备', en: 'This Device' },
62
73
  remoteDeviceSummaryDescription: {
@@ -114,6 +125,7 @@ export const REMOTE_LABELS: Record<string, { zh: string; en: string }> = {
114
125
  remoteStateError: { zh: '连接异常', en: 'Error' },
115
126
  remoteStateDisconnected: { zh: '已断开', en: 'Disconnected' },
116
127
  remoteStateDisabled: { zh: '未启用', en: 'Disabled' },
128
+ remoteStateReauthorizationRequired: { zh: '需要重新登录', en: 'Sign-In Required' },
117
129
  remoteLocalOrigin: { zh: '本地服务地址', en: 'Local Origin' },
118
130
  remotePublicPlatform: { zh: '平台地址', en: 'Platform Base' },
119
131
  remoteDeviceId: { zh: '设备 ID', en: 'Device ID' },
@@ -79,6 +79,15 @@ export class RemoteAccessManager {
79
79
  });
80
80
  };
81
81
 
82
+ reauthorizeRemoteAccess = async (status: RemoteAccessView | undefined) => {
83
+ const currentStatus = status ?? (await refreshRemoteStatus());
84
+ await this.accountManager?.startBrowserSignIn({
85
+ status: currentStatus,
86
+ apiBase: useRemoteAccessStore.getState().platformApiBase,
87
+ pendingAction: { type: 'repair-remote' }
88
+ });
89
+ };
90
+
82
91
  saveAdvancedSettings = async (status: RemoteAccessView | undefined) => {
83
92
  const currentStatus = status ?? (await refreshRemoteStatus());
84
93
  const draft = useRemoteAccessStore.getState();
@@ -126,6 +135,10 @@ export class RemoteAccessManager {
126
135
  }
127
136
  if (action.type === 'enable-remote') {
128
137
  await this.applyEnabledState(true, status);
138
+ return;
139
+ }
140
+ if (action.type === 'repair-remote') {
141
+ await this.repairRemoteAccess(status);
129
142
  }
130
143
  };
131
144
 
@@ -0,0 +1,75 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import type { RemoteAccessView } from '@/api/remote.types';
3
+ import { setLanguage } from '@/lib/i18n';
4
+ import { buildRemoteAccessFeedbackView, requiresRemoteReauthorization } from '@/remote/remote-access-feedback.service';
5
+
6
+ function createRemoteAccessView(overrides: Partial<RemoteAccessView> = {}): RemoteAccessView {
7
+ return {
8
+ account: {
9
+ loggedIn: true,
10
+ email: 'user@example.com',
11
+ apiBase: 'https://ai-gateway-api.nextclaw.io/v1',
12
+ platformBase: 'https://ai-gateway-api.nextclaw.io'
13
+ },
14
+ settings: {
15
+ enabled: true,
16
+ deviceName: 'MacBook Pro',
17
+ platformApiBase: 'https://ai-gateway-api.nextclaw.io/v1'
18
+ },
19
+ service: {
20
+ running: true,
21
+ currentProcess: false
22
+ },
23
+ localOrigin: 'http://127.0.0.1:55667',
24
+ configuredEnabled: true,
25
+ platformBase: 'https://ai-gateway-api.nextclaw.io',
26
+ runtime: {
27
+ enabled: true,
28
+ mode: 'service',
29
+ state: 'error',
30
+ deviceName: 'MacBook Pro',
31
+ lastError: 'Remote relay closed unexpectedly.',
32
+ updatedAt: '2026-03-23T00:00:00.000Z'
33
+ },
34
+ ...overrides
35
+ };
36
+ }
37
+
38
+ describe('remote-access-feedback.service', () => {
39
+ beforeEach(() => {
40
+ setLanguage('zh');
41
+ });
42
+
43
+ it('turns token errors into a reauthorization experience', () => {
44
+ const status = createRemoteAccessView({
45
+ runtime: {
46
+ enabled: true,
47
+ mode: 'service',
48
+ state: 'error',
49
+ lastError: 'Invalid or expired token.',
50
+ updatedAt: '2026-03-23T00:00:00.000Z'
51
+ }
52
+ });
53
+
54
+ expect(requiresRemoteReauthorization(status)).toBe(true);
55
+
56
+ const feedback = buildRemoteAccessFeedbackView(status);
57
+
58
+ expect(feedback.hero.title).toBe('登录已过期,请重新登录 NextClaw');
59
+ expect(feedback.primaryAction?.kind).toBe('reauthorize');
60
+ expect(feedback.primaryAction?.label).toBe('重新登录并恢复远程访问');
61
+ expect(feedback.issueHint?.body).not.toContain('Invalid or expired token');
62
+ });
63
+
64
+ it('keeps generic reconnect guidance for non-auth runtime errors', () => {
65
+ const status = createRemoteAccessView();
66
+
67
+ expect(requiresRemoteReauthorization(status)).toBe(false);
68
+
69
+ const feedback = buildRemoteAccessFeedbackView(status);
70
+
71
+ expect(feedback.hero.title).toBe('远程访问暂时没有连上');
72
+ expect(feedback.primaryAction?.kind).toBe('repair');
73
+ expect(feedback.issueHint?.body).toBe('远程访问暂时不可用。你可以先重新连接;如果问题持续,再重新登录或稍后再试。');
74
+ });
75
+ });