@nextclaw/ui 0.9.9 → 0.9.11

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-Brc1qLSU.js +1 -0
  3. package/dist/assets/{ChatPage-CMthudUt.js → ChatPage-DmGI776q.js} +24 -24
  4. package/dist/assets/{DocBrowser-BOvBC_5q.js → DocBrowser-xLVf1p4L.js} +1 -1
  5. package/dist/assets/{LogoBadge-BUvLZbji.js → LogoBadge-CcTyimdr.js} +1 -1
  6. package/dist/assets/{MarketplacePage-CcbfvtGX.js → MarketplacePage-Bk-qXxyh.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-D56yvyWI.js → McpMarketplacePage-gFqAYekc.js} +1 -1
  8. package/dist/assets/ModelConfig-DnKNTuw6.js +1 -0
  9. package/dist/assets/{ProvidersList-Bd4n7muZ.js → ProvidersList-Cjr8EFu_.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-Be8jduPM.js → RemoteAccessPage-Rzi5a6Gc.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-D8DYogZ1.js → RuntimeConfig-CttN--Tv.js} +1 -1
  12. package/dist/assets/{SearchConfig-BtiGCmXR.js → SearchConfig-D-GzinsL.js} +1 -1
  13. package/dist/assets/{SecretsConfig-fwAjbwlq.js → SecretsConfig-BvqQq4Ds.js} +1 -1
  14. package/dist/assets/{SessionsConfig-Y7_TDSk2.js → SessionsConfig-DbtnLmI6.js} +1 -1
  15. package/dist/assets/{chat-message-Cwq8nW0e.js → chat-message-DYQjL1tD.js} +1 -1
  16. package/dist/assets/index-ClLy_7T2.js +8 -0
  17. package/dist/assets/{label-C0dJBNgU.js → label-DBSKOMGE.js} +1 -1
  18. package/dist/assets/{page-layout-4_789zOC.js → page-layout-B5th9UzR.js} +1 -1
  19. package/dist/assets/{popover-CWmq2f6H.js → popover-BEIWRoeP.js} +1 -1
  20. package/dist/assets/{security-config-CZeVwEwq.js → security-config-D72JskP5.js} +1 -1
  21. package/dist/assets/{skeleton-kjkadEki.js → skeleton-B_Pn9x0i.js} +1 -1
  22. package/dist/assets/{status-dot-C7cVa53V.js → status-dot-CU5ZpOn1.js} +1 -1
  23. package/dist/assets/{switch-C6zdGbY0.js → switch-BdaXEtXk.js} +1 -1
  24. package/dist/assets/{tabs-custom-BQj0Z-ZC.js → tabs-custom-BVhSoteN.js} +1 -1
  25. package/dist/assets/{useConfirmDialog-yX-ZMNf9.js → useConfirmDialog-Dugi9V-Z.js} +1 -1
  26. package/dist/index.html +1 -1
  27. package/package.json +4 -4
  28. package/src/App.tsx +2 -2
  29. package/src/api/client.ts +5 -5
  30. package/src/api/config.ts +2 -4
  31. package/src/components/chat/chat-stream/transport.ts +67 -16
  32. package/src/components/config/ModelConfig.test.tsx +78 -0
  33. package/src/components/config/ModelConfig.tsx +4 -1
  34. package/src/hooks/use-realtime-query-bridge.ts +114 -0
  35. package/src/transport/app-client.ts +107 -0
  36. package/src/transport/index.ts +9 -0
  37. package/src/transport/local.transport.ts +168 -0
  38. package/src/transport/remote.transport.ts +363 -0
  39. package/src/transport/sse-stream.ts +114 -0
  40. package/src/transport/transport-websocket-url.ts +24 -0
  41. package/src/transport/transport.types.ts +41 -0
  42. package/dist/assets/ChannelsList-a063_8pv.js +0 -1
  43. package/dist/assets/ModelConfig-D5AuTffd.js +0 -1
  44. package/dist/assets/index-C6dwNe7e.js +0 -8
  45. package/src/api/websocket.ts +0 -79
  46. package/src/hooks/useWebSocket.ts +0 -190
package/dist/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-C6dwNe7e.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-ClLy_7T2.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-CmQZsDAE.js">
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-DfEAJJsA.css">
12
12
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,11 +27,11 @@
27
27
  "tailwind-merge": "^2.5.4",
28
28
  "zod": "^3.23.8",
29
29
  "zustand": "^5.0.2",
30
- "@nextclaw/ncp-react": "0.3.2",
30
+ "@nextclaw/ncp-http-agent-client": "0.3.1",
31
31
  "@nextclaw/agent-chat": "0.1.1",
32
32
  "@nextclaw/ncp": "0.3.1",
33
- "@nextclaw/ncp-http-agent-client": "0.3.1",
34
- "@nextclaw/agent-chat-ui": "0.2.1"
33
+ "@nextclaw/agent-chat-ui": "0.2.1",
34
+ "@nextclaw/ncp-react": "0.3.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@testing-library/react": "^16.3.0",
package/src/App.tsx CHANGED
@@ -5,7 +5,7 @@ import { appQueryClient } from '@/app-query-client';
5
5
  import { LoginPage } from '@/components/auth/login-page';
6
6
  import { AppLayout } from '@/components/layout/AppLayout';
7
7
  import { useAuthStatus } from '@/hooks/use-auth';
8
- import { useWebSocket } from '@/hooks/useWebSocket';
8
+ import { useRealtimeQueryBridge } from '@/hooks/use-realtime-query-bridge';
9
9
  import { AppPresenterProvider } from '@/presenter/app-presenter-context';
10
10
  import { Toaster } from 'sonner';
11
11
  import { Routes, Route, Navigate } from 'react-router-dom';
@@ -32,7 +32,7 @@ function LazyRoute({ children }: { children: JSX.Element }) {
32
32
  }
33
33
 
34
34
  function ProtectedApp() {
35
- useWebSocket(appQueryClient); // Initialize WebSocket connection
35
+ useRealtimeQueryBridge(appQueryClient);
36
36
 
37
37
  return (
38
38
  <AppPresenterProvider>
package/src/api/client.ts CHANGED
@@ -42,7 +42,7 @@ function inferNonJsonHint(endpoint: string, status: number): string | undefined
42
42
  return undefined;
43
43
  }
44
44
 
45
- async function apiRequest<T>(
45
+ export async function requestApiResponse<T>(
46
46
  endpoint: string,
47
47
  options: RequestInit = {}
48
48
  ): Promise<ApiResponse<T>> {
@@ -104,19 +104,19 @@ async function apiRequest<T>(
104
104
  }
105
105
 
106
106
  export const api = {
107
- get: <T>(path: string) => apiRequest<T>(path, { method: 'GET' }),
107
+ get: <T>(path: string) => requestApiResponse<T>(path, { method: 'GET' }),
108
108
  put: <T>(path: string, body: unknown) =>
109
- apiRequest<T>(path, {
109
+ requestApiResponse<T>(path, {
110
110
  method: 'PUT',
111
111
  body: JSON.stringify(body)
112
112
  }),
113
113
  post: <T>(path: string, body: unknown) =>
114
- apiRequest<T>(path, {
114
+ requestApiResponse<T>(path, {
115
115
  method: 'POST',
116
116
  body: JSON.stringify(body)
117
117
  }),
118
118
  delete: <T>(path: string) =>
119
- apiRequest<T>(path, {
119
+ requestApiResponse<T>(path, {
120
120
  method: 'DELETE'
121
121
  })
122
122
  };
package/src/api/config.ts CHANGED
@@ -142,10 +142,8 @@ export async function fetchConfigSchema(): Promise<ConfigSchemaResponse> {
142
142
  }
143
143
 
144
144
  // PUT /api/config/model
145
- export async function updateModel(data: {
146
- model: string;
147
- }): Promise<{ model: string }> {
148
- const response = await api.put<{ model: string }>('/api/config/model', data);
145
+ export async function updateModel(data: { model: string; workspace?: string }): Promise<{ model: string; workspace?: string }> {
146
+ const response = await api.put<{ model: string; workspace?: string }>('/api/config/model', data);
149
147
  if (!response.ok) {
150
148
  throw new Error(response.error.message);
151
149
  }
@@ -1,4 +1,5 @@
1
- import { fetchChatRuns, sendChatTurnStream, stopChatTurn, streamChatRun } from '@/api/config';
1
+ import { fetchChatRuns, stopChatTurn } from '@/api/config';
2
+ import { appClient } from '@/transport';
2
3
  import type { ActiveRunState, SendMessageParams, StreamDeltaEvent, StreamReadyEvent, StreamSessionEvent } from './types';
3
4
 
4
5
  function buildSendTurnPayload(item: SendMessageParams, requestedSkills: string[]) {
@@ -32,12 +33,37 @@ export async function openSendTurnStream(params: {
32
33
  onDelta: (event: StreamDeltaEvent) => void;
33
34
  onSessionEvent: (event: StreamSessionEvent) => void;
34
35
  }) {
35
- return sendChatTurnStream(buildSendTurnPayload(params.item, params.requestedSkills), {
36
+ let readySessionKey = '';
37
+ const session = appClient.openStream<{ reply?: string; sessionKey?: string }>({
38
+ method: 'POST',
39
+ path: '/api/chat/turn/stream',
40
+ body: buildSendTurnPayload(params.item, params.requestedSkills),
36
41
  signal: params.signal,
37
- onReady: params.onReady,
38
- onDelta: params.onDelta,
39
- onSessionEvent: params.onSessionEvent
42
+ onEvent: (event) => {
43
+ if (event.name === 'ready') {
44
+ const payload = (event.payload ?? {}) as StreamReadyEvent;
45
+ if (typeof payload.sessionKey === 'string' && payload.sessionKey.trim()) {
46
+ readySessionKey = payload.sessionKey;
47
+ }
48
+ params.onReady(payload);
49
+ return;
50
+ }
51
+ if (event.name === 'delta') {
52
+ params.onDelta((event.payload ?? { delta: '' }) as StreamDeltaEvent);
53
+ return;
54
+ }
55
+ if (event.name === 'session_event') {
56
+ params.onSessionEvent({ data: event.payload as StreamSessionEvent['data'] });
57
+ }
58
+ }
40
59
  });
60
+ const result = await session.finished;
61
+ return {
62
+ sessionKey: typeof result?.sessionKey === 'string' && result.sessionKey.trim()
63
+ ? result.sessionKey
64
+ : readySessionKey,
65
+ reply: typeof result?.reply === 'string' ? result.reply : ''
66
+ };
41
67
  }
42
68
 
43
69
  export async function openResumeRunStream(params: {
@@ -48,18 +74,43 @@ export async function openResumeRunStream(params: {
48
74
  onDelta: (event: StreamDeltaEvent) => void;
49
75
  onSessionEvent: (event: StreamSessionEvent) => void;
50
76
  }) {
51
- return streamChatRun(
52
- {
53
- runId: params.runId,
54
- ...(typeof params.fromEventIndex === 'number' ? { fromEventIndex: params.fromEventIndex } : {})
55
- },
56
- {
57
- signal: params.signal,
58
- onReady: params.onReady,
59
- onDelta: params.onDelta,
60
- onSessionEvent: params.onSessionEvent
77
+ let readySessionKey = '';
78
+ const query = new URLSearchParams();
79
+ if (typeof params.fromEventIndex === 'number') {
80
+ query.set('fromEventIndex', String(Math.max(0, Math.trunc(params.fromEventIndex))));
81
+ }
82
+ const path =
83
+ `/api/chat/runs/${encodeURIComponent(params.runId)}/stream`
84
+ + (query.size > 0 ? `?${query.toString()}` : '');
85
+ const session = appClient.openStream<{ reply?: string; sessionKey?: string }>({
86
+ method: 'GET',
87
+ path,
88
+ signal: params.signal,
89
+ onEvent: (event) => {
90
+ if (event.name === 'ready') {
91
+ const payload = (event.payload ?? {}) as StreamReadyEvent;
92
+ if (typeof payload.sessionKey === 'string' && payload.sessionKey.trim()) {
93
+ readySessionKey = payload.sessionKey;
94
+ }
95
+ params.onReady(payload);
96
+ return;
97
+ }
98
+ if (event.name === 'delta') {
99
+ params.onDelta((event.payload ?? { delta: '' }) as StreamDeltaEvent);
100
+ return;
101
+ }
102
+ if (event.name === 'session_event') {
103
+ params.onSessionEvent({ data: event.payload as StreamSessionEvent['data'] });
104
+ }
61
105
  }
62
- );
106
+ });
107
+ const result = await session.finished;
108
+ return {
109
+ sessionKey: typeof result?.sessionKey === 'string' && result.sessionKey.trim()
110
+ ? result.sessionKey
111
+ : readySessionKey,
112
+ reply: typeof result?.reply === 'string' ? result.reply : ''
113
+ };
63
114
  }
64
115
 
65
116
  export async function requestStopRun(activeRun: ActiveRunState): Promise<void> {
@@ -0,0 +1,78 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ModelConfig } from '@/components/config/ModelConfig';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ mutate: vi.fn(),
7
+ configQuery: {
8
+ data: {
9
+ agents: {
10
+ defaults: {
11
+ model: 'openai/gpt-5.2',
12
+ workspace: '~/old-workspace'
13
+ }
14
+ },
15
+ providers: {
16
+ openai: {
17
+ enabled: true,
18
+ apiKeySet: true,
19
+ models: ['gpt-5.2']
20
+ }
21
+ }
22
+ },
23
+ isLoading: false
24
+ },
25
+ metaQuery: {
26
+ data: {
27
+ providers: [
28
+ {
29
+ name: 'openai',
30
+ displayName: 'OpenAI',
31
+ modelPrefix: 'openai',
32
+ defaultModels: ['openai/gpt-5.2'],
33
+ keywords: [],
34
+ envKey: 'OPENAI_API_KEY'
35
+ }
36
+ ]
37
+ }
38
+ },
39
+ schemaQuery: {
40
+ data: {
41
+ uiHints: {}
42
+ }
43
+ }
44
+ }));
45
+
46
+ vi.mock('@/hooks/useConfig', () => ({
47
+ useConfig: () => mocks.configQuery,
48
+ useConfigMeta: () => mocks.metaQuery,
49
+ useConfigSchema: () => mocks.schemaQuery,
50
+ useUpdateModel: () => ({
51
+ mutate: mocks.mutate,
52
+ isPending: false
53
+ })
54
+ }));
55
+
56
+ describe('ModelConfig', () => {
57
+ beforeEach(() => {
58
+ mocks.mutate.mockReset();
59
+ });
60
+
61
+ it('submits the workspace together with the selected model', async () => {
62
+ const user = userEvent.setup();
63
+
64
+ render(<ModelConfig />);
65
+
66
+ const workspaceInput = await screen.findByLabelText('Default Path');
67
+ await user.clear(workspaceInput);
68
+ await user.type(workspaceInput, '~/new-workspace');
69
+ await user.click(screen.getByRole('button', { name: /save/i }));
70
+
71
+ await waitFor(() => {
72
+ expect(mocks.mutate).toHaveBeenCalledWith({
73
+ model: 'openai/gpt-5.2',
74
+ workspace: '~/new-workspace'
75
+ });
76
+ });
77
+ });
78
+ });
@@ -95,7 +95,10 @@ export function ModelConfig() {
95
95
 
96
96
  const handleSubmit = (e: React.FormEvent) => {
97
97
  e.preventDefault();
98
- updateModel.mutate({ model: composedModel });
98
+ updateModel.mutate({
99
+ model: composedModel,
100
+ workspace
101
+ });
99
102
  };
100
103
 
101
104
  if (isLoading) {
@@ -0,0 +1,114 @@
1
+ import { useEffect } from 'react';
2
+ import { appClient } from '@/transport';
3
+ import { useUiStore } from '@/stores/ui.store';
4
+ import type { QueryClient } from '@tanstack/react-query';
5
+
6
+ type ConnectionStatus = 'connected' | 'disconnected' | 'connecting';
7
+ type SetConnectionStatus = (status: ConnectionStatus) => void;
8
+
9
+ function shouldInvalidateConfigQuery(configPath: string) {
10
+ const normalized = configPath.trim().toLowerCase();
11
+ if (!normalized) {
12
+ return true;
13
+ }
14
+ if (normalized.startsWith('plugins') || normalized.startsWith('skills')) {
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+
20
+ function invalidateMarketplaceQueries(queryClient: QueryClient | undefined, configPath: string): void {
21
+ if (configPath.startsWith('plugins')) {
22
+ queryClient?.invalidateQueries({ queryKey: ['ncp-session-types'] });
23
+ queryClient?.invalidateQueries({ queryKey: ['marketplace-installed', 'plugin'] });
24
+ queryClient?.invalidateQueries({ queryKey: ['marketplace-items'] });
25
+ }
26
+ if (configPath.startsWith('mcp')) {
27
+ queryClient?.invalidateQueries({ queryKey: ['marketplace-mcp-installed'] });
28
+ queryClient?.invalidateQueries({ queryKey: ['marketplace-mcp-items'] });
29
+ queryClient?.invalidateQueries({ queryKey: ['marketplace-mcp-doctor'] });
30
+ }
31
+ }
32
+
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
+ }
47
+
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
+ }
57
+
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
+ }
74
+
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
+ }
105
+
106
+ export function useRealtimeQueryBridge(queryClient?: QueryClient) {
107
+ const { setConnectionStatus } = useUiStore();
108
+
109
+ useEffect(() => {
110
+ setConnectionStatus('connecting');
111
+
112
+ return appClient.subscribe((event) => handleRealtimeEvent(queryClient, setConnectionStatus, event));
113
+ }, [queryClient, setConnectionStatus]);
114
+ }
@@ -0,0 +1,107 @@
1
+ import { API_BASE } from '@/api/client';
2
+ import { LocalAppTransport } from './local.transport';
3
+ import { RemoteSessionMultiplexTransport } from './remote.transport';
4
+ import type { AppTransport, RemoteRuntimeInfo, RequestInput, StreamInput, StreamSession } from './transport.types';
5
+
6
+ const REMOTE_RUNTIME_PATH = '/_remote/runtime';
7
+
8
+ async function resolveRuntime(apiBase: string): Promise<AppTransport> {
9
+ const runtimeUrl = `${apiBase.replace(/\/$/, '')}${REMOTE_RUNTIME_PATH}`;
10
+ let response: Response;
11
+ try {
12
+ response = await fetch(runtimeUrl, {
13
+ method: 'GET',
14
+ credentials: 'include',
15
+ cache: 'no-store'
16
+ });
17
+ } catch {
18
+ return new LocalAppTransport({ apiBase });
19
+ }
20
+
21
+ if (response.status === 404) {
22
+ return new LocalAppTransport({ apiBase });
23
+ }
24
+
25
+ const payload = await response.json() as { ok?: boolean; data?: RemoteRuntimeInfo };
26
+ if (response.ok && payload.ok && payload.data?.mode === 'remote') {
27
+ return new RemoteSessionMultiplexTransport(payload.data, apiBase);
28
+ }
29
+
30
+ if (response.status >= 400) {
31
+ return new RemoteSessionMultiplexTransport({
32
+ mode: 'remote',
33
+ protocolVersion: 1,
34
+ wsPath: '/_remote/ws'
35
+ }, apiBase);
36
+ }
37
+
38
+ return new LocalAppTransport({ apiBase });
39
+ }
40
+
41
+ class AppClient {
42
+ private transportPromise: Promise<AppTransport> | null = null;
43
+
44
+ constructor(private readonly apiBase: string = API_BASE) {}
45
+
46
+ private async getTransport(): Promise<AppTransport> {
47
+ if (!this.transportPromise) {
48
+ this.transportPromise = resolveRuntime(this.apiBase);
49
+ }
50
+ return await this.transportPromise;
51
+ }
52
+
53
+ async request<T>(input: RequestInput): Promise<T> {
54
+ return await (await this.getTransport()).request<T>(input);
55
+ }
56
+
57
+ openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal> {
58
+ let currentSession: StreamSession<TFinal> | null = null;
59
+ let resolveFinished!: (value: TFinal) => void;
60
+ let rejectFinished!: (error: Error) => void;
61
+ const finished = new Promise<TFinal>((resolve, reject) => {
62
+ resolveFinished = resolve;
63
+ rejectFinished = reject;
64
+ });
65
+
66
+ void this.getTransport()
67
+ .then((transport) => {
68
+ currentSession = transport.openStream<TFinal>(input);
69
+ void currentSession.finished.then(resolveFinished).catch((error) => {
70
+ rejectFinished(error instanceof Error ? error : new Error(String(error)));
71
+ });
72
+ })
73
+ .catch((error) => {
74
+ rejectFinished(error instanceof Error ? error : new Error(String(error)));
75
+ });
76
+
77
+ return {
78
+ finished,
79
+ cancel: () => currentSession?.cancel()
80
+ };
81
+ }
82
+
83
+ subscribe(handler: (event: Parameters<Parameters<AppTransport['subscribe']>[0]>[0]) => void): () => void {
84
+ let unsubscribe = () => {};
85
+ let active = true;
86
+ void this.getTransport().then((transport) => {
87
+ if (!active) {
88
+ return;
89
+ }
90
+ unsubscribe = transport.subscribe(handler);
91
+ }).catch((error) => {
92
+ handler({
93
+ type: 'connection.error',
94
+ payload: {
95
+ message: error instanceof Error ? error.message : String(error)
96
+ }
97
+ });
98
+ });
99
+
100
+ return () => {
101
+ active = false;
102
+ unsubscribe();
103
+ };
104
+ }
105
+ }
106
+
107
+ export const appClient = new AppClient();
@@ -0,0 +1,9 @@
1
+ export { appClient } from './app-client';
2
+ export type {
3
+ AppEvent,
4
+ AppTransport,
5
+ RequestInput,
6
+ StreamEvent,
7
+ StreamInput,
8
+ StreamSession
9
+ } from './transport.types';