From 57a314233053f78a1175ea842ac17219da3eb3cb Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 23 Mar 2026 20:45:33 -0700 Subject: [PATCH 01/14] fix: hide the context bar when no context is attached - Return early from the context bar when there are no context chips - Keep the add button available only once context is already present --- apps/desktop/src/chat/components/context-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/chat/components/context-bar.tsx b/apps/desktop/src/chat/components/context-bar.tsx index 73f5439a0d..d90e1784be 100644 --- a/apps/desktop/src/chat/components/context-bar.tsx +++ b/apps/desktop/src/chat/components/context-bar.tsx @@ -281,7 +281,7 @@ export function ContextBar({ [entities], ); - if (chips.length === 0 && !onAddEntity) { + if (chips.length === 0) { return null; } From dd761677a6235b853cd56eca899ca63b38d06177 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 23 Mar 2026 20:45:48 -0700 Subject: [PATCH 02/14] fix: restore rounded chat composer corners when context is empty - Hide the context bar entirely when there are no context chips - Render the composer with full rounded corners when it is no longer attached to a context bar --- apps/desktop/src/chat/components/input/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index 3ea3caa255..4569510c15 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -148,8 +148,12 @@ function Container({
{children} From ed9c0a69ecab11aa17af57223d9310f0adad42c6 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 23 Mar 2026 20:47:55 -0700 Subject: [PATCH 03/14] fix: make chat mentions participate in context - Extract mention nodes from the chat draft into typed context refs - Show mentioned sessions, people, and organizations in the context bar while typing - Persist mention refs on sent messages and render contact/org context into the chat prompt --- .../src/chat/components/body/empty.tsx | 2 + .../src/chat/components/body/index.tsx | 2 + apps/desktop/src/chat/components/content.tsx | 25 ++++- .../src/chat/components/context-bar.tsx | 28 ++++-- .../src/chat/components/input/hooks.ts | 98 ++++++++++++++++++- .../src/chat/components/input/index.tsx | 6 ++ .../src/chat/components/session-provider.tsx | 20 +++- apps/desktop/src/chat/context/entities.ts | 34 ++++++- apps/desktop/src/chat/context/refs.ts | 32 ++++-- apps/desktop/src/chat/context/registry.ts | 41 +++++++- .../chat/context/use-chat-context-pipeline.ts | 86 +++++++++++++--- apps/desktop/src/chat/transport/index.ts | 43 ++++++-- .../src/chat/transport/use-transport.ts | 84 +++++++++++++++- apps/desktop/src/chat/types.ts | 26 +++-- 14 files changed, 470 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/chat/components/body/empty.tsx b/apps/desktop/src/chat/components/body/empty.tsx index 56e0304db4..0fa5c27e9f 100644 --- a/apps/desktop/src/chat/components/body/empty.tsx +++ b/apps/desktop/src/chat/components/body/empty.tsx @@ -8,6 +8,7 @@ import { useCallback } from "react"; import { cn } from "@hypr/utils"; +import type { ContextRef } from "~/chat/context/entities"; import { useTabs } from "~/store/zustand/tabs"; const SUGGESTIONS = [ @@ -38,6 +39,7 @@ export function ChatBodyEmpty({ onSendMessage?: ( content: string, parts: Array<{ type: "text"; text: string }>, + contextRefs?: ContextRef[], ) => void; }) { const openNew = useTabs((state) => state.openNew); diff --git a/apps/desktop/src/chat/components/body/index.tsx b/apps/desktop/src/chat/components/body/index.tsx index 5ac91831cb..0b77edbf77 100644 --- a/apps/desktop/src/chat/components/body/index.tsx +++ b/apps/desktop/src/chat/components/body/index.tsx @@ -8,6 +8,7 @@ import { ChatBodyEmpty } from "./empty"; import { ChatBodyNonEmpty } from "./non-empty"; import { useChatAutoScroll } from "./use-chat-auto-scroll"; +import type { ContextRef } from "~/chat/context/entities"; import type { HyprUIMessage } from "~/chat/types"; import { useShell } from "~/contexts/shell"; @@ -29,6 +30,7 @@ export function ChatBody({ onSendMessage?: ( content: string, parts: Array<{ type: "text"; text: string }>, + contextRefs?: ContextRef[], ) => void; }) { const { chat } = useShell(); diff --git a/apps/desktop/src/chat/components/content.tsx b/apps/desktop/src/chat/components/content.tsx index 92c01ccaee..c07eccf95a 100644 --- a/apps/desktop/src/chat/components/content.tsx +++ b/apps/desktop/src/chat/components/content.tsx @@ -5,7 +5,7 @@ import { ContextBar } from "./context-bar"; import { ChatMessageInput, type McpIndicator } from "./input"; import type { useLanguageModel } from "~/ai/hooks"; -import type { ContextRef } from "~/chat/context/entities"; +import { dedupeByKey, type ContextRef } from "~/chat/context/entities"; import type { DisplayEntity } from "~/chat/context/use-chat-context-pipeline"; import type { HyprUIMessage } from "~/chat/types"; @@ -23,6 +23,7 @@ export function ChatContent({ pendingRefs, onRemoveContextEntity, onAddContextEntity, + onDraftContextRefsChange, isSystemPromptReady, mcpIndicator, children, @@ -45,11 +46,14 @@ export function ChatContent({ pendingRefs: ContextRef[]; onRemoveContextEntity?: (key: string) => void; onAddContextEntity?: (ref: ContextRef) => void; + onDraftContextRefsChange?: (refs: ContextRef[]) => void; isSystemPromptReady: boolean; mcpIndicator?: McpIndicator; children?: React.ReactNode; }) { const disabled = !model || !isSystemPromptReady; + const mergeContextRefs = (contextRefs?: ContextRef[]) => + contextRefs ? dedupeByKey([pendingRefs, contextRefs]) : pendingRefs; return (
@@ -60,8 +64,13 @@ export function ChatContent({ error={error} onReload={regenerate} isModelConfigured={!!model} - onSendMessage={(content, parts) => { - handleSendMessage(content, parts, sendMessage, pendingRefs); + onSendMessage={(content, parts, contextRefs) => { + handleSendMessage( + content, + parts, + sendMessage, + mergeContextRefs(contextRefs), + ); }} /> )} @@ -74,9 +83,15 @@ export function ChatContent({ draftKey={sessionId} disabled={disabled} hasContextBar={contextEntities.length > 0} - onSendMessage={(content, parts) => { - handleSendMessage(content, parts, sendMessage, pendingRefs); + onSendMessage={(content, parts, contextRefs) => { + handleSendMessage( + content, + parts, + sendMessage, + mergeContextRefs(contextRefs), + ); }} + onContextRefsChange={onDraftContextRefsChange} isStreaming={status === "streaming" || status === "submitted"} onStop={stop} mcpIndicator={mcpIndicator} diff --git a/apps/desktop/src/chat/components/context-bar.tsx b/apps/desktop/src/chat/components/context-bar.tsx index d90e1784be..2bcc1a038d 100644 --- a/apps/desktop/src/chat/components/context-bar.tsx +++ b/apps/desktop/src/chat/components/context-bar.tsx @@ -71,17 +71,33 @@ function ContextChip({ }) { const Icon = chip.icon; const openNew = useTabs((state) => state.openNew); - const isClickable = chip.entityKind === "session" && chip.entityId; + const isClickable = !!chip.entityKind && !!chip.entityId; + + const handleClick = () => { + if (!chip.entityKind || !chip.entityId) { + return; + } + + if (chip.entityKind === "session") { + openNew({ type: "sessions", id: chip.entityId }); + return; + } + + if (chip.entityKind === "human") { + openNew({ type: "humans", id: chip.entityId }); + return; + } + + if (chip.entityKind === "organization") { + openNew({ type: "organizations", id: chip.entityId }); + } + }; return ( { - if (isClickable) { - openNew({ type: "sessions", id: chip.entityId! }); - } - }} + onClick={handleClick} className={cn([ "group max-w-48 min-w-0 rounded-md px-1.5 py-0.5 text-xs", pending diff --git a/apps/desktop/src/chat/components/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index 4e4f456190..eb7d4b9e1b 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -8,22 +8,36 @@ import type { } from "@hypr/tiptap/chat"; import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared"; +import type { ContextRef } from "~/chat/context/entities"; import { useSearchEngine } from "~/search/contexts/engine"; import * as main from "~/store/tinybase/store/main"; const draftsByKey = new Map(); -export function useDraftState({ draftKey }: { draftKey: string }) { +export function useDraftState({ + draftKey, + onContextRefsChange, +}: { + draftKey: string; + onContextRefsChange?: (refs: ContextRef[]) => void; +}) { const [hasContent, setHasContent] = useState(false); const initialContent = useRef(draftsByKey.get(draftKey) ?? EMPTY_TIPTAP_DOC); + useEffect(() => { + onContextRefsChange?.( + extractContextRefsFromTiptapJson(initialContent.current), + ); + }, [onContextRefsChange]); + const handleEditorUpdate = useCallback( (json: JSONContent) => { const text = tiptapJsonToText(json).trim(); setHasContent(text.length > 0); draftsByKey.set(draftKey, json); + onContextRefsChange?.(extractContextRefsFromTiptapJson(json)); }, - [draftKey], + [draftKey, onContextRefsChange], ); return { @@ -39,6 +53,7 @@ export function useSubmit({ disabled, isStreaming, onSendMessage, + onContextRefsChange, }: { draftKey: string; editorRef: React.RefObject<{ editor: TiptapEditor | null } | null>; @@ -47,21 +62,32 @@ export function useSubmit({ onSendMessage: ( content: string, parts: Array<{ type: "text"; text: string }>, + contextRefs?: ContextRef[], ) => void; + onContextRefsChange?: (refs: ContextRef[]) => void; }) { return useCallback(() => { const json = editorRef.current?.editor?.getJSON(); const text = tiptapJsonToText(json).trim(); + const mentionRefs = extractContextRefsFromTiptapJson(json); if (!text || disabled || isStreaming) { return; } void analyticsCommands.event({ event: "message_sent" }); - onSendMessage(text, [{ type: "text", text }]); + onSendMessage(text, [{ type: "text", text }], mentionRefs); editorRef.current?.editor?.commands.clearContent(); draftsByKey.delete(draftKey); - }, [draftKey, editorRef, disabled, isStreaming, onSendMessage]); + onContextRefsChange?.([]); + }, [ + draftKey, + editorRef, + disabled, + isStreaming, + onSendMessage, + onContextRefsChange, + ]); } export function useAutoFocusEditor({ @@ -179,7 +205,7 @@ function tiptapJsonToText(json: any): string { return json.text || ""; } - if (json.type === "mention") { + if (typeof json.type === "string" && json.type.startsWith("mention-")) { return `@${json.attrs?.label || json.attrs?.id || ""}`; } @@ -189,3 +215,65 @@ function tiptapJsonToText(json: any): string { return ""; } + +function extractContextRefsFromTiptapJson( + json: JSONContent | undefined, +): ContextRef[] { + const refs: ContextRef[] = []; + const seen = new Set(); + + const visit = (node: JSONContent | undefined) => { + if (!node || typeof node !== "object") { + return; + } + + if (typeof node.type === "string" && node.type.startsWith("mention-")) { + const mentionType = + typeof node.attrs?.type === "string" ? node.attrs.type : null; + const mentionId = + typeof node.attrs?.id === "string" ? node.attrs.id : null; + + if (!mentionType || !mentionId) { + return; + } + + let ref: ContextRef | null = null; + if (mentionType === "session") { + ref = { + kind: "session", + key: `session:manual:${mentionId}`, + source: "manual", + sessionId: mentionId, + }; + } else if (mentionType === "human") { + ref = { + kind: "human", + key: `human:manual:${mentionId}`, + source: "manual", + humanId: mentionId, + }; + } else if (mentionType === "organization") { + ref = { + kind: "organization", + key: `organization:manual:${mentionId}`, + source: "manual", + organizationId: mentionId, + }; + } + + if (ref && !seen.has(ref.key)) { + seen.add(ref.key); + refs.push(ref); + } + } + + if (Array.isArray(node.content)) { + for (const child of node.content) { + visit(child); + } + } + }; + + visit(json); + return refs; +} diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index 4569510c15..32f9d8832c 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -15,6 +15,7 @@ import { } from "./hooks"; import { type McpIndicator, McpIndicatorBadge } from "./mcp"; +import type { ContextRef } from "~/chat/context/entities"; import { useShell } from "~/contexts/shell"; export type { McpIndicator } from "./mcp"; @@ -27,17 +28,20 @@ export function ChatMessageInput({ isStreaming, onStop, mcpIndicator, + onContextRefsChange, }: { draftKey: string; onSendMessage: ( content: string, parts: Array<{ type: "text"; text: string }>, + contextRefs?: ContextRef[], ) => void; disabled?: boolean | { disabled: boolean; message?: string }; hasContextBar?: boolean; isStreaming?: boolean; onStop?: () => void; mcpIndicator?: McpIndicator; + onContextRefsChange?: (refs: ContextRef[]) => void; }) { const { chat } = useShell(); const editorRef = useRef<{ editor: TiptapEditor | null }>(null); @@ -48,6 +52,7 @@ export function ChatMessageInput({ const { hasContent, initialContent, handleEditorUpdate } = useDraftState({ draftKey, + onContextRefsChange, }); const handleSubmit = useSubmit({ draftKey, @@ -55,6 +60,7 @@ export function ChatMessageInput({ disabled, isStreaming, onSendMessage, + onContextRefsChange, }); useAutoFocusEditor({ editorRef, disabled, shouldFocus }); const slashCommandConfig = useSlashCommandConfig(); diff --git a/apps/desktop/src/chat/components/session-provider.tsx b/apps/desktop/src/chat/components/session-provider.tsx index 930fa1e1a4..b12fa34ede 100644 --- a/apps/desktop/src/chat/components/session-provider.tsx +++ b/apps/desktop/src/chat/components/session-provider.tsx @@ -4,11 +4,12 @@ import { type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; -import type { ContextRef } from "~/chat/context/entities"; +import { dedupeByKey, type ContextRef } from "~/chat/context/entities"; import { type DisplayEntity, useChatContextPipeline, @@ -45,6 +46,7 @@ interface ChatSessionProps { pendingRefs: ContextRef[]; onRemoveContextEntity: (key: string) => void; onAddContextEntity: (ref: ContextRef) => void; + onDraftContextRefsChange: (refs: ContextRef[]) => void; isSystemPromptReady: boolean; }) => ReactNode; } @@ -62,6 +64,7 @@ export function ChatSession({ const { user_id } = main.UI.useValues(main.STORE_ID); const [pendingManualRefs, setPendingManualRefs] = useState([]); + const [pendingDraftRefs, setPendingDraftRefs] = useState([]); const onAddContextEntity = useCallback((ref: ContextRef) => { setPendingManualRefs((prev) => @@ -71,10 +74,16 @@ export function ChatSession({ const onRemoveContextEntity = useCallback((key: string) => { setPendingManualRefs((prev) => prev.filter((r) => r.key !== key)); + setPendingDraftRefs((prev) => prev.filter((r) => r.key !== key)); + }, []); + + const onDraftContextRefsChange = useCallback((refs: ContextRef[]) => { + setPendingDraftRefs(refs); }, []); useEffect(() => { setPendingManualRefs([]); + setPendingDraftRefs([]); }, [sessionId, chatGroupId]); const { transport, isSystemPromptReady } = useTransport( @@ -156,14 +165,20 @@ export function ChatSession({ const count = messages.filter((message) => message.role === "user").length; if (count > prevUserMsgCountRef.current) { setPendingManualRefs([]); + setPendingDraftRefs([]); } prevUserMsgCountRef.current = count; }, [messages]); + const pendingMessageRefs = useMemo( + () => dedupeByKey([pendingManualRefs, pendingDraftRefs]), + [pendingManualRefs, pendingDraftRefs], + ); + const { contextEntities, pendingRefs } = useChatContextPipeline({ messages, currentSessionId, - pendingManualRefs, + pendingManualRefs: pendingMessageRefs, store, }); @@ -182,6 +197,7 @@ export function ChatSession({ pendingRefs, onRemoveContextEntity, onAddContextEntity, + onDraftContextRefsChange, isSystemPromptReady, })}
diff --git a/apps/desktop/src/chat/context/entities.ts b/apps/desktop/src/chat/context/entities.ts index e166f5ba2b..dc04037057 100644 --- a/apps/desktop/src/chat/context/entities.ts +++ b/apps/desktop/src/chat/context/entities.ts @@ -14,19 +14,47 @@ export const CONTEXT_ENTITY_SOURCES = [ ] as const; export type ContextEntitySource = (typeof CONTEXT_ENTITY_SOURCES)[number]; -export type ContextRef = { - kind: "session"; +type BaseContextRef = { key: string; source?: ContextEntitySource; +}; + +export type SessionContextRef = BaseContextRef & { + kind: "session"; sessionId: string; }; +export type HumanContextRef = BaseContextRef & { + kind: "human"; + humanId: string; +}; + +export type OrganizationContextRef = BaseContextRef & { + kind: "organization"; + organizationId: string; +}; + +export type ContextRef = + | SessionContextRef + | HumanContextRef + | OrganizationContextRef; + export type ContextEntity = - | (ContextRef & { + | (SessionContextRef & { title?: string | null; date?: string | null; removable?: boolean; }) + | (HumanContextRef & { + name?: string | null; + email?: string | null; + organizationName?: string | null; + removable?: boolean; + }) + | (OrganizationContextRef & { + name?: string | null; + removable?: boolean; + }) | ({ kind: "account"; key: string; diff --git a/apps/desktop/src/chat/context/refs.ts b/apps/desktop/src/chat/context/refs.ts index 4c30b2f56c..137d4270a5 100644 --- a/apps/desktop/src/chat/context/refs.ts +++ b/apps/desktop/src/chat/context/refs.ts @@ -9,14 +9,30 @@ function isRecord(value: unknown): value is Record { const validSources: ReadonlySet = new Set(CONTEXT_ENTITY_SOURCES); export function isContextRef(value: unknown): value is ContextRef { - return ( - isRecord(value) && - value.kind === "session" && - typeof value.key === "string" && - typeof value.sessionId === "string" && - (value.source === undefined || - (typeof value.source === "string" && validSources.has(value.source))) - ); + if (!isRecord(value) || typeof value.key !== "string") { + return false; + } + + if ( + value.source !== undefined && + (typeof value.source !== "string" || !validSources.has(value.source)) + ) { + return false; + } + + if (value.kind === "session") { + return typeof value.sessionId === "string"; + } + + if (value.kind === "human") { + return typeof value.humanId === "string"; + } + + if (value.kind === "organization") { + return typeof value.organizationId === "string"; + } + + return false; } export function getContextRefs(metadata: unknown): ContextRef[] { diff --git a/apps/desktop/src/chat/context/registry.ts b/apps/desktop/src/chat/context/registry.ts index 54abc6276f..2ffea078cf 100644 --- a/apps/desktop/src/chat/context/registry.ts +++ b/apps/desktop/src/chat/context/registry.ts @@ -1,4 +1,10 @@ -import { CalendarIcon, MonitorIcon, SearchIcon, UserIcon } from "lucide-react"; +import { + Building2Icon, + CalendarIcon, + MonitorIcon, + SearchIcon, + UserIcon, +} from "lucide-react"; import type { ContextEntity, ContextEntityKind } from "./entities"; @@ -42,6 +48,39 @@ const renderers: RendererMap = { }, }, + human: { + toChip: (entity) => { + const label = entity.name || entity.email || "Person"; + const tooltip = [entity.name, entity.email, entity.organizationName] + .filter(Boolean) + .join(" • "); + return { + key: entity.key, + icon: UserIcon, + label, + tooltip: tooltip || label, + removable: entity.removable, + entityKind: "human", + entityId: entity.humanId, + }; + }, + }, + + organization: { + toChip: (entity) => { + const label = entity.name || "Organization"; + return { + key: entity.key, + icon: Building2Icon, + label, + tooltip: label, + removable: entity.removable, + entityKind: "organization", + entityId: entity.organizationId, + }; + }, + }, + account: { toChip: (entity) => { if (!entity.email && !entity.userId) return null; diff --git a/apps/desktop/src/chat/context/use-chat-context-pipeline.ts b/apps/desktop/src/chat/context/use-chat-context-pipeline.ts index 368bec7070..e57f577679 100644 --- a/apps/desktop/src/chat/context/use-chat-context-pipeline.ts +++ b/apps/desktop/src/chat/context/use-chat-context-pipeline.ts @@ -28,6 +28,77 @@ function getSessionDisplayData( }; } +function getHumanDisplayData( + store: ReturnType, + humanId: string, +): { + name: string | null; + email: string | null; + organizationName: string | null; +} { + if (!store) { + return { name: null, email: null, organizationName: null }; + } + + const row = store.getRow("humans", humanId); + const orgId = typeof row.org_id === "string" ? row.org_id : null; + const organization = + orgId && store.hasRow("organizations", orgId) + ? store.getRow("organizations", orgId) + : {}; + + return { + name: typeof row.name === "string" && row.name.trim() ? row.name : null, + email: typeof row.email === "string" && row.email.trim() ? row.email : null, + organizationName: + typeof organization.name === "string" && organization.name.trim() + ? organization.name + : null, + }; +} + +function getOrganizationDisplayData( + store: ReturnType, + organizationId: string, +): { name: string | null } { + if (!store) { + return { name: null }; + } + + const row = store.getRow("organizations", organizationId); + return { + name: typeof row.name === "string" && row.name.trim() ? row.name : null, + }; +} + +function toDisplayEntity( + ref: ContextRef, + store: ReturnType, + removable: boolean, +): ContextEntity { + if (ref.kind === "session") { + return { + ...ref, + ...getSessionDisplayData(store, ref.sessionId), + removable, + }; + } + + if (ref.kind === "human") { + return { + ...ref, + ...getHumanDisplayData(store, ref.humanId), + removable, + }; + } + + return { + ...ref, + ...getOrganizationDisplayData(store, ref.organizationId), + removable, + }; +} + type UseChatContextPipelineParams = { messages: HyprUIMessage[]; currentSessionId?: string; @@ -72,23 +143,16 @@ export function useChatContextPipeline({ }, [currentSessionId, pendingManualRefs]); const committedEntities = useMemo( - () => - committedRefs.map((ref) => ({ - ...ref, - ...getSessionDisplayData(store, ref.sessionId), - removable: false, - })), + () => committedRefs.map((ref) => toDisplayEntity(ref, store, false)), [committedRefs, store], ); // Pending manual refs are removable; pending auto-current is not. const pendingEntities = useMemo( () => - pendingRefs.map((ref) => ({ - ...ref, - ...getSessionDisplayData(store, ref.sessionId), - removable: ref.source === "manual", - })), + pendingRefs.map((ref) => + toDisplayEntity(ref, store, ref.source === "manual"), + ), [pendingRefs, store], ); diff --git a/apps/desktop/src/chat/transport/index.ts b/apps/desktop/src/chat/transport/index.ts index 5d282ac546..a2edabc625 100644 --- a/apps/desktop/src/chat/transport/index.ts +++ b/apps/desktop/src/chat/transport/index.ts @@ -28,6 +28,10 @@ import { type ToolOutputPart, } from "./helpers"; +export type ResolvedChatContext = + | { kind: "session"; context: SessionContext } + | { kind: "text"; text: string }; + export class CustomChatTransport implements ChatTransport { constructor( private model: LanguageModel, @@ -35,7 +39,7 @@ export class CustomChatTransport implements ChatTransport { private systemPrompt?: string, private resolveContextRef?: ( ref: ContextRef, - ) => Promise, + ) => Promise, ) {} private async renderContextBlock( @@ -52,24 +56,45 @@ export class CustomChatTransport implements ChatTransport { } const seen = new Set(); - const contexts: SessionContext[] = []; + const sessionContexts: SessionContext[] = []; + const textContexts: string[] = []; for (const ref of contextRefs) { if (seen.has(ref.key)) continue; seen.add(ref.key); const context = await this.resolveContextRef(ref); - if (context) contexts.push(context); + if (!context) { + continue; + } + + if (context.kind === "session") { + sessionContexts.push(context.context); + } else if (context.text.trim()) { + textContexts.push(context.text.trim()); + } } - if (contexts.length === 0) { + if (sessionContexts.length === 0 && textContexts.length === 0) { cache.set(cacheKey, null); return null; } - // Rendered by Rust-side template engine via Tauri plugin - const rendered = await templateCommands.render({ - contextBlock: { contexts }, - }); - const result = rendered.status === "ok" ? rendered.data : null; + const blocks: string[] = []; + + if (sessionContexts.length > 0) { + // Rendered by Rust-side template engine via Tauri plugin + const rendered = await templateCommands.render({ + contextBlock: { contexts: sessionContexts }, + }); + if (rendered.status === "ok" && rendered.data.trim()) { + blocks.push(rendered.data.trim()); + } + } + + if (textContexts.length > 0) { + blocks.push(textContexts.join("\n\n")); + } + + const result = blocks.length > 0 ? blocks.join("\n\n") : null; cache.set(cacheKey, result); return result; } diff --git a/apps/desktop/src/chat/transport/use-transport.ts b/apps/desktop/src/chat/transport/use-transport.ts index d45b8ee2c6..47a1d7a05b 100644 --- a/apps/desktop/src/chat/transport/use-transport.ts +++ b/apps/desktop/src/chat/transport/use-transport.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { commands as templateCommands } from "@hypr/plugin-template"; import { CustomChatTransport } from "./index"; +import type { ResolvedChatContext } from "./index"; import { useLanguageModel } from "~/ai/hooks"; import type { ContextRef } from "~/chat/context/entities"; @@ -11,6 +12,67 @@ import { hydrateSessionContextFromFs } from "~/chat/context/session-context-hydr import { useToolRegistry } from "~/contexts/tool"; import * as main from "~/store/tinybase/store/main"; +function renderHumanContext( + store: ReturnType, + humanId: string, +): string | null { + if (!store) { + return null; + } + + const human = store.getRow("humans", humanId); + const orgId = typeof human.org_id === "string" ? human.org_id : ""; + const organization = + orgId && store.hasRow("organizations", orgId) + ? store.getRow("organizations", orgId) + : {}; + + const name = + typeof human.name === "string" && human.name.trim() ? human.name : null; + const email = + typeof human.email === "string" && human.email.trim() ? human.email : null; + const jobTitle = + typeof human.job_title === "string" && human.job_title.trim() + ? human.job_title + : null; + const organizationName = + typeof organization.name === "string" && organization.name.trim() + ? organization.name + : null; + const memo = + typeof human.memo === "string" && human.memo.trim() ? human.memo : null; + + if (!name && !email) { + return null; + } + + const details = [ + jobTitle, + organizationName ? `Organization: ${organizationName}` : null, + email ? `Email: ${email}` : null, + memo ? `Notes: ${memo}` : null, + ].filter(Boolean); + + return [`Referenced contact: ${name ?? email}`, ...details].join("\n"); +} + +function renderOrganizationContext( + store: ReturnType, + organizationId: string, +): string | null { + if (!store) { + return null; + } + + const organization = store.getRow("organizations", organizationId); + const name = + typeof organization.name === "string" && organization.name.trim() + ? organization.name + : null; + + return name ? `Referenced organization: ${name}` : null; +} + export function useTransport( modelOverride?: LanguageModel, extraTools?: ToolSet, @@ -96,7 +158,27 @@ export function useTransport( if (!store) { return null; } - return hydrateSessionContextFromFs(store, ref.sessionId); + if (ref.kind === "session") { + const context = await hydrateSessionContextFromFs( + store, + ref.sessionId, + ); + return context + ? ({ kind: "session", context } satisfies ResolvedChatContext) + : null; + } + + if (ref.kind === "human") { + const text = renderHumanContext(store, ref.humanId); + return text + ? ({ kind: "text", text } satisfies ResolvedChatContext) + : null; + } + + const text = renderOrganizationContext(store, ref.organizationId); + return text + ? ({ kind: "text", text } satisfies ResolvedChatContext) + : null; }, ); }, [model, tools, effectiveSystemPrompt, store]); diff --git a/apps/desktop/src/chat/types.ts b/apps/desktop/src/chat/types.ts index aefd69cfe5..b023cc7222 100644 --- a/apps/desktop/src/chat/types.ts +++ b/apps/desktop/src/chat/types.ts @@ -8,12 +8,26 @@ const messageMetadataSchema = z.object({ createdAt: z.number().optional(), contextRefs: z .array( - z.object({ - kind: z.literal("session"), - key: z.string(), - source: z.enum(CONTEXT_ENTITY_SOURCES).optional(), - sessionId: z.string(), - }), + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("session"), + key: z.string(), + source: z.enum(CONTEXT_ENTITY_SOURCES).optional(), + sessionId: z.string(), + }), + z.object({ + kind: z.literal("human"), + key: z.string(), + source: z.enum(CONTEXT_ENTITY_SOURCES).optional(), + humanId: z.string(), + }), + z.object({ + kind: z.literal("organization"), + key: z.string(), + source: z.enum(CONTEXT_ENTITY_SOURCES).optional(), + organizationId: z.string(), + }), + ]), ) .optional(), }); From fe2487571d3491a942e18885e9a6925c646116b9 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 23 Mar 2026 20:50:13 -0700 Subject: [PATCH 04/14] fix: make chat mentions participate in context and plain-text messages - Extract mention nodes from the chat draft into typed context refs - Show mentioned sessions, people, and organizations in the context bar while typing - Persist mention refs on sent messages and render contact/org context into the chat prompt - Flatten mention nodes into plain-text @labels for the visible user message and chat title --- .../src/chat/components/input/hooks.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/chat/components/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index eb7d4b9e1b..5afc40f595 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -205,8 +205,12 @@ function tiptapJsonToText(json: any): string { return json.text || ""; } - if (typeof json.type === "string" && json.type.startsWith("mention-")) { - return `@${json.attrs?.label || json.attrs?.id || ""}`; + if (json.type === "hardBreak") { + return "\n"; + } + + if (isMentionNode(json)) { + return mentionNodeToPlainText(json); } if (json.content && Array.isArray(json.content)) { @@ -227,7 +231,7 @@ function extractContextRefsFromTiptapJson( return; } - if (typeof node.type === "string" && node.type.startsWith("mention-")) { + if (isMentionNode(node)) { const mentionType = typeof node.attrs?.type === "string" ? node.attrs.type : null; const mentionId = @@ -277,3 +281,23 @@ function extractContextRefsFromTiptapJson( visit(json); return refs; } + +function isMentionNode( + node: Pick | Record, +): boolean { + return ( + typeof node.type === "string" && + (node.type === "mention" || node.type.startsWith("mention-")) + ); +} + +function mentionNodeToPlainText(node: JSONContent): string { + const label = + typeof node.attrs?.label === "string" && node.attrs.label.trim() + ? node.attrs.label.trim() + : typeof node.attrs?.id === "string" && node.attrs.id.trim() + ? node.attrs.id.trim() + : ""; + + return label ? `@${label}` : ""; +} From d94ae4f0732875d9e6261b2883a44c1159f745f1 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 23 Mar 2026 21:18:31 -0700 Subject: [PATCH 05/14] refactor: simplify draft mention context handling - derive plain-text chat content and mention refs from one draft serializer - mark editor-derived mention refs as draft context instead of removable manual context - add regression coverage for mention text serialization and ref extraction --- .../src/chat/components/input/hooks.test.ts | 89 +++++++++++++++++++ .../src/chat/components/input/hooks.ts | 69 +++++++------- .../src/chat/components/session-provider.tsx | 1 - apps/desktop/src/chat/context/entities.ts | 1 + .../chat/context/use-chat-context-pipeline.ts | 2 +- 5 files changed, 121 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src/chat/components/input/hooks.test.ts diff --git a/apps/desktop/src/chat/components/input/hooks.test.ts b/apps/desktop/src/chat/components/input/hooks.test.ts new file mode 100644 index 0000000000..cd0c365fd8 --- /dev/null +++ b/apps/desktop/src/chat/components/input/hooks.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; + +import type { JSONContent } from "@hypr/tiptap/chat"; + +import { serializeDraftMessage } from "./hooks"; + +describe("serializeDraftMessage", () => { + test("serializes mention labels into plain text and draft refs", () => { + const json: JSONContent = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "mention-@", + attrs: { + id: "human-1", + type: "human", + label: "john", + }, + }, + { + type: "text", + text: " is this", + }, + ], + }, + ], + }; + + expect(serializeDraftMessage(json)).toEqual({ + text: "@john is this", + refs: [ + { + kind: "human", + key: "human:manual:human-1", + source: "draft", + humanId: "human-1", + }, + ], + }); + }); + + test("dedupes refs while preserving repeated mention text", () => { + const json: JSONContent = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "mention", + attrs: { + id: "org-1", + type: "organization", + label: "Acme", + }, + }, + { + type: "text", + text: " and ", + }, + { + type: "mention-@", + attrs: { + id: "org-1", + type: "organization", + label: "Acme", + }, + }, + ], + }, + ], + }; + + expect(serializeDraftMessage(json)).toEqual({ + text: "@Acme and @Acme", + refs: [ + { + kind: "organization", + key: "organization:manual:org-1", + source: "draft", + organizationId: "org-1", + }, + ], + }); + }); +}); diff --git a/apps/desktop/src/chat/components/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index 5afc40f595..caaf3d2e25 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -25,17 +25,16 @@ export function useDraftState({ const initialContent = useRef(draftsByKey.get(draftKey) ?? EMPTY_TIPTAP_DOC); useEffect(() => { - onContextRefsChange?.( - extractContextRefsFromTiptapJson(initialContent.current), - ); + onContextRefsChange?.(serializeDraftMessage(initialContent.current).refs); }, [onContextRefsChange]); const handleEditorUpdate = useCallback( (json: JSONContent) => { - const text = tiptapJsonToText(json).trim(); + const draft = serializeDraftMessage(json); + const text = draft.text.trim(); setHasContent(text.length > 0); draftsByKey.set(draftKey, json); - onContextRefsChange?.(extractContextRefsFromTiptapJson(json)); + onContextRefsChange?.(draft.refs); }, [draftKey, onContextRefsChange], ); @@ -68,15 +67,15 @@ export function useSubmit({ }) { return useCallback(() => { const json = editorRef.current?.editor?.getJSON(); - const text = tiptapJsonToText(json).trim(); - const mentionRefs = extractContextRefsFromTiptapJson(json); + const draft = serializeDraftMessage(json); + const text = draft.text.trim(); if (!text || disabled || isStreaming) { return; } void analyticsCommands.event({ event: "message_sent" }); - onSendMessage(text, [{ type: "text", text }], mentionRefs); + onSendMessage(text, [{ type: "text", text }], draft.refs); editorRef.current?.editor?.commands.clearContent(); draftsByKey.delete(draftKey); onContextRefsChange?.([]); @@ -196,33 +195,11 @@ export function useSlashCommandConfig(): SlashCommandConfig { ); } -function tiptapJsonToText(json: any): string { - if (!json || typeof json !== "object") { - return ""; - } - - if (json.type === "text") { - return json.text || ""; - } - - if (json.type === "hardBreak") { - return "\n"; - } - - if (isMentionNode(json)) { - return mentionNodeToPlainText(json); - } - - if (json.content && Array.isArray(json.content)) { - return json.content.map(tiptapJsonToText).join(""); - } - - return ""; -} - -function extractContextRefsFromTiptapJson( - json: JSONContent | undefined, -): ContextRef[] { +export function serializeDraftMessage(json: JSONContent | undefined): { + text: string; + refs: ContextRef[]; +} { + const textParts: string[] = []; const refs: ContextRef[] = []; const seen = new Set(); @@ -231,7 +208,19 @@ function extractContextRefsFromTiptapJson( return; } + if (node.type === "text") { + textParts.push(node.text || ""); + return; + } + + if (node.type === "hardBreak") { + textParts.push("\n"); + return; + } + if (isMentionNode(node)) { + textParts.push(mentionNodeToPlainText(node)); + const mentionType = typeof node.attrs?.type === "string" ? node.attrs.type : null; const mentionId = @@ -246,21 +235,21 @@ function extractContextRefsFromTiptapJson( ref = { kind: "session", key: `session:manual:${mentionId}`, - source: "manual", + source: "draft", sessionId: mentionId, }; } else if (mentionType === "human") { ref = { kind: "human", key: `human:manual:${mentionId}`, - source: "manual", + source: "draft", humanId: mentionId, }; } else if (mentionType === "organization") { ref = { kind: "organization", key: `organization:manual:${mentionId}`, - source: "manual", + source: "draft", organizationId: mentionId, }; } @@ -269,6 +258,8 @@ function extractContextRefsFromTiptapJson( seen.add(ref.key); refs.push(ref); } + + return; } if (Array.isArray(node.content)) { @@ -279,7 +270,7 @@ function extractContextRefsFromTiptapJson( }; visit(json); - return refs; + return { text: textParts.join(""), refs }; } function isMentionNode( diff --git a/apps/desktop/src/chat/components/session-provider.tsx b/apps/desktop/src/chat/components/session-provider.tsx index b12fa34ede..ef9c8ec1d9 100644 --- a/apps/desktop/src/chat/components/session-provider.tsx +++ b/apps/desktop/src/chat/components/session-provider.tsx @@ -74,7 +74,6 @@ export function ChatSession({ const onRemoveContextEntity = useCallback((key: string) => { setPendingManualRefs((prev) => prev.filter((r) => r.key !== key)); - setPendingDraftRefs((prev) => prev.filter((r) => r.key !== key)); }, []); const onDraftContextRefsChange = useCallback((refs: ContextRef[]) => { diff --git a/apps/desktop/src/chat/context/entities.ts b/apps/desktop/src/chat/context/entities.ts index dc04037057..002c96861a 100644 --- a/apps/desktop/src/chat/context/entities.ts +++ b/apps/desktop/src/chat/context/entities.ts @@ -10,6 +10,7 @@ function isRecord(value: unknown): value is Record { export const CONTEXT_ENTITY_SOURCES = [ "tool", "manual", + "draft", "auto-current", ] as const; export type ContextEntitySource = (typeof CONTEXT_ENTITY_SOURCES)[number]; diff --git a/apps/desktop/src/chat/context/use-chat-context-pipeline.ts b/apps/desktop/src/chat/context/use-chat-context-pipeline.ts index e57f577679..8ca65ad1fc 100644 --- a/apps/desktop/src/chat/context/use-chat-context-pipeline.ts +++ b/apps/desktop/src/chat/context/use-chat-context-pipeline.ts @@ -147,7 +147,7 @@ export function useChatContextPipeline({ [committedRefs, store], ); - // Pending manual refs are removable; pending auto-current is not. + // Only explicit manual refs are removable; draft and auto-current refs are not. const pendingEntities = useMemo( () => pendingRefs.map((ref) => From eb9b877ff5c3a0ef48afe27cb887377789d72ca3 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 21:53:40 -0700 Subject: [PATCH 06/14] fix: route Cmd+N to a new chat when the chat composer is focused - send the global shortcut to the persistent chat new-session action when the composer has focus - centralize persistent chat session resets in shared chat state so the shortcut and header button stay in sync - add chat state coverage for starting and selecting chat sessions --- .../src/chat/components/chat-panel.tsx | 30 +++-------------- .../src/chat/components/input/index.tsx | 1 + .../src/chat/state/chat-context.test.ts | 33 +++++++++++++++++++ apps/desktop/src/chat/state/chat-context.ts | 8 +++++ apps/desktop/src/chat/state/use-chat-mode.ts | 6 ++++ apps/desktop/src/shared/main/index.tsx | 26 ++++++++++++++- 6 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/chat/state/chat-context.test.ts diff --git a/apps/desktop/src/chat/components/chat-panel.tsx b/apps/desktop/src/chat/components/chat-panel.tsx index ee39a2579d..055edcf2fc 100644 --- a/apps/desktop/src/chat/components/chat-panel.tsx +++ b/apps/desktop/src/chat/components/chat-panel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { cn } from "@hypr/utils"; @@ -11,28 +11,19 @@ import { useSessionTab } from "./use-session-tab"; import { useLanguageModel } from "~/ai/hooks"; import { useChatActions } from "~/chat/store/use-chat-actions"; import { useShell } from "~/contexts/shell"; -import { id } from "~/shared/utils"; import * as main from "~/store/tinybase/store/main"; export function ChatView() { const { chat } = useShell(); - const { groupId, setGroupId } = chat; + const { groupId, sessionId, setGroupId, startNewChat, selectChat } = chat; const { currentSessionId } = useSessionTab(); - // sessionId drives the ChatSession key and useChat id. - // It is managed explicitly — not derived from groupId — so that we can distinguish: - // handleNewChat: new random ID → fresh useChat instance - // handleSelectChat: set to groupId → forces ChatSession remount to load history - // onGroupCreated: groupId changes but sessionId stays stable → keeps useChat alive for the in-flight stream - const [sessionId, setSessionId] = useState(() => groupId ?? id()); - const model = useLanguageModel("chat"); const { user_id } = main.UI.useValues(main.STORE_ID); const handleGroupCreated = useCallback( (newGroupId: string) => { - // Don't update sessionId — keep current one so useChat stays alive for the in-flight stream setGroupId(newGroupId); }, [setGroupId], @@ -43,19 +34,6 @@ export function ChatView() { onGroupCreated: handleGroupCreated, }); - const handleNewChat = useCallback(() => { - setGroupId(undefined); - setSessionId(id()); - }, [setGroupId]); - - const handleSelectChat = useCallback( - (selectedGroupId: string) => { - setGroupId(selectedGroupId); - setSessionId(selectedGroupId); - }, - [setGroupId], - ); - return (
chat.sendEvent({ type: "CLOSE" })} /> {user_id && ( diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index 32f9d8832c..a266a5e07e 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -71,6 +71,7 @@ export function ChatMessageInput({ isRightPanel={chat.mode === "RightPanelOpen"} >
{ + beforeEach(() => { + useChatContext.setState({ + groupId: undefined, + sessionId: "session-initial", + }); + }); + + test("startNewChat resets the group and rotates the session id", () => { + useChatContext.setState({ + groupId: "group-1", + sessionId: "session-1", + }); + + useChatContext.getState().startNewChat(); + + const state = useChatContext.getState(); + expect(state.groupId).toBeUndefined(); + expect(state.sessionId).not.toBe("session-1"); + }); + + test("selectChat syncs the selected group and session id", () => { + useChatContext.getState().selectChat("group-2"); + + const state = useChatContext.getState(); + expect(state.groupId).toBe("group-2"); + expect(state.sessionId).toBe("group-2"); + }); +}); diff --git a/apps/desktop/src/chat/state/chat-context.ts b/apps/desktop/src/chat/state/chat-context.ts index 903a086bf3..74ab4ec3ca 100644 --- a/apps/desktop/src/chat/state/chat-context.ts +++ b/apps/desktop/src/chat/state/chat-context.ts @@ -1,16 +1,24 @@ import { create } from "zustand"; +import { id } from "~/shared/utils"; + interface ChatContextState { groupId: string | undefined; + sessionId: string; } interface ChatContextActions { setGroupId: (groupId: string | undefined) => void; + startNewChat: () => void; + selectChat: (groupId: string) => void; } export const useChatContext = create( (set) => ({ groupId: undefined, + sessionId: id(), setGroupId: (groupId) => set({ groupId }), + startNewChat: () => set({ groupId: undefined, sessionId: id() }), + selectChat: (groupId) => set({ groupId, sessionId: groupId }), }), ); diff --git a/apps/desktop/src/chat/state/use-chat-mode.ts b/apps/desktop/src/chat/state/use-chat-mode.ts index 9e1e52f4ab..97ad28e897 100644 --- a/apps/desktop/src/chat/state/use-chat-mode.ts +++ b/apps/desktop/src/chat/state/use-chat-mode.ts @@ -11,7 +11,10 @@ export function useChatMode() { const transitionChatMode = useTabs((state) => state.transitionChatMode); const groupId = useChatContext((state) => state.groupId); + const sessionId = useChatContext((state) => state.sessionId); const setGroupId = useChatContext((state) => state.setGroupId); + const startNewChat = useChatContext((state) => state.startNewChat); + const selectChat = useChatContext((state) => state.selectChat); useHotkeys( "mod+j", @@ -28,6 +31,9 @@ export function useChatMode() { mode, sendEvent: transitionChatMode, groupId, + sessionId, setGroupId, + startNewChat, + selectChat, }; } diff --git a/apps/desktop/src/shared/main/index.tsx b/apps/desktop/src/shared/main/index.tsx index 681ca89783..a256c68c3b 100644 --- a/apps/desktop/src/shared/main/index.tsx +++ b/apps/desktop/src/shared/main/index.tsx @@ -937,6 +937,11 @@ function useTabsShortcuts() { useHotkeys( "mod+n", () => { + if (isPersistentChatInputFocused(chat.mode)) { + chat.startNewChat(); + return; + } + if (currentTab?.type === "empty") { newNoteCurrent(); } else { @@ -948,7 +953,7 @@ function useTabsShortcuts() { enableOnFormTags: true, enableOnContentEditable: true, }, - [currentTab, newNote, newNoteCurrent], + [chat, currentTab, newNote, newNoteCurrent], ); useHotkeys( @@ -1129,3 +1134,22 @@ function useNewEmptyTab() { return handler; } + +function isPersistentChatInputFocused( + mode: ReturnType["chat"]["mode"], +) { + if (mode !== "FloatingOpen" && mode !== "RightPanelOpen") { + return false; + } + + if (typeof document === "undefined") { + return false; + } + + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) { + return false; + } + + return activeElement.closest("[data-chat-message-input]") !== null; +} From d47abb338fb5d3218b5364b6c9b365d5d1c546b4 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 21:57:34 -0700 Subject: [PATCH 07/14] fix: render sent user mentions like composer chips - persist mention labels on draft context refs so sent messages keep the typed chip text - render user message mentions as inline clickable chips using the composer mention styling - add coverage for mention serialization and user-message tokenization --- .../src/chat/components/input/hooks.test.ts | 2 + .../src/chat/components/input/hooks.ts | 12 + .../src/chat/components/message/normal.tsx | 9 +- .../chat/components/message/user-text.test.ts | 112 ++++++++ .../src/chat/components/message/user-text.tsx | 269 ++++++++++++++++++ apps/desktop/src/chat/context/entities.ts | 1 + apps/desktop/src/chat/context/refs.ts | 4 + 7 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/chat/components/message/user-text.test.ts create mode 100644 apps/desktop/src/chat/components/message/user-text.tsx diff --git a/apps/desktop/src/chat/components/input/hooks.test.ts b/apps/desktop/src/chat/components/input/hooks.test.ts index cd0c365fd8..f464da972e 100644 --- a/apps/desktop/src/chat/components/input/hooks.test.ts +++ b/apps/desktop/src/chat/components/input/hooks.test.ts @@ -35,6 +35,7 @@ describe("serializeDraftMessage", () => { { kind: "human", key: "human:manual:human-1", + label: "john", source: "draft", humanId: "human-1", }, @@ -80,6 +81,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/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index caaf3d2e25..2b9fb3102c 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -235,6 +235,10 @@ export function serializeDraftMessage(json: JSONContent | undefined): { ref = { kind: "session", key: `session:manual:${mentionId}`, + label: + typeof node.attrs?.label === "string" + ? node.attrs.label + : undefined, source: "draft", sessionId: mentionId, }; @@ -242,6 +246,10 @@ export function serializeDraftMessage(json: JSONContent | undefined): { ref = { kind: "human", key: `human:manual:${mentionId}`, + label: + typeof node.attrs?.label === "string" + ? node.attrs.label + : undefined, source: "draft", humanId: mentionId, }; @@ -249,6 +257,10 @@ export function serializeDraftMessage(json: JSONContent | undefined): { ref = { kind: "organization", key: `organization:manual:${mentionId}`, + label: + typeof node.attrs?.label === "string" + ? node.attrs.label + : undefined, source: "draft", organizationId: mentionId, }; diff --git a/apps/desktop/src/chat/components/message/normal.tsx b/apps/desktop/src/chat/components/message/normal.tsx index 3a4695281a..4e582177a8 100644 --- a/apps/desktop/src/chat/components/message/normal.tsx +++ b/apps/desktop/src/chat/components/message/normal.tsx @@ -7,6 +7,7 @@ import { cn } from "@hypr/utils"; import { Disclosure, MessageBubble, MessageContainer } from "./shared"; import { Tool } from "./tool"; import type { Part } from "./types"; +import { UserMessageText } from "./user-text"; import { hasRenderableContent } from "~/chat/components/shared"; import type { HyprUIMessage } from "~/chat/types"; @@ -69,9 +70,11 @@ export function NormalMessage({ ])} > - {message.parts.map((part, i) => ( - - ))} + {isUser ? ( + + ) : ( + message.parts.map((part, i) => ) + )} {!isUser && (
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/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)) From ecd00d05514fd523dd0c88ff72eb33499c5ae9b7 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 22:03:30 -0700 Subject: [PATCH 08/14] fix: derive chat draft context without effect sync - move draft serialization and storage helpers into a shared input draft module - initialize pending draft refs from stored draft state instead of syncing them in useEffect - keep chat mention and draft tests passing with the new draft initialization flow --- .../src/chat/components/input/draft.ts | 132 +++++++++++++++++ .../src/chat/components/input/hooks.test.ts | 2 +- .../src/chat/components/input/hooks.ts | 135 ++---------------- .../src/chat/components/session-provider.tsx | 5 +- 4 files changed, 151 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src/chat/components/input/draft.ts diff --git a/apps/desktop/src/chat/components/input/draft.ts b/apps/desktop/src/chat/components/input/draft.ts new file mode 100644 index 0000000000..ca7fb42cc6 --- /dev/null +++ b/apps/desktop/src/chat/components/input/draft.ts @@ -0,0 +1,132 @@ +import type { JSONContent } from "@hypr/tiptap/chat"; +import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared"; + +import type { ContextRef } from "~/chat/context/entities"; + +const draftsByKey = new Map(); + +export function getDraftContent(draftKey: string): JSONContent { + return draftsByKey.get(draftKey) ?? EMPTY_TIPTAP_DOC; +} + +export function setDraftContent(draftKey: string, content: JSONContent) { + draftsByKey.set(draftKey, content); +} + +export function clearDraftContent(draftKey: string) { + draftsByKey.delete(draftKey); +} + +export function serializeDraftMessage(json: JSONContent | undefined): { + text: string; + refs: ContextRef[]; +} { + const textParts: string[] = []; + const refs: ContextRef[] = []; + const seen = new Set(); + + const visit = (node: JSONContent | undefined) => { + if (!node || typeof node !== "object") { + return; + } + + if (node.type === "text") { + textParts.push(node.text || ""); + return; + } + + if (node.type === "hardBreak") { + textParts.push("\n"); + return; + } + + if (isMentionNode(node)) { + textParts.push(mentionNodeToPlainText(node)); + + const mentionType = + typeof node.attrs?.type === "string" ? node.attrs.type : null; + const mentionId = + typeof node.attrs?.id === "string" ? node.attrs.id : null; + + if (!mentionType || !mentionId) { + return; + } + + let ref: ContextRef | null = null; + if (mentionType === "session") { + ref = { + kind: "session", + key: `session:manual:${mentionId}`, + label: + typeof node.attrs?.label === "string" + ? node.attrs.label + : undefined, + source: "draft", + sessionId: mentionId, + }; + } else if (mentionType === "human") { + ref = { + kind: "human", + key: `human:manual:${mentionId}`, + label: + typeof node.attrs?.label === "string" + ? node.attrs.label + : undefined, + source: "draft", + humanId: mentionId, + }; + } else if (mentionType === "organization") { + ref = { + kind: "organization", + key: `organization:manual:${mentionId}`, + label: + typeof node.attrs?.label === "string" + ? node.attrs.label + : undefined, + source: "draft", + organizationId: mentionId, + }; + } + + if (ref && !seen.has(ref.key)) { + seen.add(ref.key); + refs.push(ref); + } + + return; + } + + if (Array.isArray(node.content)) { + for (const child of node.content) { + visit(child); + } + } + }; + + visit(json); + return { text: textParts.join(""), refs }; +} + +export function getDraftContextRefs(draftKey: string): ContextRef[] { + return serializeDraftMessage(getDraftContent(draftKey)).refs; +} + +function isMentionNode( + node: Pick | Record, +): boolean { + return ( + typeof node.type === "string" && + (node.type === "mention" || node.type.startsWith("mention-")) + ); +} + +function mentionNodeToPlainText(node: JSONContent): string { + const label = + typeof node.attrs?.label === "string" && node.attrs.label.trim() + ? node.attrs.label.trim() + : typeof node.attrs?.id === "string" && node.attrs.id.trim() + ? node.attrs.id.trim() + : ""; + + return label ? `@${label}` : ""; +} diff --git a/apps/desktop/src/chat/components/input/hooks.test.ts b/apps/desktop/src/chat/components/input/hooks.test.ts index f464da972e..08b692f8ba 100644 --- a/apps/desktop/src/chat/components/input/hooks.test.ts +++ b/apps/desktop/src/chat/components/input/hooks.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import type { JSONContent } from "@hypr/tiptap/chat"; -import { serializeDraftMessage } from "./hooks"; +import { serializeDraftMessage } from "./draft"; describe("serializeDraftMessage", () => { test("serializes mention labels into plain text and draft refs", () => { diff --git a/apps/desktop/src/chat/components/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index 2b9fb3102c..c0431dad79 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -6,14 +6,18 @@ import type { SlashCommandConfig, TiptapEditor, } from "@hypr/tiptap/chat"; -import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared"; + +import { + clearDraftContent, + getDraftContent, + serializeDraftMessage, + setDraftContent, +} from "./draft"; import type { ContextRef } from "~/chat/context/entities"; import { useSearchEngine } from "~/search/contexts/engine"; import * as main from "~/store/tinybase/store/main"; -const draftsByKey = new Map(); - export function useDraftState({ draftKey, onContextRefsChange, @@ -21,19 +25,18 @@ export function useDraftState({ draftKey: string; onContextRefsChange?: (refs: ContextRef[]) => void; }) { - const [hasContent, setHasContent] = useState(false); - const initialContent = useRef(draftsByKey.get(draftKey) ?? EMPTY_TIPTAP_DOC); - - useEffect(() => { - onContextRefsChange?.(serializeDraftMessage(initialContent.current).refs); - }, [onContextRefsChange]); + const initialContent = useRef(getDraftContent(draftKey)); + const initialDraft = useRef(serializeDraftMessage(initialContent.current)); + const [hasContent, setHasContent] = useState( + initialDraft.current.text.trim().length > 0, + ); const handleEditorUpdate = useCallback( (json: JSONContent) => { const draft = serializeDraftMessage(json); const text = draft.text.trim(); setHasContent(text.length > 0); - draftsByKey.set(draftKey, json); + setDraftContent(draftKey, json); onContextRefsChange?.(draft.refs); }, [draftKey, onContextRefsChange], @@ -77,7 +80,7 @@ export function useSubmit({ void analyticsCommands.event({ event: "message_sent" }); onSendMessage(text, [{ type: "text", text }], draft.refs); editorRef.current?.editor?.commands.clearContent(); - draftsByKey.delete(draftKey); + clearDraftContent(draftKey); onContextRefsChange?.([]); }, [ draftKey, @@ -194,113 +197,3 @@ export function useSlashCommandConfig(): SlashCommandConfig { [sessions, humans, organizations, search], ); } - -export function serializeDraftMessage(json: JSONContent | undefined): { - text: string; - refs: ContextRef[]; -} { - const textParts: string[] = []; - const refs: ContextRef[] = []; - const seen = new Set(); - - const visit = (node: JSONContent | undefined) => { - if (!node || typeof node !== "object") { - return; - } - - if (node.type === "text") { - textParts.push(node.text || ""); - return; - } - - if (node.type === "hardBreak") { - textParts.push("\n"); - return; - } - - if (isMentionNode(node)) { - textParts.push(mentionNodeToPlainText(node)); - - const mentionType = - typeof node.attrs?.type === "string" ? node.attrs.type : null; - const mentionId = - typeof node.attrs?.id === "string" ? node.attrs.id : null; - - if (!mentionType || !mentionId) { - return; - } - - let ref: ContextRef | null = null; - if (mentionType === "session") { - ref = { - kind: "session", - key: `session:manual:${mentionId}`, - label: - typeof node.attrs?.label === "string" - ? node.attrs.label - : undefined, - source: "draft", - sessionId: mentionId, - }; - } else if (mentionType === "human") { - ref = { - kind: "human", - key: `human:manual:${mentionId}`, - label: - typeof node.attrs?.label === "string" - ? node.attrs.label - : undefined, - source: "draft", - humanId: mentionId, - }; - } else if (mentionType === "organization") { - ref = { - kind: "organization", - key: `organization:manual:${mentionId}`, - label: - typeof node.attrs?.label === "string" - ? node.attrs.label - : undefined, - source: "draft", - organizationId: mentionId, - }; - } - - if (ref && !seen.has(ref.key)) { - seen.add(ref.key); - refs.push(ref); - } - - return; - } - - if (Array.isArray(node.content)) { - for (const child of node.content) { - visit(child); - } - } - }; - - visit(json); - return { text: textParts.join(""), refs }; -} - -function isMentionNode( - node: Pick | Record, -): boolean { - return ( - typeof node.type === "string" && - (node.type === "mention" || node.type.startsWith("mention-")) - ); -} - -function mentionNodeToPlainText(node: JSONContent): string { - const label = - typeof node.attrs?.label === "string" && node.attrs.label.trim() - ? node.attrs.label.trim() - : typeof node.attrs?.id === "string" && node.attrs.id.trim() - ? node.attrs.id.trim() - : ""; - - return label ? `@${label}` : ""; -} diff --git a/apps/desktop/src/chat/components/session-provider.tsx b/apps/desktop/src/chat/components/session-provider.tsx index ef9c8ec1d9..7b0718ba19 100644 --- a/apps/desktop/src/chat/components/session-provider.tsx +++ b/apps/desktop/src/chat/components/session-provider.tsx @@ -9,6 +9,7 @@ import { useState, } from "react"; +import { getDraftContextRefs } from "~/chat/components/input/draft"; import { dedupeByKey, type ContextRef } from "~/chat/context/entities"; import { type DisplayEntity, @@ -64,7 +65,9 @@ export function ChatSession({ const { user_id } = main.UI.useValues(main.STORE_ID); const [pendingManualRefs, setPendingManualRefs] = useState([]); - const [pendingDraftRefs, setPendingDraftRefs] = useState([]); + const [pendingDraftRefs, setPendingDraftRefs] = useState(() => + getDraftContextRefs(sessionId), + ); const onAddContextEntity = useCallback((ref: ContextRef) => { setPendingManualRefs((prev) => From 0562991c81d7740b268ad15e491cefb824f132ef Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 22:04:20 -0700 Subject: [PATCH 09/14] refactor: trim chat draft context plumbing - remove the one-off draft context helper and initialize draft refs inline where they are consumed - simplify draft state initialization by deriving initial content directly from stored draft data - keep the live draft context callback flow intact for context bar updates without effect sync --- apps/desktop/src/chat/components/input/draft.ts | 4 ---- apps/desktop/src/chat/components/input/hooks.ts | 7 +++---- apps/desktop/src/chat/components/session-provider.tsx | 9 ++++++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/chat/components/input/draft.ts b/apps/desktop/src/chat/components/input/draft.ts index ca7fb42cc6..5da7058907 100644 --- a/apps/desktop/src/chat/components/input/draft.ts +++ b/apps/desktop/src/chat/components/input/draft.ts @@ -107,10 +107,6 @@ export function serializeDraftMessage(json: JSONContent | undefined): { return { text: textParts.join(""), refs }; } -export function getDraftContextRefs(draftKey: string): ContextRef[] { - return serializeDraftMessage(getDraftContent(draftKey)).refs; -} - function isMentionNode( node: Pick | Record, ): boolean { diff --git a/apps/desktop/src/chat/components/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index c0431dad79..34f2ac6e04 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -26,10 +26,9 @@ export function useDraftState({ onContextRefsChange?: (refs: ContextRef[]) => void; }) { const initialContent = useRef(getDraftContent(draftKey)); - const initialDraft = useRef(serializeDraftMessage(initialContent.current)); - const [hasContent, setHasContent] = useState( - initialDraft.current.text.trim().length > 0, - ); + const [hasContent, setHasContent] = useState(() => { + return serializeDraftMessage(initialContent.current).text.trim().length > 0; + }); const handleEditorUpdate = useCallback( (json: JSONContent) => { diff --git a/apps/desktop/src/chat/components/session-provider.tsx b/apps/desktop/src/chat/components/session-provider.tsx index 7b0718ba19..1d9f351c30 100644 --- a/apps/desktop/src/chat/components/session-provider.tsx +++ b/apps/desktop/src/chat/components/session-provider.tsx @@ -9,7 +9,10 @@ import { useState, } from "react"; -import { getDraftContextRefs } from "~/chat/components/input/draft"; +import { + getDraftContent, + serializeDraftMessage, +} from "~/chat/components/input/draft"; import { dedupeByKey, type ContextRef } from "~/chat/context/entities"; import { type DisplayEntity, @@ -65,8 +68,8 @@ export function ChatSession({ const { user_id } = main.UI.useValues(main.STORE_ID); const [pendingManualRefs, setPendingManualRefs] = useState([]); - const [pendingDraftRefs, setPendingDraftRefs] = useState(() => - getDraftContextRefs(sessionId), + const [pendingDraftRefs, setPendingDraftRefs] = useState( + () => serializeDraftMessage(getDraftContent(sessionId)).refs, ); const onAddContextEntity = useCallback((ref: ContextRef) => { From 3f11c216221e23d3e650a9a4451bac45a7e6bddd Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 22:06:12 -0700 Subject: [PATCH 10/14] fix: adjust chat input border radius for right panel --- apps/desktop/src/chat/components/input/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index a266a5e07e..b0790f9c5d 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -156,7 +156,9 @@ function Container({ className={cn([ "flex max-h-full flex-col border border-neutral-200 bg-white", isRightPanel - ? "rounded-t-xl rounded-b-none" + ? hasContextBar + ? "rounded-t-none rounded-b-none" + : "rounded-t-xl rounded-b-none" : hasContextBar ? "rounded-t-none rounded-b-xl" : "rounded-xl", From cd5f4f0c25b2b0a9c16ec1e25b34b6a12ec6eab0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 24 Mar 2026 22:07:27 -0700 Subject: [PATCH 11/14] fix: handle empty reasoning text in message component --- apps/desktop/src/chat/components/message/normal.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/chat/components/message/normal.tsx b/apps/desktop/src/chat/components/message/normal.tsx index 4e582177a8..334e62bd89 100644 --- a/apps/desktop/src/chat/components/message/normal.tsx +++ b/apps/desktop/src/chat/components/message/normal.tsx @@ -116,7 +116,13 @@ function Part({ part }: { part: Part }) { } function Reasoning({ part }: { part: Extract }) { - const cleaned = part.text + const raw = part.text.trim(); + + if (!raw) { + return null; + } + + const cleaned = raw .replace(/[\n`*#"]/g, " ") .replace(/\s+/g, " ") .trim(); @@ -124,6 +130,10 @@ function Reasoning({ part }: { part: Extract }) { const streaming = part.state !== "done"; const title = streaming ? cleaned.slice(-150) : cleaned; + if (!title) { + return null; + } + return ( } From 360a04dae0eeb1ffe06d76ce2a12e32a0c18801b Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Wed, 25 Mar 2026 17:27:53 -0700 Subject: [PATCH 12/14] fix: suppress blank reasoning-only chat rows --- .../src/chat/components/shared.test.ts | 25 +++++++++++++++++++ apps/desktop/src/chat/components/shared.ts | 12 ++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/chat/components/shared.test.ts diff --git a/apps/desktop/src/chat/components/shared.test.ts b/apps/desktop/src/chat/components/shared.test.ts new file mode 100644 index 0000000000..e955e2b1d6 --- /dev/null +++ b/apps/desktop/src/chat/components/shared.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { hasRenderableContent } from "./shared"; + +describe("hasRenderableContent", () => { + test("returns false for blank reasoning-only messages", () => { + expect( + hasRenderableContent({ + id: "message-1", + role: "assistant", + parts: [{ type: "reasoning", text: " ", state: "done" }], + }), + ).toBe(false); + }); + + test("returns true for non-empty reasoning messages", () => { + expect( + hasRenderableContent({ + id: "message-2", + role: "assistant", + parts: [{ type: "reasoning", text: "Thinking", state: "done" }], + }), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/chat/components/shared.ts b/apps/desktop/src/chat/components/shared.ts index 274c1dffbf..9eac22ff04 100644 --- a/apps/desktop/src/chat/components/shared.ts +++ b/apps/desktop/src/chat/components/shared.ts @@ -1,5 +1,15 @@ import type { HyprUIMessage } from "~/chat/types"; export function hasRenderableContent(message: HyprUIMessage): boolean { - return message.parts.some((part) => part.type !== "step-start"); + return message.parts.some((part) => { + if (part.type === "step-start") { + return false; + } + + if (part.type === "reasoning") { + return part.text.trim().length > 0; + } + + return true; + }); } From 71ecedfa1ad400c9b53a48b14d5491dc6ae967b0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 09:36:10 -0700 Subject: [PATCH 13/14] fix: preserve restored draft context refs --- .../chat/components/session-provider.test.tsx | 112 ++++++++++++++++++ .../src/chat/components/session-provider.tsx | 5 - 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/chat/components/session-provider.test.tsx diff --git a/apps/desktop/src/chat/components/session-provider.test.tsx b/apps/desktop/src/chat/components/session-provider.test.tsx new file mode 100644 index 0000000000..7027e2fe33 --- /dev/null +++ b/apps/desktop/src/chat/components/session-provider.test.tsx @@ -0,0 +1,112 @@ +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { clearDraftContent, setDraftContent } from "./input/draft"; +import { ChatSession } from "./session-provider"; + +const { + useChatMock, + useTransportMock, + useChatContextPipelineMock, + useStoreMock, + useValuesMock, +} = vi.hoisted(() => ({ + useChatMock: vi.fn(), + useTransportMock: vi.fn(), + useChatContextPipelineMock: vi.fn(), + useStoreMock: vi.fn(), + useValuesMock: vi.fn(), +})); + +vi.mock("@ai-sdk/react", () => ({ + useChat: useChatMock, +})); + +vi.mock("~/chat/transport/use-transport", () => ({ + useTransport: useTransportMock, +})); + +vi.mock("~/chat/context/use-chat-context-pipeline", () => ({ + useChatContextPipeline: useChatContextPipelineMock, +})); + +vi.mock("~/store/tinybase/store/main", () => ({ + STORE_ID: "test-store", + UI: { + useStore: useStoreMock, + useValues: useValuesMock, + }, +})); + +describe("ChatSession", () => { + beforeEach(() => { + useStoreMock.mockReturnValue(null); + useValuesMock.mockReturnValue({ user_id: "user-1" }); + useTransportMock.mockReturnValue({ + transport: null, + isSystemPromptReady: true, + }); + useChatMock.mockReturnValue({ + messages: [], + sendMessage: vi.fn(), + regenerate: vi.fn(), + stop: vi.fn(), + status: "ready", + error: undefined, + setMessages: vi.fn(), + }); + useChatContextPipelineMock.mockImplementation( + ({ pendingManualRefs }: { pendingManualRefs: unknown[] }) => ({ + contextEntities: [], + pendingRefs: pendingManualRefs, + }), + ); + }); + + afterEach(() => { + clearDraftContent("draft-session"); + vi.clearAllMocks(); + }); + + test("keeps restored draft refs after mount effects run", async () => { + setDraftContent("draft-session", { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "mention", + attrs: { + id: "human-1", + type: "human", + label: "John", + }, + }, + ], + }, + ], + }); + + const child = vi.fn(() => null); + + render( + + {(props) => child(props)} + , + ); + + await act(async () => {}); + + expect(child).toHaveBeenCalled(); + expect(child.mock.lastCall?.[0].pendingRefs).toEqual([ + { + kind: "human", + key: "human:manual:human-1", + label: "John", + source: "draft", + humanId: "human-1", + }, + ]); + }); +}); diff --git a/apps/desktop/src/chat/components/session-provider.tsx b/apps/desktop/src/chat/components/session-provider.tsx index 1d9f351c30..ecce27ff9f 100644 --- a/apps/desktop/src/chat/components/session-provider.tsx +++ b/apps/desktop/src/chat/components/session-provider.tsx @@ -86,11 +86,6 @@ export function ChatSession({ setPendingDraftRefs(refs); }, []); - useEffect(() => { - setPendingManualRefs([]); - setPendingDraftRefs([]); - }, [sessionId, chatGroupId]); - const { transport, isSystemPromptReady } = useTransport( modelOverride, extraTools, From c3e5a4ea5431713cc3461657ec78667ee1c16be0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 09:37:39 -0700 Subject: [PATCH 14/14] fix: restore user message markdown rendering --- .../chat/components/message/normal.test.tsx | 24 +++++++++++++++++++ .../src/chat/components/message/normal.tsx | 9 +++---- .../chat/components/session-provider.test.tsx | 10 ++++---- 3 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/chat/components/message/normal.test.tsx 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/normal.tsx b/apps/desktop/src/chat/components/message/normal.tsx index 334e62bd89..a4bc42988c 100644 --- a/apps/desktop/src/chat/components/message/normal.tsx +++ b/apps/desktop/src/chat/components/message/normal.tsx @@ -7,7 +7,6 @@ import { cn } from "@hypr/utils"; import { Disclosure, MessageBubble, MessageContainer } from "./shared"; import { Tool } from "./tool"; import type { Part } from "./types"; -import { UserMessageText } from "./user-text"; import { hasRenderableContent } from "~/chat/components/shared"; import type { HyprUIMessage } from "~/chat/types"; @@ -70,11 +69,9 @@ export function NormalMessage({ ])} > - {isUser ? ( - - ) : ( - message.parts.map((part, i) => ) - )} + {message.parts.map((part, i) => ( + + ))} {!isUser && (
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",