diff --git a/apps/desktop/src/chat/components/input/hooks.test.ts b/apps/desktop/src/chat/components/input/hooks.test.ts index 23cc343ae4..5b2d91bc27 100644 --- a/apps/desktop/src/chat/components/input/hooks.test.ts +++ b/apps/desktop/src/chat/components/input/hooks.test.ts @@ -38,6 +38,7 @@ describe("serializeDraftMessage", () => { { kind: "human", key: "human:manual:human-1", + label: "john", source: "draft", humanId: "human-1", }, @@ -83,6 +84,7 @@ describe("serializeDraftMessage", () => { { kind: "organization", key: "organization:manual:org-1", + label: "Acme", source: "draft", organizationId: "org-1", }, diff --git a/apps/desktop/src/chat/components/message/normal.test.tsx b/apps/desktop/src/chat/components/message/normal.test.tsx new file mode 100644 index 0000000000..ec07569ccb --- /dev/null +++ b/apps/desktop/src/chat/components/message/normal.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { NormalMessage } from "./normal"; + +describe("NormalMessage", () => { + test("renders markdown in user messages", async () => { + render( + , + ); + + const link = await screen.findByRole("link", { + name: "Docs", + }); + + expect(link.getAttribute("href")).toBe("https://example.com/docs"); + }); +}); diff --git a/apps/desktop/src/chat/components/message/user-text.test.ts b/apps/desktop/src/chat/components/message/user-text.test.ts new file mode 100644 index 0000000000..c514a4c9aa --- /dev/null +++ b/apps/desktop/src/chat/components/message/user-text.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest"; + +import { buildUserMessageSegments } from "./user-text"; + +describe("buildUserMessageSegments", () => { + test("replaces matching mention tokens inline", () => { + expect( + buildUserMessageSegments("chat with @adhit@janet.ai about this", [ + { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "adhit@janet.ai", + tokens: ["@adhit@janet.ai"], + }, + ]), + ).toEqual([ + { type: "text", text: "chat with " }, + { + type: "mention", + mention: { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "adhit@janet.ai", + tokens: ["@adhit@janet.ai"], + }, + }, + { type: "text", text: " about this" }, + ]); + }); + + test("renders repeated mentions from a single ref", () => { + expect( + buildUserMessageSegments("@Sam follow up with @Sam tomorrow", [ + { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "Sam", + tokens: ["@Sam"], + }, + ]), + ).toEqual([ + { + type: "mention", + mention: { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "Sam", + tokens: ["@Sam"], + }, + }, + { type: "text", text: " follow up with " }, + { + type: "mention", + mention: { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "Sam", + tokens: ["@Sam"], + }, + }, + { type: "text", text: " tomorrow" }, + ]); + }); + + test("prefers the longest token when mentions overlap", () => { + expect( + buildUserMessageSegments("@Sam Lee and @Sam", [ + { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "Sam", + tokens: ["@Sam"], + }, + { + key: "human:manual:2", + kind: "human", + entityId: "2", + displayLabel: "Sam Lee", + tokens: ["@Sam Lee", "@Sam"], + }, + ]), + ).toEqual([ + { + type: "mention", + mention: { + key: "human:manual:2", + kind: "human", + entityId: "2", + displayLabel: "Sam Lee", + tokens: ["@Sam Lee", "@Sam"], + }, + }, + { type: "text", text: " and " }, + { + type: "mention", + mention: { + key: "human:manual:1", + kind: "human", + entityId: "1", + displayLabel: "Sam", + tokens: ["@Sam"], + }, + }, + ]); + }); +}); diff --git a/apps/desktop/src/chat/components/message/user-text.tsx b/apps/desktop/src/chat/components/message/user-text.tsx new file mode 100644 index 0000000000..c2b95946f7 --- /dev/null +++ b/apps/desktop/src/chat/components/message/user-text.tsx @@ -0,0 +1,269 @@ +import { Facehash } from "facehash"; +import { Building2Icon, StickyNoteIcon, type LucideIcon } from "lucide-react"; +import { Fragment, useMemo } from "react"; + +import { cn } from "@hypr/utils"; + +import type { ContextRef } from "~/chat/context/entities"; +import { getContextRefs } from "~/chat/context/refs"; +import type { HyprUIMessage } from "~/chat/types"; +import { getContactBgClass } from "~/contacts/shared"; +import * as main from "~/store/tinybase/store/main"; +import { useTabs } from "~/store/zustand/tabs"; + +type MentionCandidate = { + key: string; + kind: ContextRef["kind"]; + entityId: string; + displayLabel: string; + tokens: string[]; +}; + +type MentionSegment = + | { type: "text"; text: string } + | { type: "mention"; mention: MentionCandidate }; + +function uniqueLabels(labels: Array): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const label of labels) { + if (typeof label !== "string") { + continue; + } + + const trimmed = label.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + result.push(trimmed); + } + + return result; +} + +function getMentionCandidate( + ref: ContextRef, + store: ReturnType, +): 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))