@nextclaw/ui 0.9.10 → 0.9.12

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 (61) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/assets/{ChannelsList-BgJbR6E9.js → ChannelsList-CJy2GG1a.js} +1 -1
  3. package/dist/assets/ChatPage-C7WxI8VY.js +41 -0
  4. package/dist/assets/{DocBrowser-Dw9BGO1m.js → DocBrowser-Nu-ae-eS.js} +1 -1
  5. package/dist/assets/{LogoBadge-CLc2B6st.js → LogoBadge-DbbMxPlr.js} +1 -1
  6. package/dist/assets/{MarketplacePage-ChqCNL7k.js → MarketplacePage-BQYQPeg2.js} +2 -2
  7. package/dist/assets/{McpMarketplacePage-B3PF-7ED.js → McpMarketplacePage-kiMJbS8r.js} +1 -1
  8. package/dist/assets/{ModelConfig-Dqz_NOow.js → ModelConfig-DRQ07Snj.js} +1 -1
  9. package/dist/assets/{ProvidersList-D2WaZShJ.js → ProvidersList-C0NjzKX1.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-DVJ5hBNJ.js +1 -0
  11. package/dist/assets/{RuntimeConfig-TDxQLuGy.js → RuntimeConfig-BkYWyRW7.js} +1 -1
  12. package/dist/assets/{SearchConfig-gba64nGn.js → SearchConfig-DZTW8Wnq.js} +1 -1
  13. package/dist/assets/{SecretsConfig-DpL8wgly.js → SecretsConfig-WMcwg5KV.js} +2 -2
  14. package/dist/assets/{SessionsConfig-CAODVTNW.js → SessionsConfig-CWtCXQRn.js} +1 -1
  15. package/dist/assets/{chat-message-CSG50nNb.js → chat-message-BcjCODYN.js} +1 -1
  16. package/dist/assets/index-BOhlxC12.js +8 -0
  17. package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
  18. package/dist/assets/{label-3T28q3PJ.js → label-DOWMfYPL.js} +1 -1
  19. package/dist/assets/{page-layout-BrXOQeua.js → page-layout-DQtmTgqR.js} +1 -1
  20. package/dist/assets/popover-k11l1-ko.js +1 -0
  21. package/dist/assets/{security-config-oGAhN4Zf.js → security-config-FFy-bOJb.js} +1 -1
  22. package/dist/assets/skeleton-DQ4QRdSe.js +1 -0
  23. package/dist/assets/{status-dot-QL3hmT1d.js → status-dot-CsZRxe8p.js} +1 -1
  24. package/dist/assets/{switch-Dbt2kUg2.js → switch-DfMy8G96.js} +1 -1
  25. package/dist/assets/{tabs-custom-y5hdkzXk.js → tabs-custom-CITPDGXY.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-B4zwBVbl.js → useConfirmDialog-Dr39o-0I.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 +3 -3
  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 +50 -125
  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/hooks/use-realtime-query-bridge.ts +77 -71
  48. package/src/lib/i18n.remote.ts +20 -8
  49. package/src/remote/managers/remote-access.manager.ts +13 -0
  50. package/src/remote/remote-access-feedback.service.test.ts +75 -0
  51. package/src/remote/remote-access-feedback.service.ts +195 -0
  52. package/src/transport/app-client.ts +1 -1
  53. package/src/transport/local.transport.ts +8 -125
  54. package/src/transport/remote.transport.ts +44 -74
  55. package/src/transport/sse-stream.ts +114 -0
  56. package/src/transport/transport-websocket-url.ts +24 -0
  57. package/dist/assets/ChatPage-Bv9UJPse.js +0 -38
  58. package/dist/assets/RemoteAccessPage-D_l9irp4.js +0 -1
  59. package/dist/assets/index-DaEflNCE.js +0 -8
  60. package/dist/assets/popover-BrBJjElY.js +0 -1
  61. package/dist/assets/skeleton-CIPQUKo2.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
 
@@ -3,6 +3,9 @@ import { appClient } from '@/transport';
3
3
  import { useUiStore } from '@/stores/ui.store';
4
4
  import type { QueryClient } from '@tanstack/react-query';
5
5
 
6
+ type ConnectionStatus = 'connected' | 'disconnected' | 'connecting';
7
+ type SetConnectionStatus = (status: ConnectionStatus) => void;
8
+
6
9
  function shouldInvalidateConfigQuery(configPath: string) {
7
10
  const normalized = configPath.trim().toLowerCase();
8
11
  if (!normalized) {
@@ -27,82 +30,85 @@ function invalidateMarketplaceQueries(queryClient: QueryClient | undefined, conf
27
30
  }
28
31
  }
29
32
 
30
- export function useRealtimeQueryBridge(queryClient?: QueryClient) {
31
- const { setConnectionStatus } = useUiStore();
32
-
33
- useEffect(() => {
34
- const invalidateSessionQueries = (sessionKey?: string) => {
35
- if (!queryClient) {
36
- return;
37
- }
38
- queryClient.invalidateQueries({ queryKey: ['sessions'] });
39
- queryClient.invalidateQueries({ queryKey: ['ncp-sessions'] });
40
- if (sessionKey && sessionKey.trim().length > 0) {
41
- queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey.trim()] });
42
- queryClient.invalidateQueries({ queryKey: ['ncp-session-messages', sessionKey.trim()] });
43
- return;
44
- }
45
- queryClient.invalidateQueries({ queryKey: ['session-history'] });
46
- queryClient.invalidateQueries({ queryKey: ['ncp-session-messages'] });
47
- };
48
-
49
- setConnectionStatus('connecting');
50
-
51
- return appClient.subscribe((event) => {
52
- if (event.type === 'connection.open') {
53
- setConnectionStatus('connected');
54
- return;
55
- }
33
+ function invalidateSessionQueries(queryClient: QueryClient | undefined, sessionKey?: string): void {
34
+ if (!queryClient) {
35
+ return;
36
+ }
37
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
38
+ queryClient.invalidateQueries({ queryKey: ['ncp-sessions'] });
39
+ if (sessionKey && sessionKey.trim().length > 0) {
40
+ queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey.trim()] });
41
+ queryClient.invalidateQueries({ queryKey: ['ncp-session-messages', sessionKey.trim()] });
42
+ return;
43
+ }
44
+ queryClient.invalidateQueries({ queryKey: ['session-history'] });
45
+ queryClient.invalidateQueries({ queryKey: ['ncp-session-messages'] });
46
+ }
56
47
 
57
- if (event.type === 'connection.close') {
58
- setConnectionStatus('disconnected');
59
- return;
60
- }
48
+ function handleConfigUpdatedEvent(queryClient: QueryClient | undefined, path: string): void {
49
+ if (queryClient && shouldInvalidateConfigQuery(path)) {
50
+ queryClient.invalidateQueries({ queryKey: ['config'] });
51
+ }
52
+ if (path.startsWith('session')) {
53
+ invalidateSessionQueries(queryClient);
54
+ }
55
+ invalidateMarketplaceQueries(queryClient, path);
56
+ }
61
57
 
62
- if (event.type === 'connection.error') {
63
- setConnectionStatus('disconnected');
64
- return;
65
- }
58
+ function handleRunUpdatedEvent(queryClient: QueryClient | undefined, payload: { run: { sessionKey?: string; runId?: string } }): void {
59
+ if (!queryClient) {
60
+ return;
61
+ }
62
+ const { sessionKey, runId } = payload.run;
63
+ queryClient.invalidateQueries({ queryKey: ['chat-runs'] });
64
+ if (sessionKey) {
65
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
66
+ queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey] });
67
+ } else {
68
+ queryClient.invalidateQueries({ queryKey: ['session-history'] });
69
+ }
70
+ if (runId) {
71
+ queryClient.invalidateQueries({ queryKey: ['chat-run', runId] });
72
+ }
73
+ }
66
74
 
67
- if (event.type === 'config.updated') {
68
- const configPath = typeof event.payload?.path === 'string' ? event.payload.path : '';
69
- if (queryClient && shouldInvalidateConfigQuery(configPath)) {
70
- queryClient.invalidateQueries({ queryKey: ['config'] });
71
- }
72
- if (configPath.startsWith('session')) {
73
- invalidateSessionQueries();
74
- }
75
- invalidateMarketplaceQueries(queryClient, configPath);
76
- return;
77
- }
75
+ function handleRealtimeEvent(
76
+ queryClient: QueryClient | undefined,
77
+ setConnectionStatus: SetConnectionStatus,
78
+ event: Parameters<Parameters<typeof appClient.subscribe>[0]>[0]
79
+ ): void {
80
+ if (event.type === 'connection.open') {
81
+ setConnectionStatus('connected');
82
+ return;
83
+ }
84
+ if (event.type === 'connection.close' || event.type === 'connection.error') {
85
+ setConnectionStatus('disconnected');
86
+ return;
87
+ }
88
+ if (event.type === 'config.updated') {
89
+ const configPath = typeof event.payload?.path === 'string' ? event.payload.path : '';
90
+ handleConfigUpdatedEvent(queryClient, configPath);
91
+ return;
92
+ }
93
+ if (event.type === 'run.updated') {
94
+ handleRunUpdatedEvent(queryClient, event.payload);
95
+ return;
96
+ }
97
+ if (event.type === 'session.updated') {
98
+ invalidateSessionQueries(queryClient, event.payload.sessionKey);
99
+ return;
100
+ }
101
+ if (event.type === 'error') {
102
+ console.error('Realtime transport error:', event.payload.message);
103
+ }
104
+ }
78
105
 
79
- if (event.type === 'run.updated') {
80
- if (!queryClient) {
81
- return;
82
- }
83
- const sessionKey = event.payload.run.sessionKey;
84
- const runId = event.payload.run.runId;
85
- queryClient.invalidateQueries({ queryKey: ['chat-runs'] });
86
- if (sessionKey) {
87
- queryClient.invalidateQueries({ queryKey: ['sessions'] });
88
- queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey] });
89
- } else {
90
- queryClient.invalidateQueries({ queryKey: ['session-history'] });
91
- }
92
- if (runId) {
93
- queryClient.invalidateQueries({ queryKey: ['chat-run', runId] });
94
- }
95
- return;
96
- }
106
+ export function useRealtimeQueryBridge(queryClient?: QueryClient) {
107
+ const { setConnectionStatus } = useUiStore();
97
108
 
98
- if (event.type === 'session.updated') {
99
- invalidateSessionQueries(event.payload.sessionKey);
100
- return;
101
- }
109
+ useEffect(() => {
110
+ setConnectionStatus('connecting');
102
111
 
103
- if (event.type === 'error') {
104
- console.error('Realtime transport error:', event.payload.message);
105
- }
106
- });
112
+ return appClient.subscribe((event) => handleRealtimeEvent(queryClient, setConnectionStatus, event));
107
113
  }, [queryClient, setConnectionStatus]);
108
114
  }