@runtypelabs/persona 4.8.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
- package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-IPVK3KOM.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +8 -8
- package/dist/context-mentions-7S5KVUTG.js +169 -0
- package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
- package/dist/context-mentions-inline.cjs +4 -0
- package/dist/context-mentions-inline.d.cts +203 -0
- package/dist/context-mentions-inline.d.ts +203 -0
- package/dist/context-mentions-inline.js +4 -0
- package/dist/context-mentions.cjs +295 -0
- package/dist/context-mentions.d.cts +7025 -0
- package/dist/context-mentions.d.ts +7025 -0
- package/dist/context-mentions.js +295 -0
- package/dist/index.cjs +59 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +622 -2
- package/dist/index.d.ts +622 -2
- package/dist/index.global.js +54 -46
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +59 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +17 -0
- package/dist/plugin-kit.d.ts +17 -0
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.cjs +17 -16
- package/dist/smart-dom-reader.d.cts +507 -1
- package/dist/smart-dom-reader.d.ts +507 -1
- package/dist/smart-dom-reader.js +17 -16
- package/dist/theme-editor-preview.cjs +234 -55
- package/dist/theme-editor-preview.d.cts +473 -0
- package/dist/theme-editor-preview.d.ts +473 -0
- package/dist/theme-editor-preview.js +48 -42
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +473 -0
- package/dist/theme-editor.d.ts +473 -0
- package/dist/theme-editor.js +5 -5
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/widget.css +1 -1
- package/package.json +15 -3
- package/src/client.test.ts +69 -0
- package/src/client.ts +65 -51
- package/src/components/composer-parts.ts +3 -12
- package/src/components/context-mention-button.test.ts +70 -0
- package/src/components/context-mention-button.ts +82 -0
- package/src/components/context-mention-chip.ts +134 -0
- package/src/components/context-mention-menu.test.ts +508 -0
- package/src/components/context-mention-menu.ts +0 -0
- package/src/components/message-bubble.test.ts +175 -0
- package/src/components/message-bubble.ts +177 -19
- package/src/components/panel.ts +7 -10
- package/src/context-mentions-bundle.test.ts +163 -0
- package/src/context-mentions-entry.ts +185 -0
- package/src/context-mentions-inline-entry.test.ts +136 -0
- package/src/context-mentions-inline-entry.ts +226 -0
- package/src/context-mentions-inline-loader.test.ts +30 -0
- package/src/context-mentions-inline-loader.ts +36 -0
- package/src/context-mentions-inline.ts +15 -0
- package/src/context-mentions-loader.ts +32 -0
- package/src/context-mentions.ts +16 -0
- package/src/index-core.ts +26 -0
- package/src/index-global.ts +51 -0
- package/src/markdown-parsers-loader.ts +35 -44
- package/src/plugin-kit.test.ts +40 -0
- package/src/plugin-kit.ts +39 -5
- package/src/session.mentions.test.ts +175 -0
- package/src/session.test.ts +52 -4
- package/src/session.ts +121 -5
- package/src/smart-dom-reader.test.ts +129 -2
- package/src/smart-dom-reader.ts +127 -1
- package/src/styles/context-mention-menu-css.ts +176 -0
- package/src/styles/widget.css +229 -126
- package/src/theme-editor/sections.ts +3 -3
- package/src/types/theme.ts +2 -0
- package/src/types.ts +525 -0
- package/src/ui.mention-submit.test.ts +235 -0
- package/src/ui.ts +301 -51
- package/src/utils/chunk-loader.test.ts +97 -0
- package/src/utils/chunk-loader.ts +88 -0
- package/src/utils/composer-contenteditable.test.ts +507 -0
- package/src/utils/composer-contenteditable.ts +626 -0
- package/src/utils/composer-document.test.ts +280 -0
- package/src/utils/composer-document.ts +293 -0
- package/src/utils/composer-history.test.ts +35 -7
- package/src/utils/composer-history.ts +30 -20
- package/src/utils/composer-input.ts +159 -0
- package/src/utils/context-mention-controller.test.ts +1215 -0
- package/src/utils/context-mention-controller.ts +1186 -0
- package/src/utils/context-mention-manager.test.ts +422 -0
- package/src/utils/context-mention-manager.ts +410 -0
- package/src/utils/context-mention-orchestrator.test.ts +538 -0
- package/src/utils/context-mention-orchestrator.ts +348 -0
- package/src/utils/live-region.test.ts +108 -0
- package/src/utils/live-region.ts +94 -0
- package/src/utils/mention-channels.ts +63 -0
- package/src/utils/mention-llm-format.test.ts +91 -0
- package/src/utils/mention-llm-format.ts +79 -0
- package/src/utils/mention-matcher.test.ts +86 -0
- package/src/utils/mention-matcher.ts +221 -0
- package/src/utils/mention-token.ts +72 -0
- package/src/utils/mention-trigger.test.ts +155 -0
- package/src/utils/mention-trigger.ts +156 -0
- package/src/utils/theme.test.ts +28 -4
- package/src/utils/tokens.ts +27 -11
package/src/ui.ts
CHANGED
|
@@ -30,9 +30,16 @@ import {
|
|
|
30
30
|
PersonaArtifactRecord,
|
|
31
31
|
PersonaArtifactManualUpsert,
|
|
32
32
|
PersonaArtifactFileMeta,
|
|
33
|
-
PersonaArtifactActionContext
|
|
33
|
+
PersonaArtifactActionContext,
|
|
34
|
+
AgentWidgetContentSegment,
|
|
35
|
+
AgentWidgetContextMentionRef
|
|
34
36
|
} from "./types";
|
|
35
37
|
import { AttachmentManager } from "./utils/attachment-manager";
|
|
38
|
+
import {
|
|
39
|
+
createContextMentionOrchestrator,
|
|
40
|
+
type ContextMentionOrchestrator,
|
|
41
|
+
} from "./utils/context-mention-orchestrator";
|
|
42
|
+
import type { MentionSubmitBundle } from "./utils/context-mention-manager";
|
|
36
43
|
import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
|
|
37
44
|
import { applyThemeVariables, createThemeObserver, getActiveTheme } from "./utils/theme";
|
|
38
45
|
import { resolveTokenValue } from "./utils/tokens";
|
|
@@ -535,6 +542,35 @@ function buildDropOverlay(
|
|
|
535
542
|
return overlay;
|
|
536
543
|
}
|
|
537
544
|
|
|
545
|
+
// Deep-merge two mention context maps (`{ [sourceId]: { [itemId]: unknown } }`).
|
|
546
|
+
// A source contributing to both bundles keeps BOTH per-item maps; a shallow
|
|
547
|
+
// spread would let one sourceId's inner map wholesale replace the other's.
|
|
548
|
+
export const mergeMentionContext = (
|
|
549
|
+
a: MentionSubmitBundle["context"],
|
|
550
|
+
b: MentionSubmitBundle["context"]
|
|
551
|
+
): MentionSubmitBundle["context"] => {
|
|
552
|
+
const out: MentionSubmitBundle["context"] = { ...a };
|
|
553
|
+
for (const [sourceId, items] of Object.entries(b)) {
|
|
554
|
+
const existing = out[sourceId];
|
|
555
|
+
out[sourceId] = existing ? { ...existing, ...items } : items;
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// Concatenate the fulfilled halves of two finalize() results (either may be
|
|
561
|
+
// absent when its side rejected), deep-merging their per-source context.
|
|
562
|
+
export const mergeFinalizedMentions = (
|
|
563
|
+
bundles: MentionSubmitBundle[]
|
|
564
|
+
): MentionSubmitBundle =>
|
|
565
|
+
bundles.reduce<MentionSubmitBundle>(
|
|
566
|
+
(acc, b) => ({
|
|
567
|
+
blocks: [...acc.blocks, ...b.blocks],
|
|
568
|
+
contentParts: [...acc.contentParts, ...b.contentParts],
|
|
569
|
+
context: mergeMentionContext(acc.context, b.context),
|
|
570
|
+
}),
|
|
571
|
+
{ blocks: [], contentParts: [], context: {} }
|
|
572
|
+
);
|
|
573
|
+
|
|
538
574
|
export const createAgentExperience = (
|
|
539
575
|
mount: HTMLElement,
|
|
540
576
|
initialConfig?: AgentWidgetConfig,
|
|
@@ -840,9 +876,13 @@ export const createAgentExperience = (
|
|
|
840
876
|
header,
|
|
841
877
|
footer,
|
|
842
878
|
actionsRow: _actionsRow,
|
|
843
|
-
leftActions,
|
|
844
879
|
rightActions
|
|
845
880
|
} = panelElements;
|
|
881
|
+
// Nullable + reassignable: a plugin `replaceComposer` swaps the footer, and
|
|
882
|
+
// `bindComposerRefsFromFooter` must repoint this at the NEW left-action
|
|
883
|
+
// cluster (or null it so the composerForm fallback fires) — a stale ref would
|
|
884
|
+
// insert the mention/attachment buttons into the detached old subtree.
|
|
885
|
+
let leftActions: HTMLElement | null = panelElements.leftActions;
|
|
846
886
|
let setSendButtonMode = panelElements.setSendButtonMode;
|
|
847
887
|
|
|
848
888
|
// Use mutable references for mic button so we can update them dynamically
|
|
@@ -981,6 +1021,9 @@ export const createAgentExperience = (
|
|
|
981
1021
|
|
|
982
1022
|
// Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
|
|
983
1023
|
let attachmentManager: AttachmentManager | null = null;
|
|
1024
|
+
// Context mentions orchestrator (core, tiny); lazy-loads the heavy runtime on
|
|
1025
|
+
// first use. Null when `contextMentions` is disabled / has no sources.
|
|
1026
|
+
let mentionOrchestrator: ContextMentionOrchestrator | null = null;
|
|
984
1027
|
|
|
985
1028
|
/** Wired after `handleMicButtonClick` is defined; used by `renderComposer` `onVoiceToggle`. */
|
|
986
1029
|
let composerVoiceBridge: (() => void) | null = null;
|
|
@@ -1251,6 +1294,10 @@ export const createAgentExperience = (
|
|
|
1251
1294
|
".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
|
|
1252
1295
|
);
|
|
1253
1296
|
if (ar) _actionsRow = ar;
|
|
1297
|
+
// Rebind the left-action cluster to the NEW footer (both composer builders
|
|
1298
|
+
// ship this class). Assign unconditionally: when a custom plugin composer
|
|
1299
|
+
// has no left cluster, null lets the button-insert fall back to the form.
|
|
1300
|
+
leftActions = pick<HTMLElement>(".persona-widget-composer__left-actions");
|
|
1254
1301
|
};
|
|
1255
1302
|
ensureComposerAttachmentSurface(footer);
|
|
1256
1303
|
bindComposerRefsFromFooter(footer);
|
|
@@ -1303,6 +1350,49 @@ export const createAgentExperience = (
|
|
|
1303
1350
|
container.appendChild(overlay);
|
|
1304
1351
|
}
|
|
1305
1352
|
|
|
1353
|
+
// Context mentions: render the affordance button + chip row eagerly (so the
|
|
1354
|
+
// feature is discoverable) and lazy-load the heavy runtime on first use. The
|
|
1355
|
+
// orchestrator returns null when disabled or sourceless.
|
|
1356
|
+
const mentionPrefetch = () => mentionOrchestrator?.prefetch();
|
|
1357
|
+
if (config.contextMentions?.enabled && textarea) {
|
|
1358
|
+
// Slash-command dispatch (prompt macros write text / submit; client actions
|
|
1359
|
+
// read/replace the value) and submission are owned by the composer input
|
|
1360
|
+
// surface itself — the mention runtime builds a textarea (chip) or
|
|
1361
|
+
// contenteditable (inline) adapter from this textarea. Broader host actions
|
|
1362
|
+
// (clear transcript, theme) are still wired via closures over the controller.
|
|
1363
|
+
mentionOrchestrator = createContextMentionOrchestrator({
|
|
1364
|
+
config,
|
|
1365
|
+
textarea,
|
|
1366
|
+
anchor: composerForm ?? textarea,
|
|
1367
|
+
getMessages: () => session.getMessages(),
|
|
1368
|
+
// The engine chunk creates the mention live regions in this container on
|
|
1369
|
+
// mount, keeping the live-region helper out of the core bundle.
|
|
1370
|
+
liveRegionHost: container,
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
if (mentionOrchestrator) {
|
|
1374
|
+
// Chip row sits directly above the textarea.
|
|
1375
|
+
const ta = textarea;
|
|
1376
|
+
ta.parentElement?.insertBefore(mentionOrchestrator.contextRow, ta);
|
|
1377
|
+
// Each channel's "add context" affordance is a secondary control that
|
|
1378
|
+
// augments the outgoing message, so it joins the LEFT action cluster
|
|
1379
|
+
// (beside the attachment button) — never the right cluster with mic +
|
|
1380
|
+
// send. Buttons are inserted leftmost in channel order so they read as
|
|
1381
|
+
// "add to my message" and stay clear of the primary send action. Both
|
|
1382
|
+
// composer builders (full + pill) always ship `leftActions`; the form
|
|
1383
|
+
// fallback is purely defensive.
|
|
1384
|
+
const buttons = mentionOrchestrator.affordanceButtons;
|
|
1385
|
+
for (let i = buttons.length - 1; i >= 0; i--) {
|
|
1386
|
+
const btn = buttons[i];
|
|
1387
|
+
if (leftActions) leftActions.insertBefore(btn, leftActions.firstChild);
|
|
1388
|
+
else composerForm?.appendChild(btn);
|
|
1389
|
+
}
|
|
1390
|
+
// The focus-prefetch listener (warm the chunk on first focus so the first
|
|
1391
|
+
// `@` is instant) is registered through the shared `composerListeners`
|
|
1392
|
+
// registry below, so it survives the inline contenteditable swap too.
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1306
1396
|
// Slot system: allow custom content injection into specific regions
|
|
1307
1397
|
const renderSlots = () => {
|
|
1308
1398
|
const slots = config.layout?.slots ?? {};
|
|
@@ -6174,26 +6264,93 @@ export const createAgentExperience = (
|
|
|
6174
6264
|
setOpenState(true, "auto");
|
|
6175
6265
|
};
|
|
6176
6266
|
|
|
6177
|
-
|
|
6178
|
-
|
|
6267
|
+
// Combine `@`-chip mentions with an inline server-command's context bundle so
|
|
6268
|
+
// both reach the message in one `mentions` payload. Either side may be null.
|
|
6269
|
+
type SubmitMentions = NonNullable<
|
|
6270
|
+
ReturnType<NonNullable<typeof mentionOrchestrator>["collectForSubmit"]>
|
|
6271
|
+
>;
|
|
6272
|
+
const mergeSubmitMentions = (
|
|
6273
|
+
a: SubmitMentions | null,
|
|
6274
|
+
b: SubmitMentions | null
|
|
6275
|
+
): SubmitMentions | null => {
|
|
6276
|
+
if (!a) return b;
|
|
6277
|
+
if (!b) return a;
|
|
6278
|
+
return {
|
|
6279
|
+
refs: [...a.refs, ...b.refs],
|
|
6280
|
+
// allSettled, not all: one side rejecting must not discard the other
|
|
6281
|
+
// side's already-resolved context (the bubble already echoed its chips).
|
|
6282
|
+
finalize: async () => {
|
|
6283
|
+
const [ra, rb] = await Promise.allSettled([a.finalize(), b.finalize()]);
|
|
6284
|
+
const fulfilled: MentionSubmitBundle[] = [];
|
|
6285
|
+
if (ra.status === "fulfilled") fulfilled.push(ra.value);
|
|
6286
|
+
else console.warn("[Persona] a mention bundle failed to finalize; sending without it", ra.reason);
|
|
6287
|
+
if (rb.status === "fulfilled") fulfilled.push(rb.value);
|
|
6288
|
+
else console.warn("[Persona] a mention bundle failed to finalize; sending without it", rb.reason);
|
|
6289
|
+
return mergeFinalizedMentions(fulfilled);
|
|
6290
|
+
},
|
|
6291
|
+
};
|
|
6292
|
+
};
|
|
6179
6293
|
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6294
|
+
// Inline-mode composer element: after the contenteditable swap, `textarea`
|
|
6295
|
+
// additionally exposes `getInlineMessageFields()` (built in the inline chunk,
|
|
6296
|
+
// which owns the document model — the core never imports it). Structural shape
|
|
6297
|
+
// only, so no runtime import crosses the bundle boundary.
|
|
6298
|
+
type InlineComposerFieldsHost = {
|
|
6299
|
+
getInlineMessageFields?: () => {
|
|
6300
|
+
content: string;
|
|
6301
|
+
contextMentions: AgentWidgetContextMentionRef[];
|
|
6302
|
+
contentSegments: AgentWidgetContentSegment[];
|
|
6303
|
+
};
|
|
6304
|
+
};
|
|
6305
|
+
// Read the ordered display segments from the live inline composer, but only
|
|
6306
|
+
// when at least one mention is present: a plain-text inline message must keep
|
|
6307
|
+
// the normal markdown-rendered bubble (segments bypass that path), so we return
|
|
6308
|
+
// undefined for pure prose and let the bubble render `content` as usual.
|
|
6309
|
+
const readInlineContentSegments = (): AgentWidgetContentSegment[] | undefined => {
|
|
6310
|
+
const host = textarea as unknown as InlineComposerFieldsHost;
|
|
6311
|
+
const fields = host.getInlineMessageFields?.();
|
|
6312
|
+
if (!fields) return undefined;
|
|
6313
|
+
const hasMention = fields.contentSegments.some((s) => s.kind === "mention");
|
|
6314
|
+
return hasMention ? fields.contentSegments : undefined;
|
|
6315
|
+
};
|
|
6191
6316
|
|
|
6317
|
+
// Re-entrancy guard for the async submit path: `takeInlineCommand` awaits (a
|
|
6318
|
+
// lazy chunk load + host resolve) before the composer clears, and
|
|
6319
|
+
// `isStreaming()` is still false in that window, so a second Enter would
|
|
6320
|
+
// otherwise dispatch the same text/command twice.
|
|
6321
|
+
let submitInFlight = false;
|
|
6322
|
+
|
|
6323
|
+
const performSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
|
|
6192
6324
|
const value = textarea.value.trim();
|
|
6193
6325
|
const hasAttachments = attachmentManager?.hasAttachments() ?? false;
|
|
6194
6326
|
|
|
6195
|
-
//
|
|
6196
|
-
|
|
6327
|
+
// Inline slash command (Slack-style): every `command:"server"` plus any
|
|
6328
|
+
// arg-bearing prompt/action. Resolve FIRST — a prompt command changes the
|
|
6329
|
+
// text to send, and an action sends nothing at all.
|
|
6330
|
+
const inline = value
|
|
6331
|
+
? await (mentionOrchestrator?.takeInlineCommand(value) ?? Promise.resolve(null))
|
|
6332
|
+
: null;
|
|
6333
|
+
|
|
6334
|
+
if (inline?.kind === "action") {
|
|
6335
|
+
// Ran in the browser; nothing to send. Clear the composer + any chips.
|
|
6336
|
+
textarea.value = "";
|
|
6337
|
+
textarea.style.height = "auto";
|
|
6338
|
+
resetHistoryNavigation();
|
|
6339
|
+
mentionOrchestrator?.clear();
|
|
6340
|
+
return;
|
|
6341
|
+
}
|
|
6342
|
+
|
|
6343
|
+
// Gather `@`-chip mentions synchronously (detaches chips + captures composer
|
|
6344
|
+
// text before clearing); `finalize()` resolves them inside `sendMessage`.
|
|
6345
|
+
const chipMentions = mentionOrchestrator?.collectForSubmit() ?? null;
|
|
6346
|
+
const serverMentions = inline?.kind === "server" ? inline.mentions : null;
|
|
6347
|
+
const mentions = mergeSubmitMentions(chipMentions, serverMentions);
|
|
6348
|
+
// A prompt command replaces the outgoing text with its resolved macro.
|
|
6349
|
+
const sendText = inline?.kind === "prompt" ? inline.sendText : value;
|
|
6350
|
+
|
|
6351
|
+
const hasChips = !!chipMentions && chipMentions.refs.length > 0;
|
|
6352
|
+
// Must have text, attachments, chips, or an inline server command's context.
|
|
6353
|
+
if (!sendText && !hasAttachments && !hasChips && !serverMentions) return;
|
|
6197
6354
|
|
|
6198
6355
|
maybeExpandComposerBar();
|
|
6199
6356
|
|
|
@@ -6204,22 +6361,65 @@ export const createAgentExperience = (
|
|
|
6204
6361
|
// Add image parts first
|
|
6205
6362
|
contentParts.push(...attachmentManager!.getContentParts());
|
|
6206
6363
|
// Add text part if there's text
|
|
6207
|
-
if (
|
|
6208
|
-
contentParts.push(createTextPart(
|
|
6364
|
+
if (sendText) {
|
|
6365
|
+
contentParts.push(createTextPart(sendText));
|
|
6209
6366
|
}
|
|
6210
6367
|
}
|
|
6211
6368
|
|
|
6369
|
+
// Capture the inline display segments BEFORE clearing the composer (clearing
|
|
6370
|
+
// rebuilds the document as empty text). Only set when a `prompt` macro didn't
|
|
6371
|
+
// replace the outgoing text — a macro's segments no longer match `sendText`.
|
|
6372
|
+
const contentSegments =
|
|
6373
|
+
inline?.kind === "prompt" ? undefined : readInlineContentSegments();
|
|
6374
|
+
|
|
6212
6375
|
textarea.value = "";
|
|
6213
6376
|
textarea.style.height = "auto"; // Reset height after clearing
|
|
6214
6377
|
resetHistoryNavigation();
|
|
6215
6378
|
|
|
6216
|
-
// Send message with optional content parts
|
|
6217
|
-
session.sendMessage(
|
|
6379
|
+
// Send message with optional content parts + mentions
|
|
6380
|
+
session.sendMessage(sendText, {
|
|
6381
|
+
contentParts,
|
|
6382
|
+
mentions: mentions ?? undefined,
|
|
6383
|
+
contentSegments,
|
|
6384
|
+
viaVoice: submitOptions?.viaVoice,
|
|
6385
|
+
});
|
|
6218
6386
|
|
|
6219
|
-
// Clear attachments after sending
|
|
6387
|
+
// Clear attachments + mention chips after sending
|
|
6220
6388
|
if (hasAttachments) {
|
|
6221
6389
|
attachmentManager!.clearAttachments();
|
|
6222
6390
|
}
|
|
6391
|
+
if (chipMentions) {
|
|
6392
|
+
mentionOrchestrator?.clear();
|
|
6393
|
+
}
|
|
6394
|
+
};
|
|
6395
|
+
|
|
6396
|
+
const doSubmit = async (submitOptions?: { viaVoice?: boolean }) => {
|
|
6397
|
+
if (submitInFlight) return;
|
|
6398
|
+
submitInFlight = true;
|
|
6399
|
+
try {
|
|
6400
|
+
await performSubmit(submitOptions);
|
|
6401
|
+
} finally {
|
|
6402
|
+
submitInFlight = false;
|
|
6403
|
+
}
|
|
6404
|
+
};
|
|
6405
|
+
|
|
6406
|
+
const handleSubmit = (event: Event) => {
|
|
6407
|
+
event.preventDefault();
|
|
6408
|
+
|
|
6409
|
+
// While a response is streaming, the submit button acts as a stop button.
|
|
6410
|
+
// Abort the in-flight stream and leave textarea contents / attachments
|
|
6411
|
+
// intact so the user can edit and resend without retyping.
|
|
6412
|
+
if (session.isStreaming()) {
|
|
6413
|
+
session.cancel();
|
|
6414
|
+
// Cancelling emits no terminal/error SSE frame, so reset the throughput
|
|
6415
|
+
// tracker (as clear-chat does) to avoid a stale `running` row lingering.
|
|
6416
|
+
throughputTracker?.reset();
|
|
6417
|
+
eventStreamView?.update();
|
|
6418
|
+
return;
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6421
|
+
if (submitInFlight) return;
|
|
6422
|
+
void doSubmit();
|
|
6223
6423
|
};
|
|
6224
6424
|
|
|
6225
6425
|
// --- Composer message-history navigation (Up/Down arrows) ---
|
|
@@ -6258,15 +6458,30 @@ export const createAgentExperience = (
|
|
|
6258
6458
|
textarea.setSelectionRange(end, end);
|
|
6259
6459
|
};
|
|
6260
6460
|
|
|
6261
|
-
const handleComposerInput = () => {
|
|
6262
|
-
//
|
|
6461
|
+
const handleComposerInput = (event: Event) => {
|
|
6462
|
+
// The synthetic input from history recall (guarded by `suppressHistoryReset`)
|
|
6463
|
+
// is not user typing: it must neither open the mention menu (an undefined
|
|
6464
|
+
// inputType reads as menu-opening) nor exit history-navigation mode.
|
|
6263
6465
|
if (suppressHistoryReset) return;
|
|
6466
|
+
// Drive the mention menu (open/update/close) + lazy-load on first trigger.
|
|
6467
|
+
// Skip while an IME composition is active: the intermediate value isn't the
|
|
6468
|
+
// user's committed text, so it must not open or filter the menu.
|
|
6469
|
+
if (!(event as InputEvent).isComposing) {
|
|
6470
|
+
mentionOrchestrator?.handleInput((event as InputEvent).inputType ?? undefined);
|
|
6471
|
+
}
|
|
6472
|
+
// A real edit leaves history-navigation mode.
|
|
6264
6473
|
resetHistoryNavigation();
|
|
6265
6474
|
};
|
|
6266
6475
|
|
|
6267
6476
|
const handleComposerKeydown = (event: KeyboardEvent) => {
|
|
6268
6477
|
if (!textarea) return;
|
|
6269
6478
|
|
|
6479
|
+
// Mention menu takes precedence when open (↑/↓ nav, Enter/Tab select, Esc
|
|
6480
|
+
// close) and handles Backspace-removes-last-chip on an empty composer. One
|
|
6481
|
+
// handler, no competing capture-phase listener. Skip during IME composition
|
|
6482
|
+
// so Enter-to-confirm-composition isn't swallowed as a menu selection.
|
|
6483
|
+
if (!event.isComposing && mentionOrchestrator?.handleKeydown(event)) return;
|
|
6484
|
+
|
|
6270
6485
|
// Up/Down: walk through previously sent user messages.
|
|
6271
6486
|
if (
|
|
6272
6487
|
historyNavigationEnabled() &&
|
|
@@ -6304,6 +6519,12 @@ export const createAgentExperience = (
|
|
|
6304
6519
|
event.preventDefault();
|
|
6305
6520
|
return;
|
|
6306
6521
|
}
|
|
6522
|
+
// A submit is already awaiting its async pre-send work; a second Enter in
|
|
6523
|
+
// that window would dispatch the same text twice.
|
|
6524
|
+
if (submitInFlight) {
|
|
6525
|
+
event.preventDefault();
|
|
6526
|
+
return;
|
|
6527
|
+
}
|
|
6307
6528
|
resetHistoryNavigation();
|
|
6308
6529
|
event.preventDefault();
|
|
6309
6530
|
sendButton.click();
|
|
@@ -6409,9 +6630,11 @@ export const createAgentExperience = (
|
|
|
6409
6630
|
const finalValue = textarea.value.trim();
|
|
6410
6631
|
if (finalValue && speechRecognition && isRecording) {
|
|
6411
6632
|
stopVoiceRecognition();
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6633
|
+
// Route through the normal submit path so mentions are collected +
|
|
6634
|
+
// cleared like a manual send (the transcript already lives in the
|
|
6635
|
+
// composer). Sending directly would leave stale tracked mention
|
|
6636
|
+
// context to attach to the next unrelated message.
|
|
6637
|
+
void doSubmit({ viaVoice: true });
|
|
6415
6638
|
}
|
|
6416
6639
|
}, pauseDuration);
|
|
6417
6640
|
}
|
|
@@ -6429,9 +6652,9 @@ export const createAgentExperience = (
|
|
|
6429
6652
|
if (isRecording) {
|
|
6430
6653
|
const finalValue = textarea.value.trim();
|
|
6431
6654
|
if (finalValue && finalValue !== initialText.trim()) {
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6655
|
+
// Route through the normal submit path (mentions collect + clear), same
|
|
6656
|
+
// as the pause-timer branch above.
|
|
6657
|
+
void doSubmit({ viaVoice: true });
|
|
6435
6658
|
}
|
|
6436
6659
|
stopVoiceRecognition();
|
|
6437
6660
|
}
|
|
@@ -7313,9 +7536,43 @@ export const createAgentExperience = (
|
|
|
7313
7536
|
if (composerForm) {
|
|
7314
7537
|
composerForm.addEventListener("submit", handleSubmit);
|
|
7315
7538
|
}
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7539
|
+
|
|
7540
|
+
// Single registry of composer listeners. The initial attach and the inline
|
|
7541
|
+
// contenteditable swap-reattach both consume this array, so a listener added
|
|
7542
|
+
// here is mechanically included in both — no hand-maintained enumeration to
|
|
7543
|
+
// drift. Add new composer listeners here, not as ad-hoc addEventListener calls.
|
|
7544
|
+
type ComposerListener = [event: string, handler: (event: Event) => void];
|
|
7545
|
+
const composerListeners: ComposerListener[] = [
|
|
7546
|
+
["keydown", handleComposerKeydown as unknown as (event: Event) => void],
|
|
7547
|
+
["input", handleComposerInput as (event: Event) => void],
|
|
7548
|
+
["paste", handleInputPaste as unknown as (event: Event) => void],
|
|
7549
|
+
// Warm the mention chunk on first focus so the first `@` is instant.
|
|
7550
|
+
["focus", mentionPrefetch as (event: Event) => void],
|
|
7551
|
+
];
|
|
7552
|
+
const attachComposerListeners = (el: HTMLElement | null): void => {
|
|
7553
|
+
if (!el) return;
|
|
7554
|
+
for (const [event, handler] of composerListeners) {
|
|
7555
|
+
el.addEventListener(event, handler);
|
|
7556
|
+
}
|
|
7557
|
+
};
|
|
7558
|
+
const detachComposerListeners = (el: HTMLElement | null): void => {
|
|
7559
|
+
if (!el) return;
|
|
7560
|
+
for (const [event, handler] of composerListeners) {
|
|
7561
|
+
el.removeEventListener(event, handler);
|
|
7562
|
+
}
|
|
7563
|
+
};
|
|
7564
|
+
|
|
7565
|
+
attachComposerListeners(textarea);
|
|
7566
|
+
|
|
7567
|
+
// Inline mention mode swaps the textarea for a contenteditable surface (loaded
|
|
7568
|
+
// lazily). When that happens, move the composer listeners onto the new element
|
|
7569
|
+
// and repoint `textarea` (the swapped element shims the textarea API the rest of
|
|
7570
|
+
// the composer code relies on). Fires immediately if the swap already occurred.
|
|
7571
|
+
mentionOrchestrator?.onComposerSwap((next, prev) => {
|
|
7572
|
+
detachComposerListeners(prev);
|
|
7573
|
+
textarea = next as unknown as HTMLTextAreaElement;
|
|
7574
|
+
attachComposerListeners(next);
|
|
7575
|
+
});
|
|
7319
7576
|
|
|
7320
7577
|
const escStopDoc = mount.ownerDocument ?? document;
|
|
7321
7578
|
escStopDoc.addEventListener("keydown", handleEscStop, true);
|
|
@@ -7396,10 +7653,9 @@ export const createAgentExperience = (
|
|
|
7396
7653
|
if (composerForm) {
|
|
7397
7654
|
composerForm.removeEventListener("submit", handleSubmit);
|
|
7398
7655
|
}
|
|
7399
|
-
textarea
|
|
7400
|
-
textarea?.removeEventListener("input", handleComposerInput);
|
|
7401
|
-
textarea?.removeEventListener("paste", handleInputPaste);
|
|
7656
|
+
detachComposerListeners(textarea);
|
|
7402
7657
|
escStopDoc.removeEventListener("keydown", handleEscStop, true);
|
|
7658
|
+
mentionOrchestrator?.destroy();
|
|
7403
7659
|
});
|
|
7404
7660
|
|
|
7405
7661
|
destroyCallbacks.push(() => {
|
|
@@ -8441,19 +8697,11 @@ export const createAgentExperience = (
|
|
|
8441
8697
|
attachmentButton.style.minHeight = attachIconSize;
|
|
8442
8698
|
attachmentButton.style.fontSize = "18px";
|
|
8443
8699
|
attachmentButton.style.lineHeight = "1";
|
|
8444
|
-
|
|
8445
|
-
|
|
8446
|
-
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
|
|
8450
|
-
// Add hover effect via mouseenter/mouseleave
|
|
8451
|
-
attachmentButton.addEventListener("mouseenter", () => {
|
|
8452
|
-
attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
|
|
8453
|
-
});
|
|
8454
|
-
attachmentButton.addEventListener("mouseleave", () => {
|
|
8455
|
-
attachmentButton!.style.backgroundColor = "transparent";
|
|
8456
|
-
});
|
|
8700
|
+
// Appearance (bg / fg / border / radius / hover) is themed from the
|
|
8701
|
+
// shared `.persona-attachment-button` CSS rule via the
|
|
8702
|
+
// `--persona-button-ghost-*` tokens — matching the static
|
|
8703
|
+
// createAttachmentControls path. Only sizing stays inline here so
|
|
8704
|
+
// this runtime-created button restyles identically to the built-in one.
|
|
8457
8705
|
|
|
8458
8706
|
const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
|
|
8459
8707
|
if (attachIconSvg) {
|
|
@@ -8475,8 +8723,10 @@ export const createAgentExperience = (
|
|
|
8475
8723
|
tooltip.textContent = attachTooltipText;
|
|
8476
8724
|
attachmentButtonWrapper.appendChild(tooltip);
|
|
8477
8725
|
|
|
8478
|
-
// Insert into left actions container
|
|
8479
|
-
|
|
8726
|
+
// Insert into left actions container (fall back to the form when a
|
|
8727
|
+
// custom composer has no left cluster).
|
|
8728
|
+
if (leftActions) leftActions.append(attachmentButtonWrapper);
|
|
8729
|
+
else composerForm.appendChild(attachmentButtonWrapper);
|
|
8480
8730
|
|
|
8481
8731
|
// Initialize attachment manager
|
|
8482
8732
|
if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createChunkLoader } from "./chunk-loader";
|
|
3
|
+
|
|
4
|
+
type FakeModule = { value: number };
|
|
5
|
+
|
|
6
|
+
describe("createChunkLoader", () => {
|
|
7
|
+
it("uses the fallback import when no loader is registered", async () => {
|
|
8
|
+
const mod: FakeModule = { value: 1 };
|
|
9
|
+
const fallbackImport = vi.fn(async () => mod);
|
|
10
|
+
const { load } = createChunkLoader<FakeModule>({ fallbackImport });
|
|
11
|
+
|
|
12
|
+
await expect(load()).resolves.toBe(mod);
|
|
13
|
+
expect(fallbackImport).toHaveBeenCalledTimes(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("memoizes the resolved module and shares the in-flight promise", async () => {
|
|
17
|
+
const mod: FakeModule = { value: 2 };
|
|
18
|
+
const loader = vi.fn(async () => mod);
|
|
19
|
+
const { setLoader, load } = createChunkLoader<FakeModule>({
|
|
20
|
+
fallbackImport: async () => ({ value: -1 }),
|
|
21
|
+
});
|
|
22
|
+
setLoader(loader);
|
|
23
|
+
|
|
24
|
+
// Concurrent callers share one import.
|
|
25
|
+
const [a, b] = await Promise.all([load(), load()]);
|
|
26
|
+
expect(a).toBe(mod);
|
|
27
|
+
expect(b).toBe(mod);
|
|
28
|
+
// A later call returns the cached module without importing again.
|
|
29
|
+
await expect(load()).resolves.toBe(mod);
|
|
30
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("retries after a rejected load instead of caching the failure", async () => {
|
|
34
|
+
const mod: FakeModule = { value: 3 };
|
|
35
|
+
const loader = vi
|
|
36
|
+
.fn<() => Promise<FakeModule>>()
|
|
37
|
+
.mockRejectedValueOnce(new Error("network"))
|
|
38
|
+
.mockResolvedValueOnce(mod);
|
|
39
|
+
const { setLoader, load } = createChunkLoader<FakeModule>({
|
|
40
|
+
fallbackImport: async () => ({ value: -1 }),
|
|
41
|
+
});
|
|
42
|
+
setLoader(loader);
|
|
43
|
+
|
|
44
|
+
await expect(load()).rejects.toThrow("network");
|
|
45
|
+
// The failed promise was cleared, so a later call retries and resolves.
|
|
46
|
+
await expect(load()).resolves.toBe(mod);
|
|
47
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
48
|
+
// Once resolved, the module is cached (no third loader call).
|
|
49
|
+
await expect(load()).resolves.toBe(mod);
|
|
50
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not invalidate the cache on setLoader by default", async () => {
|
|
54
|
+
const first: FakeModule = { value: 4 };
|
|
55
|
+
const second: FakeModule = { value: 5 };
|
|
56
|
+
const { setLoader, load } = createChunkLoader<FakeModule>({
|
|
57
|
+
fallbackImport: async () => ({ value: -1 }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
setLoader(async () => first);
|
|
61
|
+
await expect(load()).resolves.toBe(first);
|
|
62
|
+
// Registering a new loader after a resolved load has no effect: cache holds.
|
|
63
|
+
setLoader(async () => second);
|
|
64
|
+
await expect(load()).resolves.toBe(first);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("invalidates the cache on setLoader when resetOnSetLoader is set", async () => {
|
|
68
|
+
const first: FakeModule = { value: 6 };
|
|
69
|
+
const second: FakeModule = { value: 7 };
|
|
70
|
+
const { setLoader, load } = createChunkLoader<FakeModule>({
|
|
71
|
+
fallbackImport: async () => ({ value: -1 }),
|
|
72
|
+
resetOnSetLoader: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
setLoader(async () => first);
|
|
76
|
+
await expect(load()).resolves.toBe(first);
|
|
77
|
+
// A swapped loader takes effect: the previous module is discarded.
|
|
78
|
+
setLoader(async () => second);
|
|
79
|
+
await expect(load()).resolves.toBe(second);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("provide() seeds the cache and getSync() reads it", async () => {
|
|
83
|
+
const mod: FakeModule = { value: 8 };
|
|
84
|
+
const loader = vi.fn(async () => ({ value: -1 }));
|
|
85
|
+
const { setLoader, load, provide, getSync } = createChunkLoader<FakeModule>({
|
|
86
|
+
fallbackImport: async () => ({ value: -1 }),
|
|
87
|
+
});
|
|
88
|
+
setLoader(loader);
|
|
89
|
+
|
|
90
|
+
expect(getSync()).toBeNull();
|
|
91
|
+
provide(mod);
|
|
92
|
+
expect(getSync()).toBe(mod);
|
|
93
|
+
// A provided module short-circuits load(): the loader is never called.
|
|
94
|
+
await expect(load()).resolves.toBe(mod);
|
|
95
|
+
expect(loader).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic memoized loader for a lazy code-split chunk.
|
|
3
|
+
*
|
|
4
|
+
* Backs the near-identical loader indirections in the core/eager bundle
|
|
5
|
+
* (`context-mentions-loader.ts`, `context-mentions-inline-loader.ts`,
|
|
6
|
+
* `markdown-parsers-loader.ts`): each registers a runtime loader (IIFE/CDN
|
|
7
|
+
* sibling-URL chunk) and falls back to an external/relative dynamic import for
|
|
8
|
+
* ESM/CJS consumers. The shared semantics are:
|
|
9
|
+
*
|
|
10
|
+
* - `load()` memoizes the resolved module (`moduleCache`) and the in-flight
|
|
11
|
+
* promise (`loadPromise`) so concurrent callers share one import.
|
|
12
|
+
* - A rejected load clears `loadPromise` so a later call retries after a
|
|
13
|
+
* transient failure (one dropped fetch must not disable the feature for the
|
|
14
|
+
* whole session); the current caller still sees the rejection.
|
|
15
|
+
* - The registered loader wins over `fallbackImport`; if no loader is
|
|
16
|
+
* registered, `fallbackImport` is used.
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: this module must import nothing beyond types. It sits in the core
|
|
19
|
+
* bundle, so any value import here could drag chunk code back into core and
|
|
20
|
+
* defeat the split (see `context-mentions-bundle.test.ts`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type ChunkLoaderOptions<T> = {
|
|
24
|
+
/**
|
|
25
|
+
* Dynamic import used when no runtime loader has been registered. Keep the
|
|
26
|
+
* `import(...)` literal in the calling module (not here) so the bundler
|
|
27
|
+
* resolves it as an external subpath / relative chunk at that call site.
|
|
28
|
+
*/
|
|
29
|
+
fallbackImport: () => Promise<T>;
|
|
30
|
+
/**
|
|
31
|
+
* When true, registering a new loader clears the cached module and any
|
|
32
|
+
* in-flight promise, so a swapped loader (test injection) takes effect
|
|
33
|
+
* deterministically. Production registers its loader once before any load, so
|
|
34
|
+
* this only matters mid-session.
|
|
35
|
+
*/
|
|
36
|
+
resetOnSetLoader?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ChunkLoader<T> = {
|
|
40
|
+
setLoader: (l: () => Promise<T>) => void;
|
|
41
|
+
load: () => Promise<T>;
|
|
42
|
+
/** Register a resolved module synchronously (eager builds that bundle it). */
|
|
43
|
+
provide: (mod: T) => void;
|
|
44
|
+
/** The cached module, or null if not loaded/provided yet. */
|
|
45
|
+
getSync: () => T | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const createChunkLoader = <T>(
|
|
49
|
+
options: ChunkLoaderOptions<T>
|
|
50
|
+
): ChunkLoader<T> => {
|
|
51
|
+
const { fallbackImport, resetOnSetLoader = false } = options;
|
|
52
|
+
|
|
53
|
+
let loader: (() => Promise<T>) | null = null;
|
|
54
|
+
let moduleCache: T | null = null;
|
|
55
|
+
let loadPromise: Promise<T> | null = null;
|
|
56
|
+
|
|
57
|
+
const setLoader = (l: () => Promise<T>): void => {
|
|
58
|
+
loader = l;
|
|
59
|
+
if (resetOnSetLoader) {
|
|
60
|
+
moduleCache = null;
|
|
61
|
+
loadPromise = null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const load = (): Promise<T> => {
|
|
66
|
+
if (moduleCache) return Promise.resolve(moduleCache);
|
|
67
|
+
if (loadPromise) return loadPromise;
|
|
68
|
+
const importChunk = loader ?? fallbackImport;
|
|
69
|
+
loadPromise = importChunk()
|
|
70
|
+
.then((mod) => {
|
|
71
|
+
moduleCache = mod;
|
|
72
|
+
return mod;
|
|
73
|
+
})
|
|
74
|
+
.catch((err) => {
|
|
75
|
+
loadPromise = null;
|
|
76
|
+
throw err;
|
|
77
|
+
});
|
|
78
|
+
return loadPromise;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const provide = (mod: T): void => {
|
|
82
|
+
moduleCache = mod;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const getSync = (): T | null => moduleCache;
|
|
86
|
+
|
|
87
|
+
return { setLoader, load, provide, getSync };
|
|
88
|
+
};
|