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
205 changes: 200 additions & 5 deletions apps/shinkai-desktop/src/components/chat/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,29 @@ import { format } from 'date-fns';
import equal from 'fast-deep-equal';
import { AnimatePresence, motion } from 'framer-motion';
import {
BotIcon,
Cpu,
Edit3,
GitFork,
InfoIcon,
Loader,
Loader2,
QuoteIcon,
RotateCcw,
Unplug,
User,
XCircle,
Zap,
User,
Cpu,
BotIcon,
Loader,
} from 'lucide-react';
import React, { Fragment, memo, useEffect, useMemo, useState } from 'react';
import React, {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
Expand Down Expand Up @@ -206,6 +215,7 @@ export const MessageBase = ({

const selectedArtifact = useChatStore((state) => state.selectedArtifact);
const setArtifact = useChatStore((state) => state.setSelectedArtifact);
const setQuotedText = useChatStore((state) => state.setQuotedText);

const auth = useAuth((state) => state.auth);

Expand All @@ -215,6 +225,14 @@ export const MessageBase = ({

const [editing, setEditing] = useState(false);
const [tracingOpen, setTracingOpen] = useState(false);
const [selectedText, setSelectedText] = useState<string | null>(null);
const [selectionRect, setSelectionRect] = useState<{
top: number;
left: number;
width: number;
containerWidth: number;
} | null>(null);
const messageContentRef = useRef<HTMLDivElement>(null);

const editMessageForm = useForm<EditMessageFormSchema>({
resolver: zodResolver(editMessageFormSchema),
Expand All @@ -227,6 +245,90 @@ export const MessageBase = ({

const parentMessageId = message.metadata.parentMessageId;

const clearWindowSelection = useCallback(() => {
const selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
}, []);

const handleSelectionUpdate = useCallback(() => {
if (editing) return;
const container = messageContentRef.current;
if (!container) return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
setSelectedText(null);
setSelectionRect(null);
return;
}

const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (!anchorNode || !focusNode) {
setSelectedText(null);
setSelectionRect(null);
return;
}

if (!container.contains(anchorNode) || !container.contains(focusNode)) {
setSelectedText(null);
setSelectionRect(null);
return;
}

if (selection.rangeCount === 0) {
setSelectedText(null);
setSelectionRect(null);
return;
}

const text = selection.toString().trim();
if (!text) {
setSelectedText(null);
setSelectionRect(null);
return;
}

const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
const firstRect =
rangeRect.width === 0 && rangeRect.height === 0
? range.getClientRects()[0]
: rangeRect;

if (!firstRect) {
setSelectedText(null);
setSelectionRect(null);
return;
}

const containerRect = container.getBoundingClientRect();
const top = firstRect.top - containerRect.top + container.scrollTop;
const left = firstRect.left - containerRect.left + container.scrollLeft;

setSelectionRect({
top,
left,
width: Math.max(firstRect.width, 1),
containerWidth: containerRect.width,
});
setSelectedText((prev) => (prev === text ? prev : text));
}, [editing]);

const handleAskShinkai = useCallback(() => {
if (!selectedText) return;
setQuotedText(selectedText);
setSelectedText(null);
clearWindowSelection();
setSelectionRect(null);
}, [clearWindowSelection, selectedText, setQuotedText]);

const handleClearSelection = useCallback(() => {
setSelectedText(null);
clearWindowSelection();
setSelectionRect(null);
}, [clearWindowSelection]);

const onSubmit = async (data: z.infer<typeof editMessageFormSchema>) => {
if (message.role === 'user') {
handleEditMessage?.(data.message);
Expand All @@ -249,6 +351,72 @@ export const MessageBase = ({
return null;
}, [message.content]);

useEffect(() => {
if (!selectedText) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClearSelection();
}
};

const handleReposition = () => {
handleSelectionUpdate();
};

const handleClickOutside = (event: MouseEvent) => {
if (!messageContentRef.current) return;
if (messageContentRef.current.contains(event.target as Node)) {
return;
}
handleClearSelection();
};

document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleReposition, true);
window.addEventListener('resize', handleReposition);

return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleReposition, true);
window.removeEventListener('resize', handleReposition);
};
}, [handleClearSelection, handleSelectionUpdate, selectedText]);

useEffect(() => {
if (editing) {
handleClearSelection();
}
}, [editing, handleClearSelection]);

useEffect(() => {
handleClearSelection();
}, [handleClearSelection, message.content, message.messageId]);

const truncatedSelection = useMemo(() => {
if (!selectedText) return null;
const maxLength = 200;
if (selectedText.length <= maxLength) {
return selectedText;
}
return `${selectedText.slice(0, maxLength)}…`;
}, [selectedText]);

const selectionButtonStyle = useMemo(() => {
if (!selectionRect) return null;
const offsetTop = Math.max(selectionRect.top - 2, 0);
const centerLeft = selectionRect.left + selectionRect.width / 2;
const clampedLeft = Math.min(
Math.max(centerLeft, 16),
selectionRect.containerWidth - 16,
);
return {
top: offsetTop,
left: clampedLeft,
};
}, [selectionRect]);

const oauthUrl = useMemo(() => {
return oauthUrlMatcherFromErrorMessage(message.content);
}, [message.content]);
Expand Down Expand Up @@ -384,6 +552,10 @@ export const MessageBase = ({
'relative overflow-hidden pb-4 before:absolute before:right-0 before:bottom-0 before:left-0 before:h-10 before:animate-pulse before:bg-gradient-to-l before:from-gray-200 before:to-gray-200/10',
minimalistMode && 'rounded-xs px-2 pt-1.5 pb-1.5',
)}
onMouseUp={handleSelectionUpdate}
onPointerUp={handleSelectionUpdate}
onTouchEnd={handleSelectionUpdate}
ref={messageContentRef}
>
{message.role === 'assistant' && message.reasoning && (
<Reasoning
Expand Down Expand Up @@ -602,6 +774,29 @@ export const MessageBase = ({
!!message.generatedFiles) && (
<GeneratedFiles message={message} />
)}

{selectedText && selectionButtonStyle && (
<div
className="pointer-events-none absolute z-10"
style={{
left: selectionButtonStyle.left,
top: selectionButtonStyle.top,
transform: 'translate(-50%, calc(-100% - 8px))',
}}
>
<Button
className="bg-bg-secondary text-text-default pointer-events-auto shadow-md"
onClick={handleAskShinkai}
size="xs"
variant="outline"
aria-label="Ask Shinkai about selection"
title={truncatedSelection ?? undefined}
>
<QuoteIcon className="size-4" />
Ask AI
</Button>
</div>
)}
</div>
{!isPending && !minimalistMode && (
<motion.div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Artifact } from '@shinkai_network/shinkai-node-state/v2/queries/getChatConversation/types';
import { createContext, useContext, useState } from 'react';
import { createStore, useStore } from 'zustand';
import { createStore, useStore } from 'zustand';

export type ToolView = 'form' | 'raw';

Expand All @@ -12,6 +12,9 @@ type ChatStore = {
setChatToolView: (chatToolView: ToolView) => void;
toolRawInput: string;
setToolRawInput: (toolRawInput: string) => void;
// quoted text from message selection
quotedText: string | null;
setQuotedText: (quotedText: string | null) => void;
};

const createChatStore = () =>
Expand All @@ -25,6 +28,9 @@ const createChatStore = () =>

toolRawInput: '',
setToolRawInput: (toolRawInput: string) => set({ toolRawInput }),

quotedText: null,
setQuotedText: (quotedText: string | null) => set({ quotedText }),
}));

const ChatContext = createContext<ReturnType<typeof createChatStore> | null>(
Expand Down
36 changes: 35 additions & 1 deletion apps/shinkai-desktop/src/components/chat/conversation-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ import { invoke } from '@tauri-apps/api/core';
import equal from 'fast-deep-equal';
import { partial } from 'filesize';
import { motion } from 'framer-motion';
import { EllipsisIcon, Loader2, Paperclip, X, XIcon } from 'lucide-react';
import {
EllipsisIcon,
Loader2,
Paperclip,
Quote,
X,
XIcon,
} from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
Expand Down Expand Up @@ -199,6 +206,9 @@ function ConversationChatFooter({
(state) => state.promptSelected,
);

const quotedText = useChatStore((state) => state.quotedText);
const setQuotedText = useChatStore((state) => state.setQuotedText);

const chatForm = useForm<ChatMessageFormSchema>({
resolver: zodResolver(chatMessageFormSchema),
defaultValues: {
Expand Down Expand Up @@ -402,6 +412,11 @@ function ConversationChatFooter({

let content = data.message;

// Prepend quoted text if present
if (quotedText) {
content = `> ${quotedText}\n\n${data.message}`;
}

if (selectedTool) {
content = `${selectedTool.name} \n ${formattedToolMessage}`;
}
Expand All @@ -422,6 +437,7 @@ function ConversationChatFooter({
}
chatForm.reset();
setToolFormData(null);
setQuotedText(null); // Clear quoted text after sending
};

useEffect(() => {
Expand Down Expand Up @@ -643,6 +659,24 @@ function ConversationChatFooter({
textareaClassName="p-4 text-sm"
topAddons={
<>
{quotedText && (
<div className="bg-bg-quaternary mx-3 mt-3 flex items-start gap-2 rounded-lg p-3">
<Quote className="text-text-secondary mt-0.5 h-4 w-4 shrink-0" />
<div className="flex-1">
<p className="text-text-secondary line-clamp-3 text-xs">
"{quotedText}"
</p>
</div>
<Button
className="h-6 w-6 shrink-0"
onClick={() => setQuotedText(null)}
size="icon"
variant="tertiary"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{isDragActive && <DropFileActive />}
{!isDragActive &&
currentFiles &&
Expand Down
Loading