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.
- 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/pane-functions/cloud-session.test.ts +54 -0
- package/src/cli/pane-functions/cloud-session.ts +38 -0
- package/src/cli/pane-functions/index.ts +5 -1
- package/src/cli/pane-functions/screenshot.ts +2 -19
- 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 +2 -0
- 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.tsx +31 -221
- package/src/plugins/builtin/research/client.ts +12 -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.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/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/sources/gloomberb-cloud/news.ts +1 -0
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
|
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,
|
|
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
|
}
|