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) => {