gloomberb 0.13.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/api-client/index.test.ts +34 -0
- package/src/api-client/request.ts +4 -1
- package/src/cli/desktop-pane-shot.ts +72 -2
- package/src/cli/pane-functions/cloud-session.test.ts +77 -0
- package/src/cli/pane-functions/cloud-session.ts +47 -0
- package/src/cli/pane-functions/index.ts +5 -1
- package/src/cli/pane-functions/screenshot.test.ts +27 -0
- package/src/cli/pane-functions/screenshot.ts +85 -20
- package/src/components/command-bar/assist/runtime.ts +38 -30
- package/src/components/command-bar/surface/assist.test.tsx +43 -0
- package/src/news/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/channels.ts +2 -1
- package/src/plugins/builtin/chat/controller/index.ts +13 -5
- package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
- package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
- package/src/plugins/builtin/chat/controller/state.ts +1 -0
- package/src/plugins/builtin/chat/controller.test.ts +260 -4
- package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
- package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
- package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
- package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
- package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
- package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
- package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
- package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
- package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
- package/src/plugins/builtin/cloud/plugin.tsx +2 -0
- package/src/plugins/builtin/congress-trades/client.ts +11 -0
- package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
- package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
- package/src/plugins/builtin/congress-trades/headless.ts +145 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
- package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
- package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
- package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
- package/src/plugins/builtin/dividend-yield/model.ts +4 -22
- package/src/plugins/builtin/dividend-yield/view.ts +17 -0
- package/src/plugins/builtin/earnings/headless.test.ts +82 -0
- package/src/plugins/builtin/earnings/headless.ts +162 -0
- package/src/plugins/builtin/earnings/index.tsx +35 -9
- package/src/plugins/builtin/earnings/model.test.ts +29 -1
- package/src/plugins/builtin/earnings-calls/data.ts +29 -8
- package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
- package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
- package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
- package/src/plugins/builtin/earnings-calls/model.ts +238 -0
- package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
- package/src/plugins/builtin/holders/client.ts +29 -0
- package/src/plugins/builtin/holders/headless.test.ts +58 -0
- package/src/plugins/builtin/holders/headless.ts +162 -0
- package/src/plugins/builtin/holders/index.tsx +14 -8
- package/src/plugins/builtin/holders/pane.tsx +7 -1
- package/src/plugins/builtin/insider/client.ts +50 -0
- package/src/plugins/builtin/insider/headless.test.ts +122 -0
- package/src/plugins/builtin/insider/headless.ts +117 -0
- package/src/plugins/builtin/insider/index.tsx +21 -50
- package/src/plugins/builtin/insider/insider-data.ts +1 -1
- package/src/plugins/builtin/insider/model.ts +72 -0
- package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
- package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
- package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
- package/src/plugins/builtin/news/headless.test.ts +138 -0
- package/src/plugins/builtin/news/headless.ts +190 -0
- package/src/plugins/builtin/news/index.tsx +13 -9
- package/src/plugins/builtin/news/wire/index.tsx +2 -1
- package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
- package/src/plugins/builtin/research/analyst-headless.ts +172 -0
- package/src/plugins/builtin/research/analyst-model.ts +219 -0
- package/src/plugins/builtin/research/analyst-pane.test.tsx +145 -0
- package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
- package/src/plugins/builtin/research/client.ts +12 -0
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +27 -1
- package/src/plugins/builtin/research/event-model.ts +56 -0
- package/src/plugins/builtin/research/headless.test.ts +93 -0
- package/src/plugins/builtin/research/headless.ts +161 -0
- package/src/plugins/builtin/research/index.test.ts +43 -0
- package/src/plugins/builtin/research/index.tsx +28 -18
- package/src/plugins/builtin/sec/client.ts +16 -0
- package/src/plugins/builtin/sec/headless.test.ts +72 -0
- package/src/plugins/builtin/sec/headless.ts +69 -0
- package/src/plugins/builtin/sec/index.tsx +20 -75
- package/src/plugins/builtin/sec/model.ts +89 -0
- package/src/plugins/builtin/short-interest/client.ts +5 -2
- package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
- package/src/plugins/builtin/short-interest/headless.ts +111 -0
- package/src/plugins/builtin/short-interest/index.tsx +15 -9
- package/src/plugins/builtin/short-interest/model.ts +15 -4
- package/src/plugins/builtin/short-interest/pane.tsx +1 -1
- package/src/plugins/builtin/thirteenf/data.ts +13 -11
- package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
- package/src/plugins/builtin/thirteenf/headless.ts +286 -0
- package/src/plugins/builtin/thirteenf/index.tsx +4 -0
- package/src/plugins/builtin/thirteenf/model.ts +13 -0
- package/src/plugins/builtin/thirteenf/table.ts +3 -15
- package/src/plugins/prediction-markets/headless.test.ts +119 -0
- package/src/plugins/prediction-markets/headless.ts +255 -0
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/renderers/browser/config-host.test.ts +70 -0
- package/src/renderers/browser/config-host.ts +74 -10
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +180 -19
- package/src/sources/gloomberb-cloud/news.ts +1 -0
- package/src/updater.test.ts +17 -1
- package/src/updater.ts +4 -2
|
@@ -42,17 +42,21 @@ export function useCommandBarAssist({
|
|
|
42
42
|
askAssist: () => void;
|
|
43
43
|
resetAssist: () => boolean;
|
|
44
44
|
} {
|
|
45
|
-
const [assistState,
|
|
45
|
+
const [assistState, setRenderedAssistState] = useState<AssistRequestState>({ status: "idle" });
|
|
46
46
|
const assistStateRef = useRef(assistState);
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Event handlers can run again before React commits the state they just
|
|
49
|
+
* queued. Keep their read model current synchronously so repeated Enter
|
|
50
|
+
* claims one request instead of aborting it and starting another.
|
|
51
|
+
*/
|
|
52
|
+
const updateAssistState = useCallback((next: AssistRequestState) => {
|
|
53
|
+
assistStateRef.current = next;
|
|
54
|
+
setRenderedAssistState(next);
|
|
55
|
+
}, []);
|
|
48
56
|
const abortRef = useRef<AbortController | null>(null);
|
|
49
57
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
58
|
const answersRef = useRef(new Map<string, AssistCommandCandidate[]>());
|
|
51
|
-
/**
|
|
52
|
-
* The query the runtime has already acted on, updated the moment a request
|
|
53
|
-
* starts. Rendered state lags a tick behind, and reading it instead would let
|
|
54
|
-
* a re-render slip a second debounce past an ask that is already in flight.
|
|
55
|
-
*/
|
|
59
|
+
/** Query the runtime has already acted on, updated when a request starts. */
|
|
56
60
|
const handledQueryRef = useRef<string | null>(null);
|
|
57
61
|
/** Query the user dismissed with Esc; the section stays gone until it changes. */
|
|
58
62
|
const dismissedQueryRef = useRef<string | null>(null);
|
|
@@ -65,12 +69,13 @@ export function useCommandBarAssist({
|
|
|
65
69
|
getInventoryRef.current = getInventory;
|
|
66
70
|
|
|
67
71
|
const cancelPending = useCallback(() => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
abortRef.current
|
|
72
|
+
const debounce = debounceRef.current;
|
|
73
|
+
debounceRef.current = null;
|
|
74
|
+
if (debounce !== null) clearTimeout(debounce);
|
|
75
|
+
|
|
76
|
+
const controller = abortRef.current;
|
|
73
77
|
abortRef.current = null;
|
|
78
|
+
controller?.abort();
|
|
74
79
|
}, []);
|
|
75
80
|
|
|
76
81
|
const resetAssist = useCallback((): boolean => {
|
|
@@ -79,9 +84,9 @@ export function useCommandBarAssist({
|
|
|
79
84
|
if (active.status === "idle" || active.source === "auto") return false;
|
|
80
85
|
cancelPending();
|
|
81
86
|
dismissedQueryRef.current = active.query;
|
|
82
|
-
|
|
87
|
+
updateAssistState({ status: "idle" });
|
|
83
88
|
return true;
|
|
84
|
-
}, [cancelPending]);
|
|
89
|
+
}, [cancelPending, updateAssistState]);
|
|
85
90
|
|
|
86
91
|
const runAssist = useCallback((query: string, source: AssistRequestSource) => {
|
|
87
92
|
const trimmed = query.trim();
|
|
@@ -96,35 +101,35 @@ export function useCommandBarAssist({
|
|
|
96
101
|
|
|
97
102
|
const cached = answersRef.current.get(trimmed);
|
|
98
103
|
if (cached) {
|
|
99
|
-
|
|
104
|
+
updateAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates: cached });
|
|
100
105
|
return;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
const controller = new AbortController();
|
|
104
109
|
abortRef.current = controller;
|
|
105
|
-
|
|
110
|
+
updateAssistState({ status: "loading", query: trimmed, source: resolveSource() });
|
|
106
111
|
|
|
107
112
|
void (async () => {
|
|
108
113
|
try {
|
|
109
114
|
const response = await apiClient.assistCommand(trimmed, getInventoryRef.current(), {
|
|
110
115
|
signal: controller.signal,
|
|
111
116
|
});
|
|
112
|
-
if (controller.signal.aborted) return;
|
|
117
|
+
if (controller.signal.aborted || abortRef.current !== controller) return;
|
|
113
118
|
const candidates = response?.candidates ?? [];
|
|
114
119
|
answersRef.current.set(trimmed, candidates);
|
|
115
|
-
|
|
120
|
+
updateAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates });
|
|
116
121
|
} catch (error) {
|
|
117
|
-
if (controller.signal.aborted) return;
|
|
122
|
+
if (controller.signal.aborted || abortRef.current !== controller) return;
|
|
118
123
|
const kind = classifyAssistError(error);
|
|
119
124
|
if (kind === "rate-limited") {
|
|
120
125
|
rateLimitedUntilRef.current = Date.now() + ASSIST_RATE_LIMIT_BACKOFF_MS;
|
|
121
126
|
}
|
|
122
|
-
|
|
127
|
+
updateAssistState({ status: "error", query: trimmed, source: resolveSource(), kind });
|
|
123
128
|
} finally {
|
|
124
129
|
if (abortRef.current === controller) abortRef.current = null;
|
|
125
130
|
}
|
|
126
131
|
})();
|
|
127
|
-
}, [cancelPending]);
|
|
132
|
+
}, [cancelPending, updateAssistState]);
|
|
128
133
|
|
|
129
134
|
const askAssist = useCallback(() => {
|
|
130
135
|
const trimmed = rootQueryRef.current.trim();
|
|
@@ -134,17 +139,17 @@ export function useCommandBarAssist({
|
|
|
134
139
|
// The background ask already on the wire asks this exact question; asking
|
|
135
140
|
// again would only abort it and start the wait over.
|
|
136
141
|
if (active.status === "loading" && active.query === trimmed) {
|
|
137
|
-
|
|
142
|
+
updateAssistState({ ...active, source: "explicit" });
|
|
138
143
|
return;
|
|
139
144
|
}
|
|
140
145
|
runAssist(trimmed, "explicit");
|
|
141
|
-
}, [runAssist]);
|
|
146
|
+
}, [runAssist, updateAssistState]);
|
|
142
147
|
|
|
143
148
|
useEffect(() => {
|
|
144
149
|
const trimmed = rootQuery.trim();
|
|
145
150
|
const active = assistStateRef.current;
|
|
146
151
|
if (active.status !== "idle" && active.query !== trimmed) {
|
|
147
|
-
|
|
152
|
+
updateAssistState({ status: "idle" });
|
|
148
153
|
}
|
|
149
154
|
if (handledQueryRef.current !== null && handledQueryRef.current !== trimmed) {
|
|
150
155
|
// Whatever is in flight describes text the user has already moved past.
|
|
@@ -158,21 +163,24 @@ export function useCommandBarAssist({
|
|
|
158
163
|
const cached = answersRef.current.get(trimmed);
|
|
159
164
|
if (cached) {
|
|
160
165
|
handledQueryRef.current = trimmed;
|
|
161
|
-
|
|
166
|
+
updateAssistState({ status: "answered", query: trimmed, source: "auto", candidates: cached });
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
164
169
|
if (Date.now() < rateLimitedUntilRef.current) return;
|
|
165
170
|
|
|
166
|
-
|
|
171
|
+
const debounce = setTimeout(() => {
|
|
172
|
+
// A cleared timer can already be queued. Only the timer still owned by
|
|
173
|
+
// this effect may start a request.
|
|
174
|
+
if (debounceRef.current !== debounce) return;
|
|
167
175
|
debounceRef.current = null;
|
|
168
176
|
runAssist(trimmed, "auto");
|
|
169
177
|
}, ASSIST_DEBOUNCE_MS);
|
|
178
|
+
debounceRef.current = debounce;
|
|
170
179
|
return () => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
debounceRef.current = null;
|
|
180
|
+
clearTimeout(debounce);
|
|
181
|
+
if (debounceRef.current === debounce) debounceRef.current = null;
|
|
174
182
|
};
|
|
175
|
-
}, [autoAsk, cancelPending, rootQuery, runAssist]);
|
|
183
|
+
}, [autoAsk, cancelPending, rootQuery, runAssist, updateAssistState]);
|
|
176
184
|
|
|
177
185
|
useEffect(() => cancelPending, [cancelPending]);
|
|
178
186
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act } from "react";
|
|
2
3
|
import { apiClient, setCloudApiFetchTransport } from "../../../api-client";
|
|
3
4
|
import { testRender } from "../../../renderers/opentui/test-utils";
|
|
4
5
|
import type { PluginRegistry } from "../../../plugins/registry";
|
|
@@ -255,6 +256,48 @@ describe("CommandBar AI assist", () => {
|
|
|
255
256
|
expect(requests).toHaveLength(1);
|
|
256
257
|
});
|
|
257
258
|
|
|
259
|
+
test("does not restart an ask when Enter repeats before loading renders", async () => {
|
|
260
|
+
signInVerified();
|
|
261
|
+
let releaseResponse = () => {};
|
|
262
|
+
const held = new Promise<void>((resolve) => { releaseResponse = resolve; });
|
|
263
|
+
const requests = mockAssistTransport(async () => {
|
|
264
|
+
await held;
|
|
265
|
+
return jsonResponse({
|
|
266
|
+
candidates: [{ input: "CHAT #general", title: "Open the general channel", prefix: "CHAT", confidence: 0.9 }],
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
testSetup = await testRender(
|
|
271
|
+
<CommandBarHarness query="new chat pane" />,
|
|
272
|
+
{ width: 120, height: 20 },
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
await testSetup.renderOnce();
|
|
276
|
+
expect(testSetup.captureCharFrame()).toContain("Thinking…");
|
|
277
|
+
|
|
278
|
+
await act(async () => {
|
|
279
|
+
for (let press = 0; press < 2; press += 1) {
|
|
280
|
+
testSetup!.renderer.keyInput.emit("keypress", {
|
|
281
|
+
ctrl: false,
|
|
282
|
+
meta: false,
|
|
283
|
+
option: false,
|
|
284
|
+
shift: false,
|
|
285
|
+
eventType: "press",
|
|
286
|
+
name: "return",
|
|
287
|
+
sequence: "\r",
|
|
288
|
+
repeated: press > 0,
|
|
289
|
+
stopPropagation: () => {},
|
|
290
|
+
preventDefault: () => {},
|
|
291
|
+
} as any);
|
|
292
|
+
}
|
|
293
|
+
await testSetup!.renderOnce();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(requests).toHaveLength(1);
|
|
297
|
+
releaseResponse();
|
|
298
|
+
await waitForFrameToContain("#general · Open the general channel", ASSIST_WAIT_ATTEMPTS);
|
|
299
|
+
});
|
|
300
|
+
|
|
258
301
|
test("drops the section when a background ask fails", async () => {
|
|
259
302
|
signInVerified();
|
|
260
303
|
const requests = mockAssistTransport(() => jsonResponse({ error: "assist-unavailable" }, 503));
|
package/src/news/types.ts
CHANGED
|
@@ -104,12 +104,13 @@ export class ChatControllerChannels {
|
|
|
104
104
|
return request;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
async refreshChatState(): Promise<void> {
|
|
107
|
+
async refreshChatState(isCurrent: () => boolean = () => true): Promise<void> {
|
|
108
108
|
if (!this.options.canLoadPrivateState()) {
|
|
109
109
|
await this.refreshPresence();
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
112
112
|
const state = await apiClient.getChatState();
|
|
113
|
+
if (!isCurrent()) return;
|
|
113
114
|
this.channels = normalizeChannels(state.channels);
|
|
114
115
|
this.onlineCount = state.onlineCount;
|
|
115
116
|
for (const entry of state.channelStates) {
|
|
@@ -197,9 +197,15 @@ export class ChatController {
|
|
|
197
197
|
/** Single-flight: every open chat pane calls this on mount. */
|
|
198
198
|
async refreshSession(): Promise<void> {
|
|
199
199
|
if (this.sessionRefreshPromise) return this.sessionRefreshPromise;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
this.realtime.stopSessionRetry();
|
|
201
|
+
const request = this.runSessionRefresh()
|
|
202
|
+
.catch((error) => {
|
|
203
|
+
this.realtime.scheduleSessionRetry();
|
|
204
|
+
throw error;
|
|
205
|
+
})
|
|
206
|
+
.finally(() => {
|
|
207
|
+
this.sessionRefreshPromise = null;
|
|
208
|
+
});
|
|
203
209
|
this.sessionRefreshPromise = request;
|
|
204
210
|
return request;
|
|
205
211
|
}
|
|
@@ -215,7 +221,8 @@ export class ChatController {
|
|
|
215
221
|
ensureRealtimeSubscriptions: () => this.realtime.ensureRealtimeSubscriptions(),
|
|
216
222
|
persistChannelState: (channelId) => this.storage.persistChannelState(channelId),
|
|
217
223
|
persistSession: (sessionToken, user) => this.storage.persistSession(sessionToken, user),
|
|
218
|
-
refreshChatState: () => this.refreshChatState(),
|
|
224
|
+
refreshChatState: (isCurrent) => this.channelCatalog.refreshChatState(isCurrent),
|
|
225
|
+
scheduleSessionRetry: () => this.realtime.scheduleSessionRetry(),
|
|
219
226
|
session: this.session,
|
|
220
227
|
stopRealtimeSubscriptions: () => {
|
|
221
228
|
this.realtime.stopRealtimeSubscriptions();
|
|
@@ -337,11 +344,12 @@ export class ChatController {
|
|
|
337
344
|
this.appActive = appActive;
|
|
338
345
|
if (!appActive) {
|
|
339
346
|
this.realtime.stopVerificationPolling();
|
|
347
|
+
this.realtime.stopSessionRetry();
|
|
340
348
|
return;
|
|
341
349
|
}
|
|
342
350
|
this.markFocusedViewsViewed();
|
|
343
351
|
this.realtime.syncVerificationPolling();
|
|
344
|
-
void this.
|
|
352
|
+
void this.refreshSession()
|
|
345
353
|
.then(() => this.markFocusedViewsViewed())
|
|
346
354
|
.catch(() => {});
|
|
347
355
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { apiClient, type ChatNotification } from "../../../../api-client";
|
|
2
2
|
import {
|
|
3
3
|
SAFETY_REFRESH_MS,
|
|
4
|
+
SESSION_RETRY_MS,
|
|
4
5
|
VERIFICATION_POLL_MS,
|
|
5
6
|
} from "./state";
|
|
6
7
|
|
|
@@ -18,6 +19,7 @@ interface ChatControllerRealtimeOptions {
|
|
|
18
19
|
|
|
19
20
|
export class ChatControllerRealtime {
|
|
20
21
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
22
|
+
private sessionRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
21
23
|
private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
22
24
|
private chatNotificationUnsubscribe: (() => void) | null = null;
|
|
23
25
|
private chatPresenceUnsubscribe: (() => void) | null = null;
|
|
@@ -36,6 +38,27 @@ export class ChatControllerRealtime {
|
|
|
36
38
|
}, VERIFICATION_POLL_MS);
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
scheduleSessionRetry(): void {
|
|
42
|
+
if (
|
|
43
|
+
this.sessionRetryTimer
|
|
44
|
+
|| !this.options.getAppActive()
|
|
45
|
+
|| !this.options.hasSession()
|
|
46
|
+
) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.sessionRetryTimer = setTimeout(() => {
|
|
50
|
+
this.sessionRetryTimer = null;
|
|
51
|
+
void this.options.refreshSession().catch(() => {});
|
|
52
|
+
}, SESSION_RETRY_MS);
|
|
53
|
+
this.sessionRetryTimer.unref?.();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stopSessionRetry(): void {
|
|
57
|
+
if (!this.sessionRetryTimer) return;
|
|
58
|
+
clearTimeout(this.sessionRetryTimer);
|
|
59
|
+
this.sessionRetryTimer = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
ensureRealtimeSubscriptions(): void {
|
|
40
63
|
if (!this.chatNotificationUnsubscribe) {
|
|
41
64
|
this.chatNotificationUnsubscribe = apiClient.subscribeChatNotifications((notification) => {
|
|
@@ -85,6 +108,7 @@ export class ChatControllerRealtime {
|
|
|
85
108
|
|
|
86
109
|
stopAll(): void {
|
|
87
110
|
this.stopVerificationPolling();
|
|
111
|
+
this.stopSessionRetry();
|
|
88
112
|
this.stopSafetyRefresh();
|
|
89
113
|
this.stopRealtimeSubscriptions();
|
|
90
114
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../../../api-client";
|
|
2
|
+
import { ApiRequestError } from "../../../../api-client/errors";
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_CHAT_CHANNEL_ID,
|
|
4
5
|
type ChannelRuntimeState,
|
|
@@ -93,7 +94,8 @@ interface RefreshChatControllerSessionOptions {
|
|
|
93
94
|
ensureRealtimeSubscriptions: () => void;
|
|
94
95
|
persistChannelState: (channelId: string) => void;
|
|
95
96
|
persistSession: (sessionToken: string | null, user: ChatSessionUser | null) => void;
|
|
96
|
-
refreshChatState: () => Promise<void>;
|
|
97
|
+
refreshChatState: (isCurrent?: () => boolean) => Promise<void>;
|
|
98
|
+
scheduleSessionRetry: () => void;
|
|
97
99
|
session: ChatControllerSessionState;
|
|
98
100
|
stopRealtimeSubscriptions: () => void;
|
|
99
101
|
stopSafetyRefresh: () => void;
|
|
@@ -110,6 +112,7 @@ export async function refreshChatControllerSession({
|
|
|
110
112
|
persistChannelState,
|
|
111
113
|
persistSession,
|
|
112
114
|
refreshChatState,
|
|
115
|
+
scheduleSessionRetry,
|
|
113
116
|
session,
|
|
114
117
|
stopRealtimeSubscriptions,
|
|
115
118
|
stopSafetyRefresh,
|
|
@@ -119,20 +122,85 @@ export async function refreshChatControllerSession({
|
|
|
119
122
|
session.sessionToken = apiClient.getSessionToken();
|
|
120
123
|
const apiSession = await apiClient.getSession();
|
|
121
124
|
if (!apiSession) {
|
|
122
|
-
const persistedToken = apiClient.getSessionToken()
|
|
123
|
-
// A 200 with no user
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
125
|
+
const persistedToken = apiClient.getSessionToken();
|
|
126
|
+
// A 200 with no user used to mean the desktop backend had not received the
|
|
127
|
+
// restored cookie yet, so do not discard a captured native session on that
|
|
128
|
+
// response alone. Probe another authenticated endpoint: success validates
|
|
129
|
+
// the token, a 401 proves it expired, and a transient failure keeps offline
|
|
130
|
+
// startup from signing the user out.
|
|
128
131
|
if (persistedToken) {
|
|
129
132
|
session.sessionToken = persistedToken;
|
|
130
133
|
session.user = session.user ?? normalizeSessionUser(apiClient.getCurrentUser());
|
|
134
|
+
// getSession() clears the API client's cached user when the endpoint
|
|
135
|
+
// answers null. Keep both session views aligned while the protected
|
|
136
|
+
// request validates the preserved native credential.
|
|
137
|
+
apiClient.restoreCachedUser(session.user);
|
|
131
138
|
session.sessionChecked = true;
|
|
132
139
|
persistSession(session.sessionToken, session.user);
|
|
133
140
|
emit();
|
|
141
|
+
|
|
142
|
+
const credentialIsCurrent = () =>
|
|
143
|
+
apiClient.getSessionToken() === persistedToken && session.sessionToken === persistedToken;
|
|
144
|
+
const handleValidationError = (error: unknown) => {
|
|
145
|
+
if (!credentialIsCurrent()) return;
|
|
146
|
+
if (error instanceof ApiRequestError && error.status === 401) {
|
|
147
|
+
apiClient.setSessionToken(null);
|
|
148
|
+
session.sessionToken = null;
|
|
149
|
+
applySignedOut();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (error instanceof ApiRequestError && error.status === 403 && session.user) {
|
|
153
|
+
session.user = { ...session.user, emailVerified: false };
|
|
154
|
+
apiClient.restoreCachedUser(session.user);
|
|
155
|
+
persistSession(session.sessionToken, session.user);
|
|
156
|
+
emit();
|
|
157
|
+
syncVerificationPolling();
|
|
158
|
+
stopSafetyRefresh();
|
|
159
|
+
stopRealtimeSubscriptions();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
scheduleSessionRetry();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (!session.user?.emailVerified) {
|
|
166
|
+
try {
|
|
167
|
+
const profile = await apiClient.getAccountProfile();
|
|
168
|
+
if (!credentialIsCurrent()) return;
|
|
169
|
+
const persistedProfile = {
|
|
170
|
+
...profile,
|
|
171
|
+
updatedAt: profile.updatedAt ?? undefined,
|
|
172
|
+
};
|
|
173
|
+
session.user = normalizeSessionUser(persistedProfile);
|
|
174
|
+
apiClient.restoreCachedUser(persistedProfile);
|
|
175
|
+
persistSession(session.sessionToken, session.user);
|
|
176
|
+
emit();
|
|
177
|
+
} catch (error) {
|
|
178
|
+
handleValidationError(error);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!session.user?.emailVerified) {
|
|
183
|
+
syncVerificationPolling();
|
|
184
|
+
stopSafetyRefresh();
|
|
185
|
+
stopRealtimeSubscriptions();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await refreshChatState(credentialIsCurrent);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
handleValidationError(error);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!credentialIsCurrent()) return;
|
|
197
|
+
|
|
198
|
+
stopVerificationPolling();
|
|
199
|
+
ensureRealtimeSubscriptions();
|
|
200
|
+
ensureOpenChannelConnections();
|
|
134
201
|
return;
|
|
135
202
|
}
|
|
203
|
+
session.sessionToken = null;
|
|
136
204
|
applySignedOut();
|
|
137
205
|
return;
|
|
138
206
|
}
|
|
@@ -152,7 +220,7 @@ export async function refreshChatControllerSession({
|
|
|
152
220
|
if (nextUser?.emailVerified) {
|
|
153
221
|
stopVerificationPolling();
|
|
154
222
|
ensureRealtimeSubscriptions();
|
|
155
|
-
await refreshChatState().catch(() =>
|
|
223
|
+
await refreshChatState().catch(() => scheduleSessionRetry());
|
|
156
224
|
ensureOpenChannelConnections();
|
|
157
225
|
return;
|
|
158
226
|
}
|
|
@@ -21,6 +21,7 @@ export const TRANSCRIPT_CACHE_POLICY = {
|
|
|
21
21
|
};
|
|
22
22
|
export const DRAFT_SYNC_DEBOUNCE_MS = 250;
|
|
23
23
|
export const VERIFICATION_POLL_MS = 5_000;
|
|
24
|
+
export const SESSION_RETRY_MS = 30_000;
|
|
24
25
|
export const SAFETY_REFRESH_MS = 30_000;
|
|
25
26
|
export const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
26
27
|
|