gloomberb 0.9.7 → 0.9.8
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/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -45,6 +45,55 @@ function makeOwnMessage(content = "typo", createdAt = recentChatTimestamp()): Ch
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const makeNamedMessage = (index: number, username: string): ChatMessage => ({
|
|
49
|
+
id: `named-${index}`,
|
|
50
|
+
channelId: "everyone",
|
|
51
|
+
content: `message from ${username}`,
|
|
52
|
+
replyToId: null,
|
|
53
|
+
createdAt: `2026-03-30T00:01:${String(index).padStart(2, "0")}.000Z`,
|
|
54
|
+
user: { id: `u-${username}`, username, displayName: username },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function renderFocusedComposerWithDraft(
|
|
58
|
+
controller: ReturnType<typeof createController>,
|
|
59
|
+
draft: string,
|
|
60
|
+
options: Parameters<typeof createHarness>[1] = {},
|
|
61
|
+
) {
|
|
62
|
+
const harnessOptions = {
|
|
63
|
+
width: 72,
|
|
64
|
+
height: 14,
|
|
65
|
+
...options,
|
|
66
|
+
};
|
|
67
|
+
await act(async () => {
|
|
68
|
+
testSetup = await testRender(createHarness(controller, harnessOptions), {
|
|
69
|
+
width: harnessOptions.width ?? 72,
|
|
70
|
+
height: harnessOptions.height ?? 14,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await flushFrame();
|
|
75
|
+
|
|
76
|
+
const frameBeforeClick = setup().captureCharFrame().split("\n");
|
|
77
|
+
const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
|
|
78
|
+
const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
|
|
79
|
+
|
|
80
|
+
expect(inputRow).toBeGreaterThanOrEqual(0);
|
|
81
|
+
expect(inputCol).toBeGreaterThanOrEqual(0);
|
|
82
|
+
|
|
83
|
+
await act(async () => {
|
|
84
|
+
await setup().mockMouse.click(inputCol + 1, inputRow);
|
|
85
|
+
await setup().renderOnce();
|
|
86
|
+
await setup().renderOnce();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
await setup().mockInput.typeText(draft);
|
|
91
|
+
await setup().renderOnce();
|
|
92
|
+
await setup().renderOnce();
|
|
93
|
+
});
|
|
94
|
+
await flushFrame();
|
|
95
|
+
}
|
|
96
|
+
|
|
48
97
|
beforeEach(() => {
|
|
49
98
|
installChatApiTestDefaults();
|
|
50
99
|
});
|
|
@@ -177,6 +226,82 @@ describe("ChatContent", () => {
|
|
|
177
226
|
expect(frameAfterType).not.toContain("> betaalpha");
|
|
178
227
|
});
|
|
179
228
|
|
|
229
|
+
test("autocompletes recent user mentions from the focused composer", async () => {
|
|
230
|
+
const controller = createController({
|
|
231
|
+
messages: [
|
|
232
|
+
makeNamedMessage(1, "alpha"),
|
|
233
|
+
makeNamedMessage(2, "bravo"),
|
|
234
|
+
makeNamedMessage(3, "charlie"),
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await renderFocusedComposerWithDraft(controller, "@");
|
|
239
|
+
|
|
240
|
+
let frame = setup().captureCharFrame();
|
|
241
|
+
expect(frame).toContain("@charlie");
|
|
242
|
+
expect(frame).toContain("@bravo");
|
|
243
|
+
|
|
244
|
+
await emitKeypress({ name: "down", sequence: "\u001b[B" });
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
setup().mockInput.pressEnter();
|
|
248
|
+
await setup().renderOnce();
|
|
249
|
+
await setup().renderOnce();
|
|
250
|
+
});
|
|
251
|
+
await flushFrame();
|
|
252
|
+
|
|
253
|
+
frame = setup().captureCharFrame();
|
|
254
|
+
expect(frame).toContain("> @bravo");
|
|
255
|
+
expect(frame).not.toContain("@charlie");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("tab accepts the current mention suggestion without pane cycling", async () => {
|
|
259
|
+
const controller = createController({
|
|
260
|
+
messages: [
|
|
261
|
+
makeNamedMessage(1, "alpha"),
|
|
262
|
+
makeNamedMessage(2, "bravo"),
|
|
263
|
+
makeNamedMessage(3, "charlie"),
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await renderFocusedComposerWithDraft(controller, "@");
|
|
268
|
+
|
|
269
|
+
const event = await emitKeypress({ name: "tab", sequence: "\t" });
|
|
270
|
+
await flushFrame();
|
|
271
|
+
|
|
272
|
+
const frame = setup().captureCharFrame();
|
|
273
|
+
expect(frame).toContain("> @charlie");
|
|
274
|
+
expect(frame).not.toContain("@bravo");
|
|
275
|
+
expect(event.defaultPrevented).toBe(true);
|
|
276
|
+
expect(event.propagationStopped).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("leaves tab for the command bar when mention suggestions are open behind it", async () => {
|
|
280
|
+
const controller = createController({
|
|
281
|
+
messages: [
|
|
282
|
+
makeNamedMessage(1, "alpha"),
|
|
283
|
+
makeNamedMessage(2, "bravo"),
|
|
284
|
+
makeNamedMessage(3, "charlie"),
|
|
285
|
+
],
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await renderFocusedComposerWithDraft(controller, "@", {
|
|
289
|
+
configureState: (state) => {
|
|
290
|
+
state.commandBarOpen = true;
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const event = await emitKeypress({ name: "tab", sequence: "\t" });
|
|
295
|
+
await flushFrame();
|
|
296
|
+
|
|
297
|
+
const frame = setup().captureCharFrame();
|
|
298
|
+
expect(frame).toContain("@charlie");
|
|
299
|
+
expect(frame).toContain("> @");
|
|
300
|
+
expect(frame).not.toContain("> @charlie");
|
|
301
|
+
expect(event.defaultPrevented).toBe(false);
|
|
302
|
+
expect(event.propagationStopped).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
180
305
|
test("up arrow selects the newest message first when nothing is selected", async () => {
|
|
181
306
|
const controller = createController({
|
|
182
307
|
messages: [makeMessage(1), makeMessage(2)],
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
3
|
-
import type { ChatChannel
|
|
3
|
+
import type { ChatChannel } from "../../../../api-client";
|
|
4
4
|
import { useThrottledCommitValue } from "../../../../react/use-throttled-commit-value";
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_CHAT_CHANNEL_ID,
|
|
7
7
|
normalizeChannelId,
|
|
8
8
|
} from "../channels";
|
|
9
|
-
import type { ChatContentController } from "./types";
|
|
10
9
|
|
|
11
10
|
const CHANNEL_NAVIGATION_COMMIT_DELAY_MS = 150;
|
|
12
11
|
|
|
@@ -20,8 +19,6 @@ export function useChatChannelNavigation({
|
|
|
20
19
|
channelIdRef,
|
|
21
20
|
channels,
|
|
22
21
|
channelsLoading,
|
|
23
|
-
closeProfilePopover,
|
|
24
|
-
controller,
|
|
25
22
|
focused,
|
|
26
23
|
inputFocused,
|
|
27
24
|
onChannelChange,
|
|
@@ -33,8 +30,6 @@ export function useChatChannelNavigation({
|
|
|
33
30
|
channelIdRef: MutableRefObject<string>;
|
|
34
31
|
channels: ChatChannel[];
|
|
35
32
|
channelsLoading: boolean;
|
|
36
|
-
closeProfilePopover: () => void;
|
|
37
|
-
controller: ChatContentController;
|
|
38
33
|
focused: boolean;
|
|
39
34
|
inputFocused: boolean;
|
|
40
35
|
onChannelChange?: (channelId: string) => void;
|
|
@@ -46,7 +41,6 @@ export function useChatChannelNavigation({
|
|
|
46
41
|
focusChannelSidebar: () => boolean;
|
|
47
42
|
focusChatContent: () => boolean;
|
|
48
43
|
moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
|
|
49
|
-
openDirectMessage: (target: ChatUserSummary) => Promise<void>;
|
|
50
44
|
selectSidebarChannel: (channelId: string) => void;
|
|
51
45
|
setDirectExpanded: Dispatch<SetStateAction<boolean>>;
|
|
52
46
|
setSidebarFocused: (nextFocused: boolean) => void;
|
|
@@ -82,19 +76,6 @@ export function useChatChannelNavigation({
|
|
|
82
76
|
replaceValue: replaceSidebarCursorChannelId,
|
|
83
77
|
} = useThrottledCommitValue(channelId, changeChannel, CHANNEL_NAVIGATION_COMMIT_DELAY_MS);
|
|
84
78
|
|
|
85
|
-
const openDirectMessage = useCallback(async (target: ChatUserSummary) => {
|
|
86
|
-
if (!target.id && !target.username) return;
|
|
87
|
-
try {
|
|
88
|
-
const channel = await controller.openDirectChannel({
|
|
89
|
-
userId: target.id,
|
|
90
|
-
username: target.username ?? undefined,
|
|
91
|
-
});
|
|
92
|
-
setDirectExpanded(true);
|
|
93
|
-
setSidebarCursorChannelId(channel.id, { immediate: true });
|
|
94
|
-
closeProfilePopover();
|
|
95
|
-
} catch {}
|
|
96
|
-
}, [closeProfilePopover, controller, setSidebarCursorChannelId]);
|
|
97
|
-
|
|
98
79
|
useEffect(() => {
|
|
99
80
|
if (!onChannelChange || channelsLoading || channels.length === 0) return;
|
|
100
81
|
if (channels.some((channel) => channel.id === channelId)) return;
|
|
@@ -185,7 +166,6 @@ export function useChatChannelNavigation({
|
|
|
185
166
|
focusChannelSidebar,
|
|
186
167
|
focusChatContent,
|
|
187
168
|
moveSidebarChannelSelection,
|
|
188
|
-
openDirectMessage,
|
|
189
169
|
selectSidebarChannel,
|
|
190
170
|
setDirectExpanded,
|
|
191
171
|
setSidebarFocused,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TextareaRenderable } from "../../../../ui";
|
|
2
|
+
|
|
3
|
+
const clampCursorOffset = (offset: number, draft: string) => Math.max(0, Math.min(offset, draft.length));
|
|
4
|
+
|
|
5
|
+
export function getComposerCursorOffset(
|
|
6
|
+
textarea: TextareaRenderable | null | undefined,
|
|
7
|
+
draft: string,
|
|
8
|
+
): number {
|
|
9
|
+
const offset = textarea?.visualCursor?.offset ?? textarea?.cursorOffset ?? draft.length;
|
|
10
|
+
return clampCursorOffset(offset, draft);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function moveComposerCursorToOffset(
|
|
14
|
+
textarea: TextareaRenderable,
|
|
15
|
+
draft: string,
|
|
16
|
+
offset: number,
|
|
17
|
+
): void {
|
|
18
|
+
const nextOffset = clampCursorOffset(offset, draft);
|
|
19
|
+
const editBuffer = textarea.editBuffer as typeof textarea.editBuffer & {
|
|
20
|
+
setCursorByOffset?: (offset: number) => void;
|
|
21
|
+
};
|
|
22
|
+
if (typeof editBuffer.setCursorByOffset === "function") return editBuffer.setCursorByOffset(nextOffset);
|
|
23
|
+
if (typeof textarea.setCursorOffset === "function") return textarea.setCursorOffset(nextOffset);
|
|
24
|
+
try {
|
|
25
|
+
textarea.cursorOffset = nextOffset;
|
|
26
|
+
} catch {
|
|
27
|
+
// Some host renderers expose cursorOffset as read-only.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -7,31 +7,12 @@ import {
|
|
|
7
7
|
} from "../layout";
|
|
8
8
|
import { parseChatComposerCommand } from "../composer-commands";
|
|
9
9
|
import type { ChatContentController } from "./types";
|
|
10
|
+
import { getComposerCursorOffset, moveComposerCursorToOffset } from "./composer-cursor";
|
|
10
11
|
|
|
11
12
|
interface MutableRef<T> {
|
|
12
13
|
current: T;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function moveComposerCursorToEnd(textarea: TextareaRenderable, draft: string) {
|
|
16
|
-
const offset = draft.length;
|
|
17
|
-
const editBuffer = textarea.editBuffer as typeof textarea.editBuffer & {
|
|
18
|
-
setCursorByOffset?: (offset: number) => void;
|
|
19
|
-
};
|
|
20
|
-
if (typeof editBuffer.setCursorByOffset === "function") {
|
|
21
|
-
editBuffer.setCursorByOffset(offset);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
if (typeof textarea.setCursorOffset === "function") {
|
|
25
|
-
textarea.setCursorOffset(offset);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
try {
|
|
29
|
-
textarea.cursorOffset = offset;
|
|
30
|
-
} catch {
|
|
31
|
-
// Some host renderers expose cursorOffset as read-only.
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
16
|
export function useChatComposerRuntime({
|
|
36
17
|
applyingExternalDraftRef,
|
|
37
18
|
blurInput,
|
|
@@ -46,6 +27,7 @@ export function useChatComposerRuntime({
|
|
|
46
27
|
inputRef,
|
|
47
28
|
inputValueRef,
|
|
48
29
|
messages,
|
|
30
|
+
onComposerStateChange,
|
|
49
31
|
onChannelChange,
|
|
50
32
|
editingMessage,
|
|
51
33
|
latestEditableMessageId,
|
|
@@ -71,6 +53,7 @@ export function useChatComposerRuntime({
|
|
|
71
53
|
inputRef: MutableRef<TextareaRenderable | null>;
|
|
72
54
|
inputValueRef: MutableRef<string>;
|
|
73
55
|
messages: ChatMessage[];
|
|
56
|
+
onComposerStateChange?: (draft: string, cursorOffset: number) => void;
|
|
74
57
|
onChannelChange?: (channelId: string) => void;
|
|
75
58
|
editingMessage: ChatMessage | null;
|
|
76
59
|
latestEditableMessageId: string | null;
|
|
@@ -112,7 +95,7 @@ export function useChatComposerRuntime({
|
|
|
112
95
|
}
|
|
113
96
|
}, [channelId, controller, useDefaultControllerChannel]);
|
|
114
97
|
|
|
115
|
-
const replaceLocalComposer = useCallback((draft: string) => {
|
|
98
|
+
const replaceLocalComposer = useCallback((draft: string, cursorOffset = draft.length) => {
|
|
116
99
|
inputValueRef.current = draft;
|
|
117
100
|
updateComposerRows(draft);
|
|
118
101
|
const textarea = inputRef.current;
|
|
@@ -120,14 +103,20 @@ export function useChatComposerRuntime({
|
|
|
120
103
|
applyingExternalDraftRef.current = true;
|
|
121
104
|
try {
|
|
122
105
|
textarea.setText(draft);
|
|
123
|
-
|
|
106
|
+
moveComposerCursorToOffset(textarea, draft, cursorOffset);
|
|
124
107
|
} finally {
|
|
125
108
|
applyingExternalDraftRef.current = false;
|
|
126
109
|
}
|
|
127
110
|
} else if (textarea) {
|
|
128
|
-
|
|
111
|
+
moveComposerCursorToOffset(textarea, draft, cursorOffset);
|
|
129
112
|
}
|
|
130
|
-
|
|
113
|
+
onComposerStateChange?.(draft, cursorOffset);
|
|
114
|
+
}, [applyingExternalDraftRef, inputRef, inputValueRef, onComposerStateChange, updateComposerRows]);
|
|
115
|
+
|
|
116
|
+
const replaceComposerDraft = useCallback((draft: string, cursorOffset = draft.length) => {
|
|
117
|
+
replaceLocalComposer(draft, cursorOffset);
|
|
118
|
+
persistDraft(draft);
|
|
119
|
+
}, [persistDraft, replaceLocalComposer]);
|
|
131
120
|
|
|
132
121
|
const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
|
|
133
122
|
if (!canSend || index < 0 || index >= messages.length) return;
|
|
@@ -295,14 +284,22 @@ export function useChatComposerRuntime({
|
|
|
295
284
|
|
|
296
285
|
const commitLocalDraft = useCallback((draft: string) => {
|
|
297
286
|
if (applyingExternalDraftRef.current) return;
|
|
287
|
+
const previousDraft = inputValueRef.current;
|
|
298
288
|
inputValueRef.current = draft;
|
|
299
289
|
updateComposerRows(draft);
|
|
300
290
|
persistDraft(draft);
|
|
291
|
+
const rawCursorOffset = getComposerCursorOffset(inputRef.current, draft);
|
|
292
|
+
const cursorOffset = rawCursorOffset === 0 && draft.length > previousDraft.length && draft.startsWith(previousDraft)
|
|
293
|
+
? draft.length
|
|
294
|
+
: rawCursorOffset;
|
|
295
|
+
onComposerStateChange?.(draft, cursorOffset);
|
|
301
296
|
}, [
|
|
302
297
|
applyingExternalDraftRef,
|
|
303
298
|
channelId,
|
|
304
299
|
controller,
|
|
300
|
+
inputRef,
|
|
305
301
|
inputValueRef,
|
|
302
|
+
onComposerStateChange,
|
|
306
303
|
persistDraft,
|
|
307
304
|
updateComposerRows,
|
|
308
305
|
useDefaultControllerChannel,
|
|
@@ -376,6 +373,7 @@ export function useChatComposerRuntime({
|
|
|
376
373
|
focusComposer,
|
|
377
374
|
inputPlaceholder,
|
|
378
375
|
replyPreview,
|
|
376
|
+
replaceComposerDraft,
|
|
379
377
|
returnToComposer,
|
|
380
378
|
sendMessage,
|
|
381
379
|
};
|
|
@@ -5,6 +5,7 @@ import type { ChatMessage } from "../../../../api-client";
|
|
|
5
5
|
import { InlineAuthActions } from "../../cloud/auth-actions";
|
|
6
6
|
import { ChatActionChip } from "../message/action-chip";
|
|
7
7
|
import { COMPOSER_ACTION_WIDTH } from "../layout";
|
|
8
|
+
import type { ChatMentionSuggestion } from "./mentions";
|
|
8
9
|
|
|
9
10
|
const DESKTOP_CHAT_INPUT_TOP_MARGIN_PX = 6;
|
|
10
11
|
|
|
@@ -33,9 +34,73 @@ interface ChatComposerAreaProps {
|
|
|
33
34
|
replyPreview: string;
|
|
34
35
|
replyTo: ChatMessage | null;
|
|
35
36
|
sendMessage: () => void;
|
|
37
|
+
mentionSuggestions: ChatMentionSuggestion[];
|
|
38
|
+
mentionSelectedIndex: number;
|
|
39
|
+
onMentionCursorChange: () => void;
|
|
40
|
+
onMentionSelect: (index?: number) => boolean;
|
|
36
41
|
user: { id: string; username: string; emailVerified: boolean } | null;
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
function ChatMentionSuggestions({
|
|
45
|
+
contentWidth,
|
|
46
|
+
nativePaneChrome,
|
|
47
|
+
onSelect,
|
|
48
|
+
selectedIndex,
|
|
49
|
+
suggestions,
|
|
50
|
+
}: {
|
|
51
|
+
contentWidth: number;
|
|
52
|
+
nativePaneChrome: boolean | undefined;
|
|
53
|
+
onSelect: (index?: number) => boolean;
|
|
54
|
+
selectedIndex: number;
|
|
55
|
+
suggestions: ChatMentionSuggestion[];
|
|
56
|
+
}) {
|
|
57
|
+
if (suggestions.length === 0) return null;
|
|
58
|
+
const width = Math.max(18, Math.min(contentWidth, 44));
|
|
59
|
+
const menuStyle = nativePaneChrome
|
|
60
|
+
? {
|
|
61
|
+
marginBottom: 4,
|
|
62
|
+
border: `1px solid ${colors.border}`,
|
|
63
|
+
borderRadius: 6,
|
|
64
|
+
overflow: "hidden",
|
|
65
|
+
}
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Box
|
|
70
|
+
width={width}
|
|
71
|
+
height={suggestions.length}
|
|
72
|
+
flexDirection="column"
|
|
73
|
+
backgroundColor={nativePaneChrome ? colors.panel : colors.bg}
|
|
74
|
+
style={menuStyle}
|
|
75
|
+
>
|
|
76
|
+
{suggestions.map((suggestion, index) => {
|
|
77
|
+
const selected = index === selectedIndex;
|
|
78
|
+
return (
|
|
79
|
+
<Box
|
|
80
|
+
key={suggestion.username}
|
|
81
|
+
height={1}
|
|
82
|
+
width={width}
|
|
83
|
+
flexDirection="row"
|
|
84
|
+
backgroundColor={selected ? colors.selected : "transparent"}
|
|
85
|
+
onMouseDown={() => { onSelect(index); }}
|
|
86
|
+
style={{
|
|
87
|
+
cursor: "pointer",
|
|
88
|
+
paddingInline: nativePaneChrome ? 8 : undefined,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<Text
|
|
92
|
+
fg={selected ? colors.selectedText : colors.positive}
|
|
93
|
+
attributes={selected ? TextAttributes.BOLD : 0}
|
|
94
|
+
>
|
|
95
|
+
{`@${suggestion.username}`}
|
|
96
|
+
</Text>
|
|
97
|
+
</Box>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</Box>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
39
104
|
export function ChatComposerArea({
|
|
40
105
|
canSend,
|
|
41
106
|
cancelEditMessage,
|
|
@@ -57,6 +122,10 @@ export function ChatComposerArea({
|
|
|
57
122
|
replyPreview,
|
|
58
123
|
replyTo,
|
|
59
124
|
sendMessage,
|
|
125
|
+
mentionSuggestions,
|
|
126
|
+
mentionSelectedIndex,
|
|
127
|
+
onMentionCursorChange,
|
|
128
|
+
onMentionSelect,
|
|
60
129
|
user,
|
|
61
130
|
}: ChatComposerAreaProps) {
|
|
62
131
|
if (!canSend) {
|
|
@@ -122,6 +191,14 @@ export function ChatComposerArea({
|
|
|
122
191
|
</Box>
|
|
123
192
|
)}
|
|
124
193
|
|
|
194
|
+
<ChatMentionSuggestions
|
|
195
|
+
contentWidth={contentWidth}
|
|
196
|
+
nativePaneChrome={nativePaneChrome}
|
|
197
|
+
onSelect={onMentionSelect}
|
|
198
|
+
selectedIndex={mentionSelectedIndex}
|
|
199
|
+
suggestions={mentionSuggestions}
|
|
200
|
+
/>
|
|
201
|
+
|
|
125
202
|
<MessageComposer
|
|
126
203
|
inputRef={inputRef}
|
|
127
204
|
initialValue={inputValueRef.current}
|
|
@@ -132,6 +209,7 @@ export function ChatComposerArea({
|
|
|
132
209
|
height={composerHeight}
|
|
133
210
|
onFocusRequest={focusComposer}
|
|
134
211
|
onInput={commitLocalDraft}
|
|
212
|
+
onCursorChange={onMentionCursorChange}
|
|
135
213
|
keyBindings={[
|
|
136
214
|
{ name: "return", action: "submit" },
|
|
137
215
|
{ name: "linefeed", action: "submit" },
|
|
@@ -141,6 +219,7 @@ export function ChatComposerArea({
|
|
|
141
219
|
{ name: "linefeed", meta: true, action: "submit" },
|
|
142
220
|
]}
|
|
143
221
|
onSubmit={() => {
|
|
222
|
+
if (onMentionSelect()) return;
|
|
144
223
|
if (inputValueRef.current.trim()) {
|
|
145
224
|
sendMessage();
|
|
146
225
|
}
|
|
@@ -157,13 +236,17 @@ export function getChatComposerAreaHeight({
|
|
|
157
236
|
editingMessage,
|
|
158
237
|
nativePaneChrome,
|
|
159
238
|
replyTo,
|
|
239
|
+
mentionSuggestionCount,
|
|
160
240
|
}: {
|
|
161
241
|
canSend: boolean;
|
|
162
242
|
composerHeight: number;
|
|
163
243
|
editingMessage: ChatMessage | null;
|
|
164
244
|
nativePaneChrome: boolean | undefined;
|
|
165
245
|
replyTo: ChatMessage | null;
|
|
246
|
+
mentionSuggestionCount?: number;
|
|
166
247
|
}) {
|
|
167
248
|
if (!canSend) return 2;
|
|
168
|
-
return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome })
|
|
249
|
+
return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome })
|
|
250
|
+
+ (editingMessage || replyTo ? 1 : 0)
|
|
251
|
+
+ Math.max(0, mentionSuggestionCount ?? 0);
|
|
169
252
|
}
|