Skip to content

Commit

Permalink
Chat UI revamp
Browse files Browse the repository at this point in the history
  • Loading branch information
zeroliu committed Jan 21, 2025
1 parent 2565bfc commit 7d7a1c2
Show file tree
Hide file tree
Showing 16 changed files with 1,151 additions and 742 deletions.
665 changes: 460 additions & 205 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,14 @@
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@tabler/icons-react": "^2.14.0",
"axios": "^1.3.4",
Expand Down
54 changes: 21 additions & 33 deletions src/components/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useChainType, useModelKey } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { updateChatMemory } from "@/chatUtils";
import { ChatControls } from "@/components/chat-components/ChatControls";
import ChatInput from "@/components/chat-components/ChatInput";
import ChatMessages from "@/components/chat-components/ChatMessages";
import { ABORT_REASON, COMMAND_IDS, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants";
Expand Down Expand Up @@ -312,25 +313,6 @@ ${chatContent}`;
[app, chatHistory, currentModelKey, settings.defaultConversationTag, settings.defaultSaveFolder]
);

const refreshVaultContext = useCallback(async () => {
if (!app) {
console.error("App instance is not available.");
return;
}

try {
await plugin.vectorStoreManager.indexVaultToVectorStore();
new Notice("Vault index refreshed.");
} catch (error) {
console.error("Error refreshing vault index:", error);
new Notice("Failed to refresh vault index. Check console for details.");
}
}, [app, plugin.vectorStoreManager]);

const clearCurrentAiMessage = useCallback(() => {
setCurrentAiMessage("");
}, []);

const handleStopGenerating = useCallback(
(reason?: ABORT_REASON) => {
if (abortController) {
Expand Down Expand Up @@ -533,6 +515,25 @@ ${chatContent}`;
setInputMessage((prev) => `${prev} ${prompt} `);
}, []);

const handleNewChat = useCallback(async () => {
handleStopGenerating(ABORT_REASON.NEW_CHAT);
if (settings.autosaveChat && chatHistory.length > 0) {
await handleSaveAsNote(true);
}
clearMessages();
chainManager.memoryManager.clearChatMemory();
setCurrentAiMessage("");
setContextNotes([]);
setIncludeActiveNote(false);
}, [
handleStopGenerating,
settings.autosaveChat,
chatHistory.length,
clearMessages,
chainManager.memoryManager,
handleSaveAsNote,
]);

return (
<div className="chat-container">
<ChatMessages
Expand All @@ -548,6 +549,7 @@ ${chatContent}`;
onReplaceChat={setInputMessage}
/>
<div className="bottom-container">
<ChatControls onNewChat={handleNewChat} onSaveAsNote={() => handleSaveAsNote(true)} />
<ChatInput
ref={inputRef}
inputMessage={inputMessage}
Expand All @@ -557,19 +559,6 @@ ${chatContent}`;
onStopGenerating={() => handleStopGenerating(ABORT_REASON.USER_STOPPED)}
app={app}
navigateHistory={navigateHistory}
onNewChat={async (openNote: boolean) => {
handleStopGenerating(ABORT_REASON.NEW_CHAT);
if (settings.autosaveChat && chatHistory.length > 0) {
await handleSaveAsNote(openNote);
}
clearMessages();
chainManager.memoryManager.clearChatMemory();
clearCurrentAiMessage();
setContextNotes([]);
setIncludeActiveNote(false);
}}
onSaveAsNote={() => handleSaveAsNote(true)}
onRefreshVaultContext={refreshVaultContext}
contextNotes={contextNotes}
setContextNotes={setContextNotes}
includeActiveNote={includeActiveNote}
Expand All @@ -578,7 +567,6 @@ ${chatContent}`;
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}
chatHistory={chatHistory}
/>
</div>
</div>
Expand Down
96 changes: 70 additions & 26 deletions src/components/chat-components/ChatButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Button } from "@/components/ui/button";
import { Platform } from "obsidian";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { USER_SENDER } from "@/constants";
import { cn } from "@/lib/utils";
import { ChatMessage } from "@/sharedState";
import {
Check,
Expand Down Expand Up @@ -35,39 +39,79 @@ export const ChatButtons: React.FC<ChatButtonsProps> = ({
hasSources,
}) => {
return (
<div className="chat-message-buttons">
<button onClick={onCopy} className="clickable-icon" title="Copy">
{isCopied ? <Check /> : <Copy />}
</button>
<div
className={cn("flex", {
"group-hover:opacity-100 opacity-0": !Platform.isMobile,
})}
>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost2" size="select" onClick={onCopy} title="Copy">
{isCopied ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
{message.sender === USER_SENDER ? (
<>
<button onClick={onEdit} className="clickable-icon" title="Edit">
<PenSquare />
</button>
<button onClick={onDelete} className="clickable-icon" title="Delete">
<Trash2 />
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onEdit} variant="ghost2" size="select" title="Edit">
<PenSquare className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDelete} variant="ghost2" size="select" title="Delete">
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</>
) : (
<>
{hasSources && (
<button onClick={onShowSources} className="clickable-icon" title="Show Sources">
<LibraryBig />
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onShowSources} variant="ghost2" size="select" title="Show Sources">
<LibraryBig className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Show Sources</TooltipContent>
</Tooltip>
)}
<button
onClick={onInsertIntoEditor}
className="clickable-icon"
title="Insert to note at cursor"
>
<TextCursorInput />
</button>
<button onClick={onRegenerate} className="clickable-icon" title="Regenerate">
<RotateCw />
</button>
<button onClick={onDelete} className="clickable-icon" title="Delete">
<Trash2 />
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onInsertIntoEditor}
variant="ghost2"
size="select"
title="Insert to note at cursor"
>
<TextCursorInput className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Insert to note at cursor</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onRegenerate} variant="ghost2" size="select" title="Regenerate">
<RotateCw className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Regenerate</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDelete} variant="ghost2" size="select" title="Delete">
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</>
)}
</div>
Expand Down
118 changes: 76 additions & 42 deletions src/components/chat-components/ChatContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Plus } from "lucide-react";
import { Plus, X } from "lucide-react";
import { TFile } from "obsidian";
import React from "react";
import { TooltipActionButton } from "./TooltipActionButton";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";

interface ChatContextMenuProps {
activeNote: TFile | null;
Expand All @@ -12,6 +13,53 @@ interface ChatContextMenuProps {
onRemoveUrl: (url: string) => void;
}

function ContextNote({
note,
isActive = false,
onRemoveContext,
}: {
note: TFile;
isActive: boolean;
onRemoveContext: (path: string) => void;
}) {
return (
<Badge className="text-xs pl-2 pr-0.5 py-0 items-center">
<div className="flex items-center gap-1">
<span className="max-w-40 truncate">{note.basename}</span>
{isActive && <span className="text-xs text-faint">Current</span>}
{note.extension === "pdf" && <span className="text-xs text-faint">pdf</span>}
</div>
<Button
variant="ghost2"
size="select"
onClick={() => onRemoveContext(note.path)}
aria-label="Remove from context"
>
<X className="size-4" />
</Button>
</Badge>
);
}

function ContextUrl({ url, onRemoveUrl }: { url: string; onRemoveUrl: (url: string) => void }) {
return (
<Badge className="text-xs pl-2 pr-0.5 py-0 items-center">
<div className="flex items-center gap-1">
<span className="max-w-40 truncate">{url}</span>
<span className="text-xs text-faint">Link</span>
</div>
<Button
variant="ghost2"
size="select"
onClick={() => onRemoveUrl(url)}
aria-label="Remove from context"
>
<X className="size-4" />
</Button>
</Badge>
);
}

export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
activeNote,
contextNotes,
Expand All @@ -36,49 +84,35 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({

const uniqueUrls = React.useMemo(() => Array.from(new Set(contextUrls)), [contextUrls]);

const renderNote = (note: TFile, isActive = false) => (
<div key={note.path} className={`context-note ${isActive ? "active" : "with-hover"}`}>
<span className="note-name">{note.basename}</span>
{isActive && <span className="note-badge">current</span>}
{note.extension === "pdf" && <span className="note-badge pdf">pdf</span>}
<button
className="remove-note"
onClick={() => onRemoveContext(note.path)}
aria-label="Remove from context"
>
×
</button>
</div>
);
const hasContext = uniqueNotes.length > 0 || uniqueUrls.length > 0 || !!activeNote;

return (
<div className="chat-context-menu">
<TooltipActionButton onClick={onAddContext} Icon={<Plus className="icon-scaler" />}>
Add Note to Context
</TooltipActionButton>
<div className="context-notes">
{activeNote && renderNote(activeNote, true)}
{uniqueNotes.map((note) => renderNote(note))}
<div className="flex items-start w-full gap-1 items-center">
<div className="flex items-start h-full">
<Button onClick={onAddContext} variant="ghost2" size="select" className="text-muted">
<Plus className="size-5" />
{!hasContext && <span className="text-sm leading-4">Add context</span>}
</Button>
</div>
<div className="flex gap-1 flex-wrap flex-1">
{activeNote && (
<ContextNote
key={activeNote.path}
note={activeNote}
isActive={true}
onRemoveContext={onRemoveContext}
/>
)}
{uniqueNotes.map((note) => (
<ContextNote
key={note.path}
note={note}
isActive={false}
onRemoveContext={onRemoveContext}
/>
))}
{uniqueUrls.map((url) => (
<div key={url} className="context-note url">
<span className="note-name" title={url}>
{(() => {
const hostname = new URL(url).hostname;
const cleanUrl = url.replace(/\/+$/, "");
const path = cleanUrl.slice(cleanUrl.indexOf(hostname) + hostname.length);
if (path.length <= 1) return hostname;
return `${hostname}/...${cleanUrl.slice(-2)}`;
})()}
</span>
<span className="note-badge">url</span>
<button
className="remove-note"
onClick={() => onRemoveUrl(url)}
aria-label="Remove URL from context"
>
×
</button>
</div>
<ContextUrl key={url} url={url} onRemoveUrl={onRemoveUrl} />
))}
</div>
</div>
Expand Down
Loading

0 comments on commit 7d7a1c2

Please sign in to comment.