Skip to content
Open
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 @@ -70,17 +70,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 @@ -278,7 +294,7 @@ export function ContextBar({
[entities],
);

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

Expand Down
128 changes: 128 additions & 0 deletions apps/desktop/src/chat/components/input/draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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<string, JSONContent>();

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<string>();

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<JSONContent, "type" | "attrs"> | Record<string, unknown>,
): 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}` : "";
}
Loading
Loading