From acac53d82f2003a71adf520120536920174b7985 Mon Sep 17 00:00:00 2001 From: MidnightCrowing <110297461+MidnightCrowing@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:05:27 +0800 Subject: [PATCH 1/2] feat: redesign chat UI --- src/copilot-chat/components/Chat.tsx | 47 +- .../components/atoms/FileSuggestion.tsx | 90 +-- .../components/atoms/LinkedNotes.tsx | 81 +++ src/copilot-chat/components/atoms/Message.tsx | 427 ++++++++---- .../components/atoms/ObsidianIcon.tsx | 24 + .../components/sections/Header.tsx | 70 +- .../components/sections/Input.tsx | 181 ++++- .../components/sections/MessageList.tsx | 93 ++- .../components/sections/ModelSelector.tsx | 77 +- src/copilot-chat/store/slices/message.tsx | 79 +++ styles.css | 659 ++++++++++++------ 11 files changed, 1306 insertions(+), 522 deletions(-) create mode 100644 src/copilot-chat/components/atoms/LinkedNotes.tsx create mode 100644 src/copilot-chat/components/atoms/ObsidianIcon.tsx diff --git a/src/copilot-chat/components/Chat.tsx b/src/copilot-chat/components/Chat.tsx index eb9c484..776d183 100644 --- a/src/copilot-chat/components/Chat.tsx +++ b/src/copilot-chat/components/Chat.tsx @@ -18,6 +18,8 @@ const Chat: React.FC = () => { conversations, activeConversationId, initConversationService, + deleteMessage, + retryMessage, } = useCopilotStore(); useEffect(() => { @@ -28,11 +30,12 @@ const Chat: React.FC = () => { const displayMessages = activeConversationId ? conversations.find((conv) => conv.id === activeConversationId) - ?.messages || [] + ?.messages || [] : messages; const formattedMessages: MessageProps[] = displayMessages.map( (message) => ({ + messageId: message.id, icon: message.role === "assistant" ? copilotIcon : userIcon, name: message.role === "assistant" ? "GitHub Copilot" : "User", message: message.content, @@ -40,13 +43,53 @@ const Chat: React.FC = () => { }), ); + // Handlers for message actions + const handleCopy: (id?: string | number, content?: string) => void = ( + id, + content, + ) => { + /* No changes needed here */ + }; + + const handleDelete: (id?: string | number) => void = (id) => { + if (typeof id === "string") { + deleteMessage(plugin, id); + } else if (typeof id === "number") { + const conv = activeConversationId + ? conversations.find((c) => c.id === activeConversationId) + : undefined; + const list = conv ? conv.messages : messages; + const msg = list[id ?? -1]; + if (msg) deleteMessage(plugin, msg.id); + } + }; + + const handleRetry: (id?: string | number) => void = (id) => { + if (typeof id === "string") { + retryMessage(plugin, id); + } else if (typeof id === "number") { + const conv = activeConversationId + ? conversations.find((c) => c.id === activeConversationId) + : undefined; + const list = conv ? conv.messages : messages; + const msg = list[id ?? -1]; + if (msg) retryMessage(plugin, msg.id); + } + }; + return (
{formattedMessages.length === 0 ? ( ) : ( - + )} diff --git a/src/copilot-chat/components/atoms/FileSuggestion.tsx b/src/copilot-chat/components/atoms/FileSuggestion.tsx index 01ef67c..c0cae73 100644 --- a/src/copilot-chat/components/atoms/FileSuggestion.tsx +++ b/src/copilot-chat/components/atoms/FileSuggestion.tsx @@ -7,7 +7,7 @@ const BASE_CLASSNAME = "copilot-chat-file-suggestion"; interface FileSuggestionProps { query: string; - position: { top: number; left: number }; + position?: { top: number; left: number }; onSelect: (file: { path: string; filename: string }) => void; onClose: () => void; plugin: CopilotPlugin | undefined; @@ -48,7 +48,7 @@ const FileSuggestion: React.FC = ({ setSelectedIndex( (prev) => (prev - 1 + files.length) % files.length, ); - } else if (e.key === "Enter") { + } else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); if (files[selectedIndex]) { handleSelect(files[selectedIndex]); @@ -86,29 +86,17 @@ const FileSuggestion: React.FC = ({ }; const getDirectory = (path: string) => { - const lastSlashIndex = path.lastIndexOf("/"); - if (lastSlashIndex === -1) return ""; - return path.substring(0, lastSlashIndex); + // Normalize path by trimming leading/trailing slashes + const normalized = (path || "").replace(/^\/+|\/+$/g, ""); + const parts = normalized.split("/").filter(Boolean); + // If no folder part, it's root + if (parts.length <= 1) return ""; + // Join folder parts and ensure a leading slash + return "/" + parts.slice(0, -1).join("/"); }; return ( -
+
{files.length === 0 ? (
= ({ No files found
) : ( -
- {files.map((file, index) => ( -
handleSelect(file)} - style={{ - padding: "8px 10px", - cursor: "pointer", - borderBottom: - "1px solid var(--background-modifier-border)", - backgroundColor: - index === selectedIndex - ? "var(--background-secondary)" - : "transparent", - display: "flex", - flexDirection: "column", - }} - > -
- {file.basename} -
- {getDirectory(file.path) && ( -
- {getDirectory(file.path)} -
- )} + files.map((file, index) => ( +
handleSelect(file)} + > +
+ {file.basename}
- ))} -
+
+ {getDirectory(file.path)} +
+
+ )) )}
); diff --git a/src/copilot-chat/components/atoms/LinkedNotes.tsx b/src/copilot-chat/components/atoms/LinkedNotes.tsx new file mode 100644 index 0000000..5a5da70 --- /dev/null +++ b/src/copilot-chat/components/atoms/LinkedNotes.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { cx } from "../../../utils/style"; +import { usePlugin } from "../../hooks/usePlugin"; +import { TFile, Notice } from "obsidian"; + +const BASE_CLASSNAME = "copilot-chat-message"; + +export interface LinkedNoteItem { + path: string; + filename: string; + content: string; +} + +interface LinkedNotesProps { + notes?: LinkedNoteItem[]; +} + +const LinkedNotes: React.FC = ({ notes }) => { + const plugin = usePlugin(); + + if (!notes || notes.length === 0) return null; + + const openLinkedNote = (note: { path: string; filename: string }) => { + if (!plugin) return; + try { + const file = plugin.app.vault.getAbstractFileByPath(note.path); + if (file instanceof TFile) { + // If already open in any leaf, activate that leaf + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + const existing = leaves.find((leaf) => { + type ViewWithFile = { file?: { path?: string } }; + const view = leaf.view as unknown as ViewWithFile; + return view?.file?.path === file.path; + }); + if (existing) { + plugin.app.workspace.revealLeaf(existing); + return; + } + + // Otherwise open in a new leaf + plugin.app.workspace + .getLeaf(true) + .openFile(file) + .catch((e) => { + console.error("Failed to open file", e); + new Notice("Unable to open file: " + note.filename); + }); + } else { + plugin.app.workspace.openLinkText(note.filename, "", false); + } + } catch (e) { + console.error("Open note error", e); + new Notice("Failed to open note: " + note.filename); + } + }; + + return ( +
+ {notes.map((note, index) => ( +
openLinkedNote(note)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openLinkedNote(note); + } + }} + aria-label={`Open note ${note.filename}`} + > + {note.filename} +
+ ))} +
+ ); +}; + +export { LinkedNotes }; diff --git a/src/copilot-chat/components/atoms/Message.tsx b/src/copilot-chat/components/atoms/Message.tsx index 5320d63..3c74bb5 100644 --- a/src/copilot-chat/components/atoms/Message.tsx +++ b/src/copilot-chat/components/atoms/Message.tsx @@ -5,6 +5,8 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; import { usePlugin } from "../../hooks/usePlugin"; +import { LinkedNotes } from "./LinkedNotes"; +import { ObsidianIcon } from "./ObsidianIcon"; const BASE_CLASSNAME = "copilot-chat-message"; @@ -13,6 +15,10 @@ export interface MessageProps { icon: string; name: string; message: string; + messageId?: string | number; + onCopy?: (messageId?: string | number, content?: string) => void; + onDelete?: (messageId?: string | number) => void; + onRetry?: (messageId?: string | number) => void; linkedNotes?: { path: string; filename: string; @@ -27,10 +33,91 @@ interface CodeProps { children?: React.ReactNode; } -const ChatMessage: React.FC = (props) => { - const { className, icon, name, message, linkedNotes } = props; +// Build component mappings for ReactMarkdown +const getMarkdownComponents = (isDarkTheme: boolean) => { + const themeClass = `theme-${isDarkTheme ? "dark" : "light"}`; + const codeStyle = isDarkTheme + ? vscDarkPlus + : (oneLight as Record); + + const CodeBlock = ({ + className, + inline, + children, + ...props + }: CodeProps) => { + const match = className?.match(/language-(\w+)/); + + if (!inline && match) { + return ( + + {String(children || "").replace(/\n$/, "")} + + ); + } + + return ( + + {children} + + ); + }; + + return { + p: ({ children }: { children?: React.ReactNode }) =>

{children}

, + code: CodeBlock, + }; +}; + +// Shared inner render: text, code highlighting, and linked notes +const ChatMessageContent: React.FC<{ + message: string; + isDarkTheme: boolean; + showRaw: boolean; + onClick?: React.MouseEventHandler; + style?: React.CSSProperties; +}> = ({ message, isDarkTheme, onClick, style, showRaw }) => { + return ( +
+ {showRaw ? ( +

{message}

+ ) : ( + + {message} + + )} +
+ ); +}; + +// Visual wrapper: Copilot messages +export const CopilotMessage: React.FC = (props) => { + const { + className, + message, + linkedNotes, + messageId, + onCopy, + onDelete, + onRetry, + } = props; const plugin = usePlugin(); const [isCopied, setIsCopied] = React.useState(false); + const [showRaw, setShowRaw] = React.useState(false); const isDarkTheme = useMemo(() => { return document.body.classList.contains("theme-dark"); @@ -41,161 +128,219 @@ const ChatMessage: React.FC = (props) => { .writeText(message) .then(() => { setIsCopied(true); - setTimeout(() => { - setIsCopied(false); - }, 2000); + setTimeout(() => setIsCopied(false), 2000); }) - .catch((err) => { - console.error("Failed to copy message: ", err); - }); + .catch((err) => console.error("Failed to copy message: ", err)); + onCopy?.(messageId, message); + }; + + const handleDelete = () => { + onDelete?.(messageId); + }; + + const handleRetry = () => { + onRetry?.(messageId); }; return (
-
-
-
{name}
+ + {linkedNotes && linkedNotes.length > 0 && ( + + )} +
+ {/* actions moved to bottom */} + + +
-
- - {children} -

- ); - }, - code({ - className, - inline, - children, - ...props - }: CodeProps) { - const match = /language-(\w+)/.exec( - className || "", - ); - return !inline && match ? ( - ) - } - PreTag="div" - className={`theme-${isDarkTheme ? "dark" : "light"}`} - customStyle={{ - background: "var(--code-background)", - borderRadius: "4px", - }} - {...props} - > - {String(children || "").replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} +
+ ); +}; + +// Visual wrapper: User messages +export const UserMessage: React.FC = (props) => { + const { className, message, linkedNotes, messageId, onCopy, onDelete } = + props; + const plugin = usePlugin(); + const [isCopied, setIsCopied] = React.useState(false); + const [showRaw, setShowRaw] = React.useState(false); + const [isCollapsed, setIsCollapsed] = React.useState(true); + const [contentHeight, setContentHeight] = React.useState(0); + + const collapsibleRef = React.useRef(null); + const [animatedHeight, setAnimatedHeight] = React.useState(0); + + React.useLayoutEffect(() => { + const wrapper = collapsibleRef.current; + if (!wrapper) return; + const content = wrapper.querySelector( + `.${BASE_CLASSNAME}-message`, + ) as HTMLElement | null; + if (!content) return; + + // Expanded height equals real content height; collapsed is min(full, 100px) + let full = content.scrollHeight; + if (showRaw) { + // In source mode, use the sum of all child element heights (including margins) + const children = Array.from(content.children) as HTMLElement[]; + full = children.reduce((sum, el) => { + const rectH = el.offsetHeight; + const cs = window.getComputedStyle(el); + const mt = parseFloat(cs.marginTop || "0"); + const mb = parseFloat(cs.marginBottom || "0"); + return sum + rectH + mt + mb; + }, 0); + } + setContentHeight(full); + const collapsed = Math.min(full, 100); + const target = isCollapsed ? collapsed : full; + setAnimatedHeight(target); + + // If content height is not greater than 100px, do not collapse + if (full <= 100 && isCollapsed) { + setIsCollapsed(false); + } + }, [isCollapsed, message, showRaw]); + + // When collapsed, if horizontal scroll exists, scroll back to the left + React.useEffect(() => { + const wrapper = collapsibleRef.current; + if (!wrapper) return; + const content = wrapper.querySelector( + `.${BASE_CLASSNAME}-message`, + ) as HTMLElement | null; + if (!content) return; + if (isCollapsed && content.scrollLeft > 0) { + content.scrollTo({ left: 0, behavior: "smooth" }); + } + }, [isCollapsed]); + + const isDarkTheme = useMemo(() => { + return document.body.classList.contains("theme-dark"); + }, [plugin?.app]); + + const handleCopyMessage = () => { + onCopy?.(messageId, message); + }; + + const handleDelete = () => { + onDelete?.(messageId); + }; + + return ( +
100 + ? concat(BASE_CLASSNAME, "collapsible") + : "", + isCollapsed ? concat(BASE_CLASSNAME, "collapsed") : "", + className || "", + )} + > + setIsCollapsed(false) : undefined} + style={ + contentHeight > 100 ? { height: animatedHeight } : undefined + } + /> + {contentHeight > 100 && ( + + )} - {linkedNotes && linkedNotes.length > 0 && ( -
-
- Linked Notes: -
-
- {linkedNotes.map((note, index) => ( -
- - [[{note.filename}]] - -
- ))} -
-
- )} + {linkedNotes && linkedNotes.length > 0 && ( + + )} + +
+ {/* actions moved below for user */} + + +
); }; - -export default ChatMessage; diff --git a/src/copilot-chat/components/atoms/ObsidianIcon.tsx b/src/copilot-chat/components/atoms/ObsidianIcon.tsx new file mode 100644 index 0000000..6ccf667 --- /dev/null +++ b/src/copilot-chat/components/atoms/ObsidianIcon.tsx @@ -0,0 +1,24 @@ +import { setIcon } from "obsidian"; +import React from "react"; + +export const ObsidianIcon: React.FC<{ + name: string; + className?: string; + style?: React.CSSProperties; +}> = ({ name, className, style }) => { + const ref = React.useRef(null); + + React.useEffect(() => { + if (ref.current) { + setIcon(ref.current, name); + } + }, [name]); + + return ( + + ); +}; diff --git a/src/copilot-chat/components/sections/Header.tsx b/src/copilot-chat/components/sections/Header.tsx index 0027278..f7f3049 100644 --- a/src/copilot-chat/components/sections/Header.tsx +++ b/src/copilot-chat/components/sections/Header.tsx @@ -1,8 +1,9 @@ import React, { useState } from "react"; -import { concat } from "../../../utils/style"; +import { concat, cx } from "../../../utils/style"; import { usePlugin } from "../../hooks/usePlugin"; import { useCopilotStore } from "../../store/store"; import ConversationSelector from "./ConversationSelector"; +import { ObsidianIcon } from "../atoms/ObsidianIcon"; const BASE_CLASSNAME = "copilot-chat-header"; @@ -46,65 +47,34 @@ const Header: React.FC = () => {
Chat
= ({ isLoading = false }) => { }); const [showFileSuggestion, setShowFileSuggestion] = useState(false); const [fileSearchQuery, setFileSearchQuery] = useState(""); - const [dropdownPosition] = useState({ - top: 0, - left: 0, - }); + + // Parse attachments from the message content + const attachments = React.useMemo(() => { + const regex = /\[\[(.*?)\]\]/g; + const list: string[] = []; + const seen = new Set(); + let match; + while ((match = regex.exec(message)) !== null) { + const filename = match[1]; + if (!seen.has(filename)) { + seen.add(filename); + list.push(filename); + } + } + return list; + }, [message]); const updateCursorPosition = () => { if (!textareaRef.current) return; @@ -71,20 +84,22 @@ const Input: React.FC = ({ isLoading = false }) => { const { start, end } = cursorPosition; const { startIndex } = checkForFileLinkPattern(value, start); - if (startIndex === -1) return; + const token = `[[${file.filename}]]`; + + const insertStart = startIndex !== -1 ? startIndex : start; + const insertEnd = startIndex !== -1 ? end : end; const newValue = - value.substring(0, startIndex) + - `[[${file.filename}]]` + - value.substring(end); + value.substring(0, insertStart) + + token + + value.substring(insertEnd); setMessage(newValue); - setShowFileSuggestion(false); setTimeout(() => { if (textareaRef.current) { - const newCursorPos = startIndex + `[[${file.filename}]]`.length; + const newCursorPos = insertStart + token.length; textareaRef.current.focus(); textareaRef.current.setSelectionRange( newCursorPos, @@ -119,6 +134,68 @@ const Input: React.FC = ({ isLoading = false }) => { }); }; + // When clicking the attachment icon, open file suggestions (no query); insert [[filename]] on selection + const handleAttachClick = () => { + setFileSearchQuery(""); + setShowFileSuggestion(true); + // 将下拉定位到文本框光标(简单保持现状) + updateCursorPosition(); + }; + + // Remove an attachment from the input (delete all occurrences of [[filename]]) + const handleRemoveAttachment = (filename: string) => { + const regex = new RegExp(`\\[\\[${filename}\\]\\]`, "g"); + const newValue = message.replace(regex, ""); + setMessage(newValue); + }; + + // Open the corresponding note when clicking an attachment tag + const openLinkedNote = (note: { path: string; filename: string }) => { + if (!plugin) return; + try { + const file = plugin.app.vault.getAbstractFileByPath(note.path); + if (file instanceof TFile) { + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + const existing = leaves.find((leaf) => { + type ViewWithFile = { file?: { path?: string } }; + const view = leaf.view as unknown as ViewWithFile; + const currentPath = view?.file?.path; + return currentPath === file.path; + }); + if (existing) { + plugin.app.workspace.revealLeaf(existing); + return; + } + + plugin.app.workspace + .getLeaf(true) + .openFile(file) + .catch((e) => { + console.error("Failed to open file", e); + new Notice("无法打开文件: " + note.filename); + }); + } else { + plugin.app.workspace.openLinkText(note.filename, "", false); + } + } catch (e) { + console.error("Open note error", e); + new Notice("打开笔记失败: " + note.filename); + } + }; + + // Auto-size the textarea based on content (including wrapping), up to 10 lines + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + const style = window.getComputedStyle(el); + const lineHeight = parseFloat(style.lineHeight || "20"); + const maxHeight = lineHeight * 10; // 10 行上限 + el.style.height = "auto"; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; + }, [message]); + const extractLinkedNotes = async () => { if (!plugin) return null; @@ -166,16 +243,11 @@ const Input: React.FC = ({ isLoading = false }) => { const linkedNotes = (await extractLinkedNotes()) || undefined; const displayMessage = message; const apiMessage = linkedNotes - ? `${message}\n\n${linkedNotes - .map( - (note) => - `Referenced content from [[${note.filename}]]:\n${note.content}`, - ) - .join("\n\n")}` + ? `${message}\n\n${linkedNotes.map((note) => `Referenced content from [[${note.filename}]]:\n${note.content}`).join("\n\n")}` : message; - await sendMessage(plugin, apiMessage, displayMessage, linkedNotes); setMessage(""); + await sendMessage(plugin, apiMessage, displayMessage, linkedNotes); } catch (error) { console.error("Failed to send message:", error); } @@ -228,8 +300,56 @@ const Input: React.FC = ({ isLoading = false }) => { return (
- -
+ {/* 第一部分:附件栏 */} +
+ + {attachments.map((name) => ( + { + if (!plugin) return; + const files = plugin.app.vault.getMarkdownFiles(); + const file = files.find((f) => f.basename === name); + if (file) { + openLinkedNote({ + path: file.path, + filename: file.basename, + }); + } else { + new Notice("File not found: " + name); + } + }} + > + {name} + + + ))} +
+ + {/* 第二部分:可自增高度输入框 */} +