gloomberb 0.13.1 → 0.13.2

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 (92) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +34 -0
  3. package/src/api-client/request.ts +4 -1
  4. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  5. package/src/cli/pane-functions/cloud-session.ts +38 -0
  6. package/src/cli/pane-functions/index.ts +5 -1
  7. package/src/cli/pane-functions/screenshot.ts +2 -19
  8. package/src/components/command-bar/assist/runtime.ts +38 -30
  9. package/src/components/command-bar/surface/assist.test.tsx +43 -0
  10. package/src/news/types.ts +1 -0
  11. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  12. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  13. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  14. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  15. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  16. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  17. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  18. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  19. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  20. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  21. package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
  22. package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
  23. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  24. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  25. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  26. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  27. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  28. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  29. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  30. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  31. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  32. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  33. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  34. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  35. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  36. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  37. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  38. package/src/plugins/builtin/earnings/headless.ts +162 -0
  39. package/src/plugins/builtin/earnings/index.tsx +2 -0
  40. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  41. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  42. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  43. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  44. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  45. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
  46. package/src/plugins/builtin/holders/client.ts +29 -0
  47. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  48. package/src/plugins/builtin/holders/headless.ts +162 -0
  49. package/src/plugins/builtin/holders/index.tsx +14 -8
  50. package/src/plugins/builtin/holders/pane.tsx +7 -1
  51. package/src/plugins/builtin/insider/client.ts +50 -0
  52. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  53. package/src/plugins/builtin/insider/headless.ts +117 -0
  54. package/src/plugins/builtin/insider/index.tsx +21 -50
  55. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  56. package/src/plugins/builtin/insider/model.ts +72 -0
  57. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  58. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  59. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  60. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  61. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  62. package/src/plugins/builtin/news/headless.test.ts +138 -0
  63. package/src/plugins/builtin/news/headless.ts +190 -0
  64. package/src/plugins/builtin/news/index.tsx +13 -9
  65. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  66. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  67. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  68. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  69. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  70. package/src/plugins/builtin/research/client.ts +12 -0
  71. package/src/plugins/builtin/research/headless.test.ts +93 -0
  72. package/src/plugins/builtin/research/headless.ts +161 -0
  73. package/src/plugins/builtin/research/index.tsx +28 -18
  74. package/src/plugins/builtin/sec/client.ts +16 -0
  75. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  76. package/src/plugins/builtin/sec/headless.ts +69 -0
  77. package/src/plugins/builtin/sec/index.tsx +20 -75
  78. package/src/plugins/builtin/sec/model.ts +89 -0
  79. package/src/plugins/builtin/short-interest/client.ts +5 -2
  80. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  81. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  82. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  83. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  84. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  85. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  86. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  87. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  88. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  89. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  90. package/src/plugins/prediction-markets/headless.ts +255 -0
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/sources/gloomberb-cloud/news.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -264,6 +264,40 @@ describe("apiClient auth cookies", () => {
264
264
  ]);
265
265
  });
266
266
 
267
+ test("ignores a stale response cookie after the session credential changes", async () => {
268
+ let releaseResponse: (() => void) | null = null;
269
+ let markRequestStarted: (() => void) | null = null;
270
+ const requestStarted = new Promise<void>((resolve) => {
271
+ markRequestStarted = resolve;
272
+ });
273
+
274
+ apiClient.setSessionToken("old-session.value");
275
+ setCloudApiFetchTransport(async () => {
276
+ markRequestStarted?.();
277
+ await new Promise<void>((resolve) => {
278
+ releaseResponse = resolve;
279
+ });
280
+ return createResponse({
281
+ channels: [],
282
+ onlineCount: 0,
283
+ channelStates: [],
284
+ notifications: [],
285
+ }, {
286
+ cookies: [
287
+ "__Secure-gloomberb.session_token=old-session.value; Path=/; HttpOnly; Secure; SameSite=None",
288
+ ],
289
+ });
290
+ });
291
+
292
+ const staleRequest = apiClient.getChatState();
293
+ await requestStarted;
294
+ apiClient.setSessionToken("new-session.value");
295
+ releaseResponse?.();
296
+ await staleRequest;
297
+
298
+ expect(apiClient.getSessionToken()).toBe("new-session.value");
299
+ });
300
+
267
301
  test("uses an installed cloud API fetch transport for auth cookie capture", async () => {
268
302
  const seenCookies: Array<string | null> = [];
269
303
  globalThis.fetch = mockFetch(async () => {
@@ -141,6 +141,7 @@ export class CloudApiRequestTransport {
141
141
  headers.set("Content-Type", "application/json");
142
142
  }
143
143
  this.setSessionCookieHeader(headers);
144
+ const sessionTokenAtRequestStart = this.sessionToken;
144
145
  headers.set("Origin", this.baseUrl);
145
146
 
146
147
  const operation = `${options?.method ?? "GET"} ${path.split("?")[0]}`;
@@ -151,7 +152,9 @@ export class CloudApiRequestTransport {
151
152
  credentials: "include",
152
153
  });
153
154
  throwIfRequestAborted(options?.signal);
154
- this.extractSessionCookie(res);
155
+ if (this.sessionToken === sessionTokenAtRequestStart) {
156
+ this.extractSessionCookie(res);
157
+ }
155
158
  const text = await res.text();
156
159
  throwIfRequestAborted(options?.signal);
157
160
 
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { apiClient } from "../../api-client";
3
+ import type { MarketContext } from "../types";
4
+ import {
5
+ resolvePersistedCloudSessionToken,
6
+ withPersistedCloudSession,
7
+ } from "./cloud-session";
8
+
9
+ function contextWithSession(
10
+ key: string,
11
+ sessionToken: string,
12
+ ): Pick<MarketContext, "persistence"> {
13
+ return {
14
+ persistence: {
15
+ pluginState: {
16
+ get: (_pluginId: string, candidate: string) => candidate === key
17
+ ? { value: { sessionToken }, schemaVersion: 1, updatedAt: 1 }
18
+ : null,
19
+ },
20
+ },
21
+ } as Pick<MarketContext, "persistence">;
22
+ }
23
+
24
+ describe("pane function Cloud session", () => {
25
+ test("restores the current and legacy persisted session keys", () => {
26
+ expect(resolvePersistedCloudSessionToken(
27
+ contextWithSession("resume:session", "current-session"),
28
+ )).toBe("current-session");
29
+ expect(resolvePersistedCloudSessionToken(
30
+ contextWithSession("session", "legacy-session"),
31
+ )).toBe("legacy-session");
32
+ });
33
+
34
+ test("scopes the persisted session to one report load", async () => {
35
+ const initial = apiClient.getSessionToken();
36
+ apiClient.setSessionToken("previous-session");
37
+ try {
38
+ let activeSession: string | null = null;
39
+ const result = await withPersistedCloudSession(
40
+ contextWithSession("resume:session", "report-session"),
41
+ async () => {
42
+ activeSession = apiClient.getSessionToken();
43
+ return 42;
44
+ },
45
+ );
46
+
47
+ expect(result).toBe(42);
48
+ expect(activeSession).toBe("report-session");
49
+ expect(apiClient.getSessionToken()).toBe("previous-session");
50
+ } finally {
51
+ apiClient.setSessionToken(initial);
52
+ }
53
+ });
54
+ });
@@ -0,0 +1,38 @@
1
+ import { apiClient } from "../../api-client";
2
+ import type { MarketContext } from "../types";
3
+
4
+ const CLOUD_PLUGIN_ID = "gloomberb-cloud";
5
+ const CLOUD_SESSION_KEYS = ["resume:session", "session"] as const;
6
+
7
+ interface PersistedCloudSession {
8
+ sessionToken?: unknown;
9
+ }
10
+
11
+ export function resolvePersistedCloudSessionToken(
12
+ context: Pick<MarketContext, "persistence">,
13
+ ): string | null {
14
+ for (const key of CLOUD_SESSION_KEYS) {
15
+ const value = context.persistence.pluginState.get<PersistedCloudSession>(
16
+ CLOUD_PLUGIN_ID,
17
+ key,
18
+ 1,
19
+ )?.value;
20
+ if (typeof value?.sessionToken === "string" && value.sessionToken.length > 0) {
21
+ return value.sessionToken;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export async function withPersistedCloudSession<T>(
28
+ context: Pick<MarketContext, "persistence">,
29
+ run: () => Promise<T>,
30
+ ): Promise<T> {
31
+ const previousSessionToken = apiClient.getSessionToken();
32
+ apiClient.setSessionToken(resolvePersistedCloudSessionToken(context));
33
+ try {
34
+ return await run();
35
+ } finally {
36
+ apiClient.setSessionToken(previousSessionToken);
37
+ }
38
+ }
@@ -28,6 +28,7 @@ import {
28
28
  isDataPaneForDomFallback,
29
29
  normalizeCapabilityOptions,
30
30
  } from "./capabilities";
31
+ import { withPersistedCloudSession } from "./cloud-session";
31
32
 
32
33
  async function withPaneRuntime<T>(
33
34
  ctx: CliCommandContext,
@@ -63,7 +64,10 @@ export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
63
64
  + `Use "gloomberb catalog ${resolved.token}" to inspect readiness.`,
64
65
  );
65
66
  }
66
- const report = await buildFunctionReport(resolved, context, parsed.arg);
67
+ const report = await withPersistedCloudSession(
68
+ context,
69
+ () => buildFunctionReport(resolved, context, parsed.arg),
70
+ );
67
71
  if (parsed.requireBotSafe && (report.data.empty || !report.data.complete)) {
68
72
  const unavailable = report.data.unavailableSymbols.length > 0
69
73
  ? ` Missing data for ${report.data.unavailableSymbols.join(", ")}.`
@@ -19,6 +19,7 @@ import { optionPaneState } from "./options";
19
19
  import type { ResolvedPaneFunction } from "./resolver";
20
20
  import type { MarketContext } from "../types";
21
21
  import { capabilityPluginState } from "./capabilities";
22
+ import { resolvePersistedCloudSessionToken } from "./cloud-session";
22
23
  import type { RemoteUiNodeSnapshot } from "../../remote/types";
23
24
  import {
24
25
  graphRowsForFinancials,
@@ -76,8 +77,6 @@ const DESKTOP_CELL_HEIGHT_PX = 18;
76
77
  const OPTIONS_PANE_ID = "options";
77
78
  const MARKET_VALUATION_PANE_ID = "market-valuation";
78
79
  const ECON_STATISTICS_PANE_ID = "econ-statistics";
79
- const CLOUD_PLUGIN_ID = "gloomberb-cloud";
80
- const CLOUD_SESSION_KEYS = ["resume:session", "session"] as const;
81
80
  const CREDENTIAL_FIELD_NAMES = new Set([
82
81
  "accesstoken",
83
82
  "accessurl",
@@ -96,10 +95,6 @@ const CREDENTIAL_FIELD_NAMES = new Set([
96
95
  "token",
97
96
  ]);
98
97
 
99
- interface PersistedShotCloudSession {
100
- sessionToken?: unknown;
101
- }
102
-
103
98
  /**
104
99
  * The proxy receives the desktop session out of band. Keeping this separate
105
100
  * from DesktopPaneShotPayload prevents the token from entering the page,
@@ -138,21 +133,9 @@ export function stripDesktopShotCredentials<T>(value: T): T {
138
133
  export function resolveDesktopShotApiProxy(
139
134
  context: Pick<MarketContext, "persistence">,
140
135
  ): DesktopPaneShotApiProxy {
141
- let sessionToken: string | null = null;
142
- for (const key of CLOUD_SESSION_KEYS) {
143
- const value = context.persistence.pluginState.get<PersistedShotCloudSession>(
144
- CLOUD_PLUGIN_ID,
145
- key,
146
- 1,
147
- )?.value;
148
- if (typeof value?.sessionToken === "string" && value.sessionToken.length > 0) {
149
- sessionToken = value.sessionToken;
150
- break;
151
- }
152
- }
153
136
  return {
154
137
  baseUrl: getCloudApiBaseUrl(),
155
- sessionToken,
138
+ sessionToken: resolvePersistedCloudSessionToken(context),
156
139
  };
157
140
  }
158
141
 
@@ -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
  }