blob: 03ff0fc75d652b32b8d0d6f39ef3513ef4bbd2ec [file]
/** Either a React synthetic event or the native one it wraps. */
export interface ChatInputCompositionEvent {
key?: string;
nativeEvent?: object;
}
export function isChatInputComposing(
event: ChatInputCompositionEvent,
trackedComposition = false,
): boolean {
const native: object = event.nativeEvent ?? event;
return trackedComposition || event.key === 'Process'
|| ('isComposing' in native && native.isComposing === true);
}
/**
* The draft text as the backend should receive it.
*
* `ChatComposerInput` anchors an inline token to what follows it with a
* NO-BREAK SPACE, and its own backspace-eats-the-token check keys on that exact
* codepoint — so the editor has to keep it and only the wire is normalized.
* Without this, every message written after a mention carries a U+00A0 where
* the user typed an ordinary space: invisible in the transcript, and a
* character no tool splitting a path on ASCII whitespace will match.
*/
export function composerWireText(draft: string): string {
return draft.replace(/ /g, ' ').trim();
}
/**
* Wraps an async lookup into the shape Astryx's trigger menu expects, adding
* the two things `useTriggerMenu` assumes a `SearchSource` provides.
*
* `searchItems` probes the source with `search('')` once per keystroke purely
* to learn whether it is async — it reads nothing but `instanceof Promise` and
* discards the value — and it calls `cancel()` immediately before, and only
* before, each real search. So the probe is answered from a resolved constant
* and never reaches the network, every real search runs fresh, and a search
* superseded by a newer one is abandoned rather than allowed to race its stale
* results into an open menu.
*/
export function createTriggerSearchSource<Item>(
run: (query: string) => Promise<Item[]>,
): { bootstrap(): Promise<Item[]>; search(query: string): Promise<Item[]>; cancel(): void } {
const probeAnswer = Promise.resolve<Item[]>([]);
/** Never settles, so a superseded search can never write to the menu. */
const abandoned = (): Promise<Item[]> => new Promise<Item[]>(() => {});
let generation = 0;
let armed = false;
return {
bootstrap: () => run('').catch(() => []),
search(query) {
if (!armed) return probeAnswer;
armed = false;
const ownGeneration = generation;
const fresh = (items: Item[]) => (ownGeneration === generation ? items : abandoned());
return run(query).then(fresh, () => fresh([]));
},
cancel() {
generation += 1;
armed = true;
},
};
}
export function fileTransferContainsFiles(types: Iterable<string>, fileCount: number): boolean {
return fileCount > 0 || Array.from(types).includes('Files');
}
/**
* The composer's text surface, seen by the hooks that own draft persistence and
* prompt-history recall.
*
* Those hooks used to hold an `HTMLTextAreaElement` ref and poke `.value` /
* `.setSelectionRange()` directly. The input is now Astryx's contentEditable
* `ChatComposerInput`, whose value is React state and whose caret is owned by
* the component — so they talk to this two-method port instead and stay free
* of any DOM shape.
*/
export interface ComposerTextPort {
/** The serialized draft text, tokens included. */
getValue(): string;
/** Replace the draft text without moving focus. */
setValue(value: string): void;
}
/**
* Case-insensitive AND-of-substring matcher shared by the file re-filter and
* the skill filter: every whitespace-separated token in `query` must appear
* somewhere in `text`. An empty query matches everything (shows the full list).
*/
export function mentionQueryMatches(query: string, text: string): boolean {
const haystack = text.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
}
/** Normalize `/skill:<query>` and bare `/<query>` into the same Skill search query. */
export function skillMentionQuery(query: string): string {
return query.toLowerCase().startsWith('skill:') ? query.slice('skill:'.length) : query;
}
export interface ChatInputActionOwner<ActionId> {
readonly pending: ActionId | null;
run<Result>(actionId: ActionId, action: () => Promise<Result>): Promise<Result | undefined>;
reset(): void;
}
export function createChatInputActionOwner<ActionId>(
onPendingChange: (action: ActionId | null) => void,
): ChatInputActionOwner<ActionId> {
let pending: ActionId | null = null;
let generation = 0;
return {
get pending() {
return pending;
},
async run<Result>(actionId: ActionId, action: () => Promise<Result>): Promise<Result | undefined> {
if (pending !== null) return undefined;
const ownedGeneration = ++generation;
pending = actionId;
onPendingChange(actionId);
try {
return await action();
} finally {
if (generation === ownedGeneration && pending === actionId) {
pending = null;
onPendingChange(null);
}
}
},
reset() {
generation += 1;
pending = null;
},
};
}