Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/chat/components/body/empty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/chat/components/body/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -29,6 +30,7 @@ export function ChatBody({
onSendMessage?: (
content: string,
parts: Array<{ type: "text"; text: string }>,
contextRefs?: ContextRef[],
) => void;
}) {
const { chat } = useShell();
Expand Down
30 changes: 4 additions & 26 deletions apps/desktop/src/chat/components/chat-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";

import { cn } from "@hypr/utils";

Expand All @@ -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<string>(() => 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],
Expand All @@ -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 (
<div
className={cn([
Expand All @@ -65,8 +43,8 @@ export function ChatView() {
>
<ChatHeader
currentChatGroupId={groupId}
onNewChat={handleNewChat}
onSelectChat={handleSelectChat}
onNewChat={startNewChat}
onSelectChat={selectChat}
handleClose={() => chat.sendEvent({ type: "CLOSE" })}
/>
{user_id && (
Expand Down
25 changes: 20 additions & 5 deletions apps/desktop/src/chat/components/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,6 +23,7 @@ export function ChatContent({
pendingRefs,
onRemoveContextEntity,
onAddContextEntity,
onDraftContextRefsChange,
isSystemPromptReady,
mcpIndicator,
children,
Expand All @@ -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 (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
Expand All @@ -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),
);
}}
/>
)}
Expand All @@ -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}
Expand Down
30 changes: 23 additions & 7 deletions apps/desktop/src/chat/components/context-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
onClick={() => {
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
Expand Down Expand Up @@ -281,7 +297,7 @@ export function ContextBar({
[entities],
);

if (chips.length === 0 && !onAddEntity) {
if (chips.length === 0) {
return null;
}

Expand Down
98 changes: 93 additions & 5 deletions apps/desktop/src/chat/components/input/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JSONContent>();

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 {
Expand All @@ -39,6 +53,7 @@ export function useSubmit({
disabled,
isStreaming,
onSendMessage,
onContextRefsChange,
}: {
draftKey: string;
editorRef: React.RefObject<{ editor: TiptapEditor | null } | null>;
Expand All @@ -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({
Expand Down Expand Up @@ -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 || ""}`;
}

Expand All @@ -189,3 +215,65 @@ function tiptapJsonToText(json: any): string {

return "";
}

function extractContextRefsFromTiptapJson(
json: JSONContent | undefined,
): ContextRef[] {
const refs: ContextRef[] = [];
const seen = new Set<string>();

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;
}
Loading
Loading