Files
kandev/apps/web/components/task/chat/message-history.ts
T
José Almeida 7de788ceed feat: add cross-integration entity references (#1862)
* feat: add cross-integration entity references

* fix(web): anchor entity reference menu to composer

* fix: keep Kandev tasks under @ mentions

* fix: address entity reference review findings

* test(e2e): use option role for prompt menu

* test(e2e): update passthrough mention roles

* fix: preserve queued references and paginate Azure PRs
2026-07-23 09:03:56 +01:00

176 lines
6.1 KiB
TypeScript

import type { Message } from "@/lib/types/http";
import type { EntityReference } from "@/lib/types/entity-reference";
import type { MessagesState } from "@/lib/state/slices/session/types";
import { entityReferencesFromMetadata } from "@/lib/entity-references/message-references";
export type MessageHistoryEntry = {
content: string;
entityReferences: EntityReference[];
};
type MessageHistoryStoreState = {
messages: MessagesState;
};
function sameEntityReference(left: EntityReference, right: EntityReference): boolean {
return (
left.version === right.version &&
left.ref === right.ref &&
left.provider === right.provider &&
left.kind === right.kind &&
left.id === right.id &&
left.key === right.key &&
left.title === right.title &&
left.url === right.url &&
left.scope === right.scope
);
}
function sameHistoryEntries(
left: readonly MessageHistoryEntry[],
right: readonly MessageHistoryEntry[],
): boolean {
if (left.length !== right.length) return false;
return left.every((entry, index) => {
const other = right[index];
return (
entry.content === other.content &&
entry.entityReferences.length === other.entityReferences.length &&
entry.entityReferences.every((reference, referenceIndex) =>
sameEntityReference(reference, other.entityReferences[referenceIndex]),
)
);
});
}
export function extractUserHistoryEntries(messages: readonly Message[]): MessageHistoryEntry[] {
const out: MessageHistoryEntry[] = [];
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.author_type !== "user" || message.type !== "message") continue;
const content = message.content ?? "";
if (!content.trim()) continue;
const newer = messages[i + 1];
const isAdjacentUserDuplicate =
newer?.author_type === "user" &&
newer.type === "message" &&
(newer.content ?? "") === content;
if (isAdjacentUserDuplicate) continue;
out.push({ content, entityReferences: entityReferencesFromMetadata(message.metadata) });
}
return out;
}
/** Build a Zustand selector whose result stays referentially stable while
* agent messages stream. React's external-store contract requires the same
* snapshot object when the derived user history has not changed. */
export function createMessageHistorySelector(sessionId: string | null) {
let previous: MessageHistoryEntry[] = [];
return (state: MessageHistoryStoreState): MessageHistoryEntry[] => {
const messages = sessionId ? state.messages.bySession[sessionId] : undefined;
const next = extractUserHistoryEntries(messages ?? []);
if (sameHistoryEntries(previous, next)) return previous;
previous = next;
return previous;
};
}
/** Extract the user's previously sent text messages for a session, newest-first.
* Consecutive duplicates *in the original message stream* are collapsed —
* duplicates separated by an assistant or tool turn stay, since the user
* meaningfully repeated themselves and should be able to recall the older
* one from history. Empty/whitespace-only messages are skipped. */
export function extractUserHistory(messages: readonly Message[]): string[] {
return extractUserHistoryEntries(messages).map((entry) => entry.content);
}
export type HistoryState = {
/** Position within the history list (0 = most recent). `null` means the user
* is currently editing their own draft, not viewing history. */
index: number | null;
};
/** Decide the next history navigation state when ArrowUp/ArrowDown is pressed.
* Returns `null` if the press should be deferred (e.g. no history, or at the
* oldest entry on ArrowUp — caller should treat as a no-op). */
export function navigateHistory(
state: HistoryState,
direction: "up" | "down",
historyLength: number,
): HistoryState | null {
if (historyLength === 0) return null;
if (direction === "up") {
const next = state.index === null ? 0 : state.index + 1;
if (next >= historyLength) return null;
return { index: next };
}
if (state.index === null) return null;
if (state.index === 0) return { index: null };
return { index: state.index - 1 };
}
/** Lightweight subsequence-fuzzy scorer. Returns `null` if `needle`'s
* characters cannot be found in `haystack` in order (case-insensitive).
* Higher score = better match; consecutive matches and word-boundary hits
* rank higher. Shorter haystacks tie-break ahead of longer ones. */
export function fuzzyScore(needle: string, haystack: string): number | null {
if (!needle) return 1;
const n = needle.toLowerCase();
const h = haystack.toLowerCase();
let hi = 0;
let score = 0;
let consecutive = 0;
for (let ni = 0; ni < n.length; ni++) {
const c = n.charAt(ni);
const next = h.indexOf(c, hi);
if (next === -1) return null;
if (next === hi) {
consecutive++;
score += 2 + consecutive;
} else {
consecutive = 0;
score += 1;
}
const prev = next === 0 ? "" : h.charAt(next - 1);
if (next === 0 || /[\s\-_/.,(){}[\]]/.test(prev)) score += 2;
hi = next + 1;
}
return score - haystack.length * 0.01;
}
export type SearchHit = {
/** Original index in the history array (lets the caller jump the history
* index when the user picks a search result). */
index: number;
content: string;
score: number;
};
function historyEntryContent(entry: string | MessageHistoryEntry): string {
return typeof entry === "string" ? entry : entry.content;
}
/** Score every history entry against `query` and return matches sorted
* best-first. An empty query returns every entry in newest-first order
* (preserving the original index). */
export function searchHistory(
history: readonly (string | MessageHistoryEntry)[],
query: string,
): SearchHit[] {
if (!query) {
return history.map((entry, index) => ({
index,
content: historyEntryContent(entry),
score: 0,
}));
}
const hits: SearchHit[] = [];
for (let i = 0; i < history.length; i++) {
const content = historyEntryContent(history[i]);
const score = fuzzyScore(query, content);
if (score !== null) hits.push({ index: i, content, score });
}
hits.sort((a, b) => b.score - a.score);
return hits;
}