diff --git a/apps/desktop/src/components/chat/context-bar.tsx b/apps/desktop/src/components/chat/context-bar.tsx new file mode 100644 index 0000000000..299780f28a --- /dev/null +++ b/apps/desktop/src/components/chat/context-bar.tsx @@ -0,0 +1,66 @@ +import { X } from "lucide-react"; + +import { cn } from "@hypr/utils"; + +import { useShell } from "../../contexts/shell"; +import type { ContextRef } from "../../contexts/shell/chat"; +import { useSession } from "../../hooks/tinybase"; + +export function ContextBar() { + const { chat } = useShell(); + + if (chat.refs.length === 0) { + return null; + } + + return ( +
+ {chat.refs.map((ref) => ( + chat.removeRef(ref.id)} + /> + ))} +
+ ); +} + +function ContextChip({ + contextRef, + onRemove, +}: { + contextRef: ContextRef; + onRemove: () => void; +}) { + const title = useRefTitle(contextRef); + + return ( +
+ {title} + +
+ ); +} + +function useRefTitle(ref: ContextRef): string { + const { title } = useSession(ref.type === "session" ? ref.id : ""); + + if (ref.type === "session") { + return (title as string) || "Untitled Session"; + } + + return ref.id; +} diff --git a/apps/desktop/src/components/chat/input.tsx b/apps/desktop/src/components/chat/input.tsx index ebf7642ec5..124a75a18d 100644 --- a/apps/desktop/src/components/chat/input.tsx +++ b/apps/desktop/src/components/chat/input.tsx @@ -23,13 +23,11 @@ import * as main from "../../store/tinybase/store/main"; export function ChatMessageInput({ onSendMessage, disabled: disabledProp, - attachedSession, isStreaming, onStop, }: { onSendMessage: (content: string, parts: any[]) => void; disabled?: boolean | { disabled: boolean; message?: string }; - attachedSession?: { id: string; title?: string }; isStreaming?: boolean; onStop?: () => void; }) { @@ -148,23 +146,24 @@ export function ChatMessageInput({ id: rowId, type: "session", label: title, + content: "", }); } }); return results.slice(0, 5); }, + onSelect: (item) => { + if (item.type === "session") { + chat.addRef({ type: "session", id: item.id, source: "manual" }); + } + }, }), - [chatShortcuts, sessions], + [chatShortcuts, sessions, chat], ); return ( - {attachedSession && ( -
- Attached: {attachedSession.title || "Untitled"} -
- )}
void; @@ -38,10 +39,10 @@ interface ChatSessionProps { export function ChatSession({ sessionId, chatGroupId, - attachedSessionId, + contextRefs, children, }: ChatSessionProps) { - const transport = useTransport(attachedSessionId); + const transport = useTransport(contextRefs); const store = main.UI.useStore(main.STORE_ID); const { user_id } = main.UI.useValues(main.STORE_ID); @@ -179,95 +180,92 @@ export function ChatSession({ ); } -function useTransport(attachedSessionId?: string) { +function useTransport(contextRefs: ContextRef[]) { const registry = useToolRegistry(); const model = useLanguageModel(); const store = main.UI.useStore(main.STORE_ID); + const indexes = main.UI.useIndexes(main.STORE_ID); const language = main.UI.useValue("ai_language", main.STORE_ID) ?? "en"; const [systemPrompt, setSystemPrompt] = useState(); - const { title, rawMd, createdAt } = useSession(attachedSessionId ?? ""); - - const enhancedNoteIds = main.UI.useSliceRowIds( - main.INDEXES.enhancedNotesBySession, - attachedSessionId ?? "", - main.STORE_ID, - ); - const firstEnhancedNoteId = enhancedNoteIds?.[0]; - const enhancedContent = main.UI.useCell( - "enhanced_notes", - firstEnhancedNoteId ?? "", - "content", - main.STORE_ID, - ); - - const transcriptIds = main.UI.useSliceRowIds( - main.INDEXES.transcriptBySession, - attachedSessionId ?? "", - main.STORE_ID, + const sessionRefs = useMemo( + () => contextRefs.filter((ref) => ref.type === "session"), + [contextRefs], ); - const firstTranscriptId = transcriptIds?.[0]; - - const wordIds = main.UI.useSliceRowIds( - main.INDEXES.wordsByTranscript, - firstTranscriptId ?? "", - main.STORE_ID, - ); - - const words = useMemo((): WordLike[] => { - if (!store || !wordIds || wordIds.length === 0) { - return []; - } - - const result: WordLike[] = []; - for (const wordId of wordIds) { - const row = store.getRow("words", wordId); - if (row) { - result.push({ - text: row.text as string, - start_ms: row.start_ms as number, - end_ms: row.end_ms as number, - channel: row.channel as WordLike["channel"], - }); - } + const chatContext = useMemo((): ChatContext | null => { + if (sessionRefs.length === 0 || !store || !indexes) { + return null; } - return result.sort((a, b) => a.start_ms - b.start_ms); - }, [store, wordIds]); - - const transcript = useMemo((): Transcript | null => { - if (words.length === 0 || !store) { + const firstSessionRef = sessionRefs[0]; + const session = store.getRow("sessions", firstSessionRef.id); + if (!session) { return null; } - const segments = buildSegments(words, [], []); - const ctx = defaultRenderLabelContext(store); - const manager = SpeakerLabelManager.fromSegments(segments, ctx); - - return { - segments: segments.map((seg) => ({ - speaker: SegmentKey.renderLabel(seg.key, ctx, manager), - text: seg.words.map((w) => w.text).join(" "), - })), - startedAt: null, - endedAt: null, - }; - }, [words, store]); + const enhancedNoteIds = indexes.getSliceRowIds( + main.INDEXES.enhancedNotesBySession, + firstSessionRef.id, + ); + const firstEnhancedNoteId = enhancedNoteIds?.[0]; + const enhancedNote = firstEnhancedNoteId + ? store.getRow("enhanced_notes", firstEnhancedNoteId) + : null; + + const transcriptIds = indexes.getSliceRowIds( + main.INDEXES.transcriptBySession, + firstSessionRef.id, + ); + const firstTranscriptId = transcriptIds?.[0]; + + let transcript: Transcript | null = null; + if (firstTranscriptId) { + const wordIds = indexes.getSliceRowIds( + main.INDEXES.wordsByTranscript, + firstTranscriptId, + ); + + if (wordIds && wordIds.length > 0) { + const words: WordLike[] = []; + for (const wordId of wordIds) { + const row = store.getRow("words", wordId); + if (row) { + words.push({ + text: row.text as string, + start_ms: row.start_ms as number, + end_ms: row.end_ms as number, + channel: row.channel as WordLike["channel"], + }); + } + } - const chatContext = useMemo(() => { - if (!attachedSessionId) { - return null; + if (words.length > 0) { + words.sort((a, b) => a.start_ms - b.start_ms); + const segments = buildSegments(words, [], []); + const ctx = defaultRenderLabelContext(store); + const manager = SpeakerLabelManager.fromSegments(segments, ctx); + + transcript = { + segments: segments.map((seg) => ({ + speaker: SegmentKey.renderLabel(seg.key, ctx, manager), + text: seg.words.map((w) => w.text).join(" "), + })), + startedAt: null, + endedAt: null, + }; + } + } } return { - title: (title as string) || null, - date: (createdAt as string) || null, - rawContent: (rawMd as string) || null, - enhancedContent: (enhancedContent as string) || null, + title: (session.title as string) || null, + date: (session.created_at as string) || null, + rawContent: (session.raw_md as string) || null, + enhancedContent: (enhancedNote?.content as string) || null, transcript, }; - }, [attachedSessionId, title, rawMd, enhancedContent, createdAt, transcript]); + }, [sessionRefs, store, indexes]); useEffect(() => { templateCommands diff --git a/apps/desktop/src/components/chat/view.tsx b/apps/desktop/src/components/chat/view.tsx index 6298367c22..4dc40aa5e3 100644 --- a/apps/desktop/src/components/chat/view.tsx +++ b/apps/desktop/src/components/chat/view.tsx @@ -1,28 +1,39 @@ -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { HyprUIMessage } from "../../chat/types"; import { useShell } from "../../contexts/shell"; -import { useSession } from "../../hooks/tinybase"; import { useLanguageModel } from "../../hooks/useLLMConnection"; import * as main from "../../store/tinybase/store/main"; import { useTabs } from "../../store/zustand/tabs"; import { id } from "../../utils"; import { ChatBody } from "./body"; +import { ContextBar } from "./context-bar"; import { ChatHeader } from "./header"; import { ChatMessageInput } from "./input"; import { ChatSession } from "./session"; export function ChatView() { const { chat } = useShell(); - const { groupId, setGroupId } = chat; - const { currentTab } = useTabs(); - - const attachedSessionId = - currentTab?.type === "sessions" ? currentTab.id : undefined; + const { groupId, setGroupId, refs, addRef, removeRef } = chat; + const { currentTab, tabs } = useTabs(); const stableSessionId = useStableSessionId(groupId); const model = useLanguageModel(); + useEffect(() => { + if (currentTab?.type === "sessions") { + addRef({ type: "session", id: currentTab.id, source: "auto" }); + } + + const sessionTabIds = new Set( + tabs.filter((t) => t.type === "sessions").map((t) => t.id), + ); + + refs + .filter((r) => r.source === "auto" && !sessionTabIds.has(r.id)) + .forEach((r) => removeRef(r.id)); + }, [currentTab, tabs, refs, addRef, removeRef]); + const { user_id } = main.UI.useValues(main.STORE_ID); const createChatGroup = main.UI.useSetRowCallback( @@ -127,7 +138,7 @@ export function ChatView() { key={stableSessionId} sessionId={stableSessionId} chatGroupId={groupId} - attachedSessionId={attachedSessionId} + contextRefs={refs} > {({ messages, sendMessage, regenerate, stop, status, error }) => ( )} @@ -156,7 +166,6 @@ function ChatViewContent({ error, model, handleSendMessage, - attachedSessionId, }: { messages: HyprUIMessage[]; sendMessage: (message: HyprUIMessage) => void; @@ -170,15 +179,7 @@ function ChatViewContent({ parts: any[], sendMessage: (message: HyprUIMessage) => void, ) => void; - attachedSessionId?: string; }) { - const { title } = useSession(attachedSessionId ?? ""); - - const attachedSession = useMemo(() => { - if (!attachedSessionId) return undefined; - return { id: attachedSessionId, title: (title as string) || undefined }; - }, [attachedSessionId, title]); - return ( <> + handleSendMessage(content, parts, sendMessage) } - attachedSession={attachedSession} isStreaming={status === "streaming" || status === "submitted"} onStop={stop} /> diff --git a/apps/desktop/src/contexts/shell/chat.ts b/apps/desktop/src/contexts/shell/chat.ts index 1718fb7848..7ad354149f 100644 --- a/apps/desktop/src/contexts/shell/chat.ts +++ b/apps/desktop/src/contexts/shell/chat.ts @@ -2,6 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { createActor, fromTransition } from "xstate"; +export type ContextRefType = "session" | "human" | "organization"; +export type ContextRefSource = "auto" | "manual"; + +export type ContextRef = { + type: ContextRefType; + id: string; + source: ContextRefSource; +}; + export type ChatMode = "RightPanelOpen" | "FloatingClosed" | "FloatingOpen"; export type ChatEvent = | { type: "OPEN" } @@ -44,6 +53,22 @@ export function useChatMode() { const [mode, setMode] = useState("FloatingClosed"); const [groupId, setGroupId] = useState(undefined); const [draftMessage, setDraftMessage] = useState(undefined); + const [refs, setRefs] = useState([]); + + const addRef = useCallback((ref: ContextRef) => { + setRefs((prev) => { + if (prev.some((r) => r.type === ref.type && r.id === ref.id)) { + return prev; + } + return [...prev, ref]; + }); + }, []); + + const removeRef = useCallback((id: string) => { + setRefs((prev) => prev.filter((r) => r.id !== id)); + }, []); + + const clearRefs = useCallback(() => setRefs([]), []); const actorRef = useMemo(() => createActor(chatModeLogic), []); @@ -75,5 +100,9 @@ export function useChatMode() { setGroupId, draftMessage, setDraftMessage, + refs, + addRef, + removeRef, + clearRefs, }; } diff --git a/packages/tiptap/src/chat/index.tsx b/packages/tiptap/src/chat/index.tsx index bfc09b73d8..5a6156f7b6 100644 --- a/packages/tiptap/src/chat/index.tsx +++ b/packages/tiptap/src/chat/index.tsx @@ -14,10 +14,16 @@ import type { PlaceholderFunction } from "../shared/extensions/placeholder"; export type { JSONContent, TiptapEditor }; export type { MentionConfig }; +export interface SlashCommandItem { + id: string; + type: string; + label: string; + content?: string; +} + export interface SlashCommandConfig { - handleSearch: ( - query: string, - ) => Promise<{ id: string; type: string; label: string; content?: string }[]>; + handleSearch: (query: string) => Promise; + onSelect?: (item: SlashCommandItem) => void; } interface ChatEditorProps { @@ -46,6 +52,7 @@ const ChatEditor = forwardRef<{ editor: TiptapEditor | null }, ChatEditorProps>( configs.push({ trigger: "/", handleSearch: slashCommandConfig.handleSearch, + onSelect: slashCommandConfig.onSelect, }); } diff --git a/packages/tiptap/src/editor/mention.tsx b/packages/tiptap/src/editor/mention.tsx index 482c50b9b1..7a85f00f05 100644 --- a/packages/tiptap/src/editor/mention.tsx +++ b/packages/tiptap/src/editor/mention.tsx @@ -151,13 +151,22 @@ const suggestion = ( pluginKey: new PluginKey(`mention-${config.trigger}`), command: ({ editor, range, props }) => { const item = props as MentionItem; - if (item.content) { - editor - .chain() - .focus() - .deleteRange(range) - .insertContent(item.content) - .run(); + + if (config.onSelect) { + config.onSelect(item); + } + + if (item.content !== undefined) { + if (item.content === "") { + editor.chain().focus().deleteRange(range).run(); + } else { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(item.content) + .run(); + } } else { editor .chain() @@ -307,6 +316,7 @@ const suggestion = ( export type MentionConfig = { trigger: string; handleSearch: (query: string) => Promise; + onSelect?: (item: MentionItem) => void; }; export const mention = (config: MentionConfig) => {