,
+): MentionCandidate | null {
+ if (ref.kind === "human") {
+ const row = store?.getRow("humans", ref.humanId) ?? {};
+ const labels = uniqueLabels([
+ ref.label,
+ typeof row.name === "string" ? row.name : null,
+ typeof row.email === "string" ? row.email : null,
+ ]);
+
+ if (labels.length === 0) {
+ return null;
+ }
+
+ return {
+ key: ref.key,
+ kind: ref.kind,
+ entityId: ref.humanId,
+ displayLabel: labels[0],
+ tokens: labels.map((label) => `@${label}`),
+ };
+ }
+
+ if (ref.kind === "session") {
+ const row = store?.getRow("sessions", ref.sessionId) ?? {};
+ const labels = uniqueLabels([
+ ref.label,
+ typeof row.title === "string" ? row.title : null,
+ ]);
+
+ if (labels.length === 0) {
+ return null;
+ }
+
+ return {
+ key: ref.key,
+ kind: ref.kind,
+ entityId: ref.sessionId,
+ displayLabel: labels[0],
+ tokens: labels.map((label) => `@${label}`),
+ };
+ }
+
+ const row = store?.getRow("organizations", ref.organizationId) ?? {};
+ const labels = uniqueLabels([
+ ref.label,
+ typeof row.name === "string" ? row.name : null,
+ ]);
+
+ if (labels.length === 0) {
+ return null;
+ }
+
+ return {
+ key: ref.key,
+ kind: ref.kind,
+ entityId: ref.organizationId,
+ displayLabel: labels[0],
+ tokens: labels.map((label) => `@${label}`),
+ };
+}
+
+export function buildUserMessageSegments(
+ text: string,
+ mentions: MentionCandidate[],
+): MentionSegment[] {
+ if (!text) {
+ return [];
+ }
+
+ const segments: MentionSegment[] = [];
+ let cursor = 0;
+
+ while (cursor < text.length) {
+ let match:
+ | {
+ mention: MentionCandidate;
+ start: number;
+ end: number;
+ tokenLength: number;
+ }
+ | undefined;
+
+ for (const mention of mentions) {
+ for (const token of mention.tokens) {
+ const start = text.indexOf(token, cursor);
+ if (start === -1) {
+ continue;
+ }
+
+ if (
+ !match ||
+ start < match.start ||
+ (start === match.start && token.length > match.tokenLength)
+ ) {
+ match = {
+ mention,
+ start,
+ end: start + token.length,
+ tokenLength: token.length,
+ };
+ }
+ }
+ }
+
+ if (!match) {
+ segments.push({ type: "text", text: text.slice(cursor) });
+ break;
+ }
+
+ if (match.start > cursor) {
+ segments.push({ type: "text", text: text.slice(cursor, match.start) });
+ }
+
+ segments.push({ type: "mention", mention: match.mention });
+ cursor = match.end;
+ }
+
+ return segments;
+}
+
+function UserMentionChip({ mention }: { mention: MentionCandidate }) {
+ const openNew = useTabs((state) => state.openNew);
+
+ const handleClick = () => {
+ if (mention.kind === "session") {
+ openNew({ type: "sessions", id: mention.entityId });
+ return;
+ }
+
+ if (mention.kind === "human") {
+ openNew({ type: "humans", id: mention.entityId });
+ return;
+ }
+
+ openNew({ type: "organizations", id: mention.entityId });
+ };
+
+ return (
+
+ );
+}
+
+function MentionAvatar({ mention }: { mention: MentionCandidate }) {
+ if (mention.kind === "human") {
+ const bgClass = getContactBgClass(mention.displayLabel);
+ return (
+
+
+
+ );
+ }
+
+ const Icon: LucideIcon =
+ mention.kind === "session" ? StickyNoteIcon : Building2Icon;
+
+ return (
+
+
+
+ );
+}
+
+export function UserMessageText({ message }: { message: HyprUIMessage }) {
+ const store = main.UI.useStore(main.STORE_ID);
+ const text = message.parts
+ .filter(
+ (
+ part,
+ ): part is Extract<(typeof message.parts)[number], { type: "text" }> =>
+ part.type === "text",
+ )
+ .map((part) => part.text)
+ .join("\n");
+
+ const mentions = useMemo(
+ () =>
+ getContextRefs(message.metadata)
+ .map((ref) => getMentionCandidate(ref, store))
+ .filter((mention): mention is MentionCandidate => mention !== null),
+ [message.metadata, store],
+ );
+
+ const segments = useMemo(
+ () => buildUserMessageSegments(text, mentions),
+ [text, mentions],
+ );
+
+ return (
+
+ {segments.map((segment, index) => {
+ if (segment.type === "text") {
+ return {segment.text};
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/desktop/src/chat/components/session-provider.test.tsx b/apps/desktop/src/chat/components/session-provider.test.tsx
index 7027e2fe33..e71b692081 100644
--- a/apps/desktop/src/chat/components/session-provider.test.tsx
+++ b/apps/desktop/src/chat/components/session-provider.test.tsx
@@ -88,18 +88,20 @@ describe("ChatSession", () => {
],
});
- const child = vi.fn(() => null);
+ let pendingRefs: unknown[] | undefined;
render(
- {(props) => child(props)}
+ {(props) => {
+ pendingRefs = props.pendingRefs;
+ return null;
+ }}
,
);
await act(async () => {});
- expect(child).toHaveBeenCalled();
- expect(child.mock.lastCall?.[0].pendingRefs).toEqual([
+ expect(pendingRefs).toEqual([
{
kind: "human",
key: "human:manual:human-1",
diff --git a/apps/desktop/src/chat/context/entities.ts b/apps/desktop/src/chat/context/entities.ts
index 002c96861a..349694d3bb 100644
--- a/apps/desktop/src/chat/context/entities.ts
+++ b/apps/desktop/src/chat/context/entities.ts
@@ -17,6 +17,7 @@ export type ContextEntitySource = (typeof CONTEXT_ENTITY_SOURCES)[number];
type BaseContextRef = {
key: string;
+ label?: string;
source?: ContextEntitySource;
};
diff --git a/apps/desktop/src/chat/context/refs.ts b/apps/desktop/src/chat/context/refs.ts
index 137d4270a5..e9b4233bae 100644
--- a/apps/desktop/src/chat/context/refs.ts
+++ b/apps/desktop/src/chat/context/refs.ts
@@ -13,6 +13,10 @@ export function isContextRef(value: unknown): value is ContextRef {
return false;
}
+ if (value.label !== undefined && typeof value.label !== "string") {
+ return false;
+ }
+
if (
value.source !== undefined &&
(typeof value.source !== "string" || !validSources.has(value.source))