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.
Files changed (107) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/api-client/index.test.ts +34 -0
  4. package/src/api-client/request.ts +4 -1
  5. package/src/cli/desktop-pane-shot.ts +72 -2
  6. package/src/cli/pane-functions/cloud-session.test.ts +77 -0
  7. package/src/cli/pane-functions/cloud-session.ts +47 -0
  8. package/src/cli/pane-functions/index.ts +5 -1
  9. package/src/cli/pane-functions/screenshot.test.ts +27 -0
  10. package/src/cli/pane-functions/screenshot.ts +85 -20
  11. package/src/components/command-bar/assist/runtime.ts +38 -30
  12. package/src/components/command-bar/surface/assist.test.tsx +43 -0
  13. package/src/news/types.ts +1 -0
  14. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  15. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  16. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  17. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  18. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  19. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  20. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  21. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  22. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  23. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  24. package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
  25. package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
  26. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  27. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  28. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  29. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  30. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  31. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  32. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  33. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  34. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  35. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  36. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  37. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  38. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  39. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  40. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  41. package/src/plugins/builtin/earnings/headless.ts +162 -0
  42. package/src/plugins/builtin/earnings/index.tsx +35 -9
  43. package/src/plugins/builtin/earnings/model.test.ts +29 -1
  44. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  45. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  46. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  47. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  48. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  49. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
  50. package/src/plugins/builtin/holders/client.ts +29 -0
  51. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  52. package/src/plugins/builtin/holders/headless.ts +162 -0
  53. package/src/plugins/builtin/holders/index.tsx +14 -8
  54. package/src/plugins/builtin/holders/pane.tsx +7 -1
  55. package/src/plugins/builtin/insider/client.ts +50 -0
  56. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  57. package/src/plugins/builtin/insider/headless.ts +117 -0
  58. package/src/plugins/builtin/insider/index.tsx +21 -50
  59. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  60. package/src/plugins/builtin/insider/model.ts +72 -0
  61. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  62. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  63. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  64. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  65. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  66. package/src/plugins/builtin/news/headless.test.ts +138 -0
  67. package/src/plugins/builtin/news/headless.ts +190 -0
  68. package/src/plugins/builtin/news/index.tsx +13 -9
  69. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  70. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  71. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  72. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  73. package/src/plugins/builtin/research/analyst-pane.test.tsx +145 -0
  74. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  75. package/src/plugins/builtin/research/client.ts +12 -0
  76. package/src/plugins/builtin/research/corporate-actions-pane.tsx +27 -1
  77. package/src/plugins/builtin/research/event-model.ts +56 -0
  78. package/src/plugins/builtin/research/headless.test.ts +93 -0
  79. package/src/plugins/builtin/research/headless.ts +161 -0
  80. package/src/plugins/builtin/research/index.test.ts +43 -0
  81. package/src/plugins/builtin/research/index.tsx +28 -18
  82. package/src/plugins/builtin/sec/client.ts +16 -0
  83. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  84. package/src/plugins/builtin/sec/headless.ts +69 -0
  85. package/src/plugins/builtin/sec/index.tsx +20 -75
  86. package/src/plugins/builtin/sec/model.ts +89 -0
  87. package/src/plugins/builtin/short-interest/client.ts +5 -2
  88. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  89. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  90. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  91. package/src/plugins/builtin/short-interest/model.ts +15 -4
  92. package/src/plugins/builtin/short-interest/pane.tsx +1 -1
  93. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  94. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  95. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  96. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  97. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  98. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  99. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  100. package/src/plugins/prediction-markets/headless.ts +255 -0
  101. package/src/plugins/prediction-markets/index.tsx +2 -0
  102. package/src/renderers/browser/config-host.test.ts +70 -0
  103. package/src/renderers/browser/config-host.ts +74 -10
  104. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +180 -19
  105. package/src/sources/gloomberb-cloud/news.ts +1 -0
  106. package/src/updater.test.ts +17 -1
  107. 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, setAssistState] = useState<AssistRequestState>({ status: "idle" });
45
+ const [assistState, setRenderedAssistState] = useState<AssistRequestState>({ status: "idle" });
46
46
  const assistStateRef = useRef(assistState);
47
- assistStateRef.current = assistState;
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
- if (debounceRef.current) {
69
- clearTimeout(debounceRef.current);
70
- debounceRef.current = null;
71
- }
72
- abortRef.current?.abort();
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
- setAssistState({ status: "idle" });
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
- setAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates: cached });
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
- setAssistState({ status: "loading", query: trimmed, source: resolveSource() });
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
- setAssistState({ status: "answered", query: trimmed, source: resolveSource(), candidates });
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
- setAssistState({ status: "error", query: trimmed, source: resolveSource(), kind });
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
- setAssistState({ ...active, source: "explicit" });
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
- setAssistState({ status: "idle" });
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
- setAssistState({ status: "answered", query: trimmed, source: "auto", candidates: cached });
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
- debounceRef.current = setTimeout(() => {
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
- if (!debounceRef.current) return;
172
- clearTimeout(debounceRef.current);
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
@@ -38,6 +38,7 @@ export interface NewsArticle {
38
38
  scores: NewsScores;
39
39
  isBreaking: boolean;
40
40
  isDeveloping: boolean;
41
+ sourceCount?: number;
41
42
  items?: NewsStoryItem[];
42
43
 
43
44
  // Compatibility aliases for RSS/Yahoo panes and existing table columns.
@@ -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
- const request = this.runSessionRefresh().finally(() => {
201
- this.sessionRefreshPromise = null;
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.refreshChatState()
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() || session.sessionToken;
123
- // A 200 with no user is what /auth/get-session returns when no cookie
124
- // reached the server. Desktop's backend process used to treat that as a
125
- // real sign-out and persist a wiped token, so the next launch was logged
126
- // out. Keep a captured native session; hard account-missing responses
127
- // already clear the token inside getSession().
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