mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-01 19:32:40 +02:00
add Chat context
This commit is contained in:
parent
2f1131c57f
commit
899f22ff61
7 changed files with 794 additions and 73 deletions
|
|
@ -31,9 +31,12 @@ import {
|
|||
PromptInputBody,
|
||||
PromptInputFooter,
|
||||
type PromptInputMessage,
|
||||
PromptInputProvider,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
usePromptInputController,
|
||||
type FileMention,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
|
|
@ -252,6 +255,99 @@ const collectDirPaths = (nodes: TreeNode[]): string[] =>
|
|||
const collectFilePaths = (nodes: TreeNode[]): string[] =>
|
||||
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
|
||||
|
||||
// Inner component that uses the controller to access mentions
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
isProcessing: boolean
|
||||
contextUsage: LanguageModelUsage
|
||||
maxTokens: number
|
||||
usedTokens: number
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
contextUsage,
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready'
|
||||
|
||||
const handleSubmit = useCallback((msg: PromptInputMessage) => {
|
||||
onSubmit(msg, controller.mentions.mentions)
|
||||
controller.mentions.clearMentions()
|
||||
}, [onSubmit, controller.mentions])
|
||||
|
||||
return (
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
placeholder="Type your message..."
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter>
|
||||
<PromptInputTools>
|
||||
<Context
|
||||
maxTokens={maxTokens}
|
||||
usedTokens={usedTokens}
|
||||
usage={contextUsage}
|
||||
>
|
||||
<ContextTrigger size="sm" />
|
||||
<ContextContent>
|
||||
<ContextContentHeader />
|
||||
<ContextContentBody>
|
||||
<ContextInputUsage />
|
||||
<ContextOutputUsage />
|
||||
<ContextReasoningUsage />
|
||||
<ContextCacheUsage />
|
||||
</ContextContentBody>
|
||||
</ContextContent>
|
||||
</Context>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit
|
||||
disabled={!canSubmit}
|
||||
status={submitStatus}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper component with PromptInputProvider
|
||||
interface ChatInputWithMentionsProps {
|
||||
knowledgeFiles: string[]
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
isProcessing: boolean
|
||||
contextUsage: LanguageModelUsage
|
||||
maxTokens: number
|
||||
usedTokens: number
|
||||
}
|
||||
|
||||
function ChatInputWithMentions({
|
||||
knowledgeFiles,
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
contextUsage,
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles}>
|
||||
<ChatInputInner
|
||||
onSubmit={onSubmit}
|
||||
isProcessing={isProcessing}
|
||||
contextUsage={contextUsage}
|
||||
maxTokens={maxTokens}
|
||||
usedTokens={usedTokens}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
// File browser state (for Knowledge section)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
|
|
@ -577,7 +673,7 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (message: PromptInputMessage) => {
|
||||
const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => {
|
||||
if (isProcessing) return
|
||||
|
||||
const { text } = message;
|
||||
|
|
@ -604,9 +700,32 @@ function App() {
|
|||
setRunId(currentRunId)
|
||||
}
|
||||
|
||||
// Read mentioned file contents and format message with XML context
|
||||
let formattedMessage = userMessage
|
||||
if (mentions && mentions.length > 0) {
|
||||
const attachedFiles = await Promise.all(
|
||||
mentions.map(async (m) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: m.path })
|
||||
return { path: m.path, content: result.data as string }
|
||||
} catch (err) {
|
||||
console.error('Failed to read mentioned file:', m.path, err)
|
||||
return { path: m.path, content: `[Error reading file: ${m.path}]` }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (attachedFiles.length > 0) {
|
||||
const filesXml = attachedFiles
|
||||
.map(f => `<file path="${f.path}">\n${f.content}\n</file>`)
|
||||
.join('\n')
|
||||
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: userMessage,
|
||||
message: formattedMessage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
|
|
@ -979,8 +1098,6 @@ function App() {
|
|||
const conversationContentClassName = hasConversation
|
||||
? "mx-auto w-full max-w-4xl pb-28"
|
||||
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
||||
const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready'
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat')
|
||||
|
||||
return (
|
||||
|
|
@ -1117,40 +1234,14 @@ function App() {
|
|||
<div className="sticky bottom-0 z-10 bg-background pb-4 pt-6 shadow-lg">
|
||||
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="mx-auto w-full max-w-4xl px-4">
|
||||
<PromptInput onSubmit={handlePromptSubmit}>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter>
|
||||
<PromptInputTools>
|
||||
<Context
|
||||
maxTokens={maxTokens}
|
||||
usedTokens={usedTokens}
|
||||
usage={contextUsage}
|
||||
>
|
||||
<ContextTrigger size="sm" />
|
||||
<ContextContent>
|
||||
<ContextContentHeader />
|
||||
<ContextContentBody>
|
||||
<ContextInputUsage />
|
||||
<ContextOutputUsage />
|
||||
<ContextReasoningUsage />
|
||||
<ContextCacheUsage />
|
||||
</ContextContentBody>
|
||||
</ContextContent>
|
||||
</Context>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit
|
||||
disabled={!canSubmit}
|
||||
status={submitStatus}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
<ChatInputWithMentions
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
onSubmit={handlePromptSubmit}
|
||||
isProcessing={isProcessing}
|
||||
contextUsage={contextUsage}
|
||||
maxTokens={maxTokens}
|
||||
usedTokens={usedTokens}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1173,6 +1264,8 @@ function App() {
|
|||
contextUsage={contextUsage}
|
||||
maxTokens={maxTokens}
|
||||
usedTokens={usedTokens}
|
||||
knowledgeFiles={knowledgeFiles}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ import {
|
|||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useMentionDetection } from "@/hooks/use-mention-detection";
|
||||
import { MentionPopover } from "@/components/mention-popover";
|
||||
import { toKnowledgePath } from "@/lib/wiki-links";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ChangeEventHandler,
|
||||
|
|
@ -83,6 +86,19 @@ export type AttachmentsContext = {
|
|||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
export type FileMention = {
|
||||
id: string;
|
||||
path: string; // "knowledge/notes.md"
|
||||
displayName: string; // "notes"
|
||||
};
|
||||
|
||||
export type MentionsContext = {
|
||||
mentions: FileMention[];
|
||||
addMention: (path: string, displayName: string) => void;
|
||||
removeMention: (id: string) => void;
|
||||
clearMentions: () => void;
|
||||
};
|
||||
|
||||
export type TextInputContext = {
|
||||
value: string;
|
||||
setInput: (v: string) => void;
|
||||
|
|
@ -92,6 +108,7 @@ export type TextInputContext = {
|
|||
export type PromptInputControllerProps = {
|
||||
textInput: TextInputContext;
|
||||
attachments: AttachmentsContext;
|
||||
mentions: MentionsContext;
|
||||
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
||||
__registerFileInput: (
|
||||
ref: RefObject<HTMLInputElement | null>,
|
||||
|
|
@ -105,6 +122,7 @@ const PromptInputController = createContext<PromptInputControllerProps | null>(
|
|||
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
|
||||
null
|
||||
);
|
||||
const ProviderMentionsContext = createContext<MentionsContext | null>(null);
|
||||
|
||||
export const usePromptInputController = () => {
|
||||
const ctx = useContext(PromptInputController);
|
||||
|
|
@ -133,8 +151,31 @@ export const useProviderAttachments = () => {
|
|||
const useOptionalProviderAttachments = () =>
|
||||
useContext(ProviderAttachmentsContext);
|
||||
|
||||
export const useProviderMentions = () => {
|
||||
const ctx = useContext(ProviderMentionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"Wrap your component inside <PromptInputProvider> to use useProviderMentions()."
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const useOptionalProviderMentions = () => useContext(ProviderMentionsContext);
|
||||
|
||||
export type KnowledgeFilesContext = {
|
||||
files: string[];
|
||||
};
|
||||
|
||||
const ProviderKnowledgeFilesContext = createContext<KnowledgeFilesContext | null>(null);
|
||||
|
||||
export const useProviderKnowledgeFiles = () => {
|
||||
return useContext(ProviderKnowledgeFilesContext);
|
||||
};
|
||||
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
knowledgeFiles?: string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
|
@ -143,6 +184,7 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
|||
*/
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
knowledgeFiles = [],
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
// ----- textInput state
|
||||
|
|
@ -227,6 +269,37 @@ export function PromptInputProvider({
|
|||
[attachmentFiles, add, remove, clear, openFileDialog]
|
||||
);
|
||||
|
||||
// ----- mentions state (for @ file mentions)
|
||||
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
|
||||
|
||||
const addMention = useCallback((path: string, displayName: string) => {
|
||||
setMentionsList((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((m) => m.path === path)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, { id: nanoid(), path, displayName }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeMention = useCallback((id: string) => {
|
||||
setMentionsList((prev) => prev.filter((m) => m.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearMentions = useCallback(() => {
|
||||
setMentionsList([]);
|
||||
}, []);
|
||||
|
||||
const mentions = useMemo<MentionsContext>(
|
||||
() => ({
|
||||
mentions: mentionsList,
|
||||
addMention,
|
||||
removeMention,
|
||||
clearMentions,
|
||||
}),
|
||||
[mentionsList, addMention, removeMention, clearMentions]
|
||||
);
|
||||
|
||||
const __registerFileInput = useCallback(
|
||||
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
|
||||
fileInputRef.current = ref.current;
|
||||
|
|
@ -243,15 +316,25 @@ export function PromptInputProvider({
|
|||
clear: clearInput,
|
||||
},
|
||||
attachments,
|
||||
mentions,
|
||||
__registerFileInput,
|
||||
}),
|
||||
[textInput, clearInput, attachments, __registerFileInput]
|
||||
[textInput, clearInput, attachments, mentions, __registerFileInput]
|
||||
);
|
||||
|
||||
const knowledgeFilesContext = useMemo<KnowledgeFilesContext>(
|
||||
() => ({ files: knowledgeFiles }),
|
||||
[knowledgeFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<PromptInputController.Provider value={controller}>
|
||||
<ProviderAttachmentsContext.Provider value={attachments}>
|
||||
{children}
|
||||
<ProviderMentionsContext.Provider value={mentions}>
|
||||
<ProviderKnowledgeFilesContext.Provider value={knowledgeFilesContext}>
|
||||
{children}
|
||||
</ProviderKnowledgeFilesContext.Provider>
|
||||
</ProviderMentionsContext.Provider>
|
||||
</ProviderAttachmentsContext.Provider>
|
||||
</PromptInputController.Provider>
|
||||
);
|
||||
|
|
@ -824,10 +907,66 @@ export const PromptInputTextarea = ({
|
|||
}: PromptInputTextareaProps) => {
|
||||
const controller = useOptionalPromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const mentionsCtx = useOptionalProviderMentions();
|
||||
const knowledgeFilesCtx = useProviderKnowledgeFiles();
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentValue = controller?.textInput.value ?? "";
|
||||
const knowledgeFiles = knowledgeFilesCtx?.files ?? [];
|
||||
|
||||
const { activeMention, cursorCoords } = useMentionDetection(
|
||||
textareaRef,
|
||||
currentValue,
|
||||
knowledgeFiles.length > 0
|
||||
);
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(path: string, displayName: string) => {
|
||||
if (!controller || !activeMention) return;
|
||||
|
||||
// Calculate the text before and after the @query
|
||||
const currentText = controller.textInput.value;
|
||||
const beforeAt = currentText.substring(0, activeMention.triggerIndex);
|
||||
const afterQuery = currentText.substring(
|
||||
activeMention.triggerIndex + 1 + activeMention.query.length
|
||||
);
|
||||
|
||||
// Replace @query with @displayName followed by a space
|
||||
const newText = `${beforeAt}@${displayName} ${afterQuery}`;
|
||||
controller.textInput.setInput(newText);
|
||||
|
||||
// Convert to knowledge path and add mention
|
||||
const fullPath = toKnowledgePath(path);
|
||||
if (fullPath && mentionsCtx) {
|
||||
mentionsCtx.addMention(fullPath, displayName);
|
||||
}
|
||||
|
||||
// Focus back on textarea
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[controller, activeMention, mentionsCtx]
|
||||
);
|
||||
|
||||
const handleMentionClose = useCallback(() => {
|
||||
// The popover handles its own closing
|
||||
}, []);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
// If mention popover is open, let it handle navigation keys
|
||||
if (activeMention && ["ArrowDown", "ArrowUp", "Tab"].includes(e.key)) {
|
||||
// Don't prevent default here - the popover handles this via document listener
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// If mention popover is open, Enter should select the item
|
||||
if (activeMention) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComposing || e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -860,6 +999,12 @@ export const PromptInputTextarea = ({
|
|||
attachments.remove(lastAttachment.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Close mention popover on Escape
|
||||
if (e.key === "Escape" && activeMention) {
|
||||
// Let the popover handle this
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
|
|
@ -899,17 +1044,31 @@ export const PromptInputTextarea = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
name="message"
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
{...controlledProps}
|
||||
/>
|
||||
<div ref={containerRef} className="relative contents">
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
name="message"
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
{...controlledProps}
|
||||
/>
|
||||
{knowledgeFiles.length > 0 && (
|
||||
<MentionPopover
|
||||
files={knowledgeFiles}
|
||||
query={activeMention?.query ?? ""}
|
||||
position={cursorCoords}
|
||||
containerRef={containerRef}
|
||||
onSelect={handleMentionSelect}
|
||||
onClose={handleMentionClose}
|
||||
open={Boolean(activeMention)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import {
|
|||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { type PromptInputMessage } from '@/components/ai-elements/prompt-input'
|
||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { useMentionDetection } from '@/hooks/use-mention-detection'
|
||||
import { MentionPopover } from '@/components/mention-popover'
|
||||
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
|
|
@ -107,10 +110,12 @@ interface ChatSidebarProps {
|
|||
isProcessing: boolean
|
||||
message: string
|
||||
onMessageChange: (message: string) => void
|
||||
onSubmit: (message: PromptInputMessage) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
contextUsage: LanguageModelUsage
|
||||
maxTokens: number
|
||||
usedTokens: number
|
||||
knowledgeFiles?: string[]
|
||||
selectedPath?: string | null
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
|
|
@ -124,12 +129,51 @@ export function ChatSidebar({
|
|||
message,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
knowledgeFiles = [],
|
||||
selectedPath,
|
||||
}: ChatSidebarProps) {
|
||||
const [width, setWidth] = useState(defaultWidth)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mentions, setMentions] = useState<FileMention[]>([])
|
||||
|
||||
const { activeMention, cursorCoords } = useMentionDetection(
|
||||
textareaRef,
|
||||
message,
|
||||
knowledgeFiles.length > 0
|
||||
)
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(path: string, displayName: string) => {
|
||||
if (!activeMention) return
|
||||
|
||||
const beforeAt = message.substring(0, activeMention.triggerIndex)
|
||||
const afterQuery = message.substring(
|
||||
activeMention.triggerIndex + 1 + activeMention.query.length
|
||||
)
|
||||
|
||||
const newText = `${beforeAt}@${displayName} ${afterQuery}`
|
||||
onMessageChange(newText)
|
||||
|
||||
const fullPath = toKnowledgePath(path)
|
||||
if (fullPath) {
|
||||
setMentions(prev => {
|
||||
if (prev.some(m => m.path === fullPath)) return prev
|
||||
return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }]
|
||||
})
|
||||
}
|
||||
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
[activeMention, message, onMessageChange]
|
||||
)
|
||||
|
||||
const handleMentionClose = useCallback(() => {
|
||||
// The popover handles its own closing
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -153,10 +197,24 @@ export function ChatSidebar({
|
|||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width])
|
||||
|
||||
// Auto-focus input when sidebar opens
|
||||
// Auto-focus textarea when sidebar opens and auto-populate with current file if applicable
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
textareaRef.current?.focus()
|
||||
|
||||
// Auto-populate with @currentfile if opening from a knowledge file
|
||||
if (selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md')) {
|
||||
// Only auto-populate if there's no existing message
|
||||
if (!message.trim()) {
|
||||
const displayName = wikiLabel(selectedPath)
|
||||
onMessageChange(`@${displayName} `)
|
||||
setMentions([{
|
||||
id: `mention-auto-${Date.now()}`,
|
||||
path: selectedPath,
|
||||
displayName,
|
||||
}])
|
||||
}
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
|
|
@ -164,14 +222,27 @@ export function ChatSidebar({
|
|||
const handleSubmit = () => {
|
||||
const trimmed = message.trim()
|
||||
if (trimmed && !isProcessing) {
|
||||
onSubmit({ text: trimmed, files: [] })
|
||||
onSubmit({ text: trimmed, files: [] }, mentions)
|
||||
setMentions([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
// If mention popover is open, let it handle navigation keys
|
||||
if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
// If mention popover is open, Enter should select the item
|
||||
if (activeMention) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -304,24 +375,25 @@ export function ChatSidebar({
|
|||
</Conversation>
|
||||
|
||||
{/* Input area - responsive to sidebar width, matches floating bar position exactly */}
|
||||
<div className="absolute bottom-6 left-14 right-6 z-10">
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-full shadow-xl px-4 py-2.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
<div className="absolute bottom-6 left-14 right-6 z-10" ref={containerRef}>
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-2xl shadow-xl px-4 py-2.5">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => onMessageChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
disabled={isProcessing}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50"
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-[1.5rem]"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all self-end",
|
||||
canSubmit
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
|
|
@ -330,6 +402,17 @@ export function ChatSidebar({
|
|||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{knowledgeFiles.length > 0 && (
|
||||
<MentionPopover
|
||||
files={knowledgeFiles}
|
||||
query={activeMention?.query ?? ''}
|
||||
position={cursorCoords}
|
||||
containerRef={containerRef}
|
||||
onSelect={handleMentionSelect}
|
||||
onClose={handleMentionClose}
|
||||
open={Boolean(activeMention)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
152
apps/x/apps/renderer/src/components/mention-popover.tsx
Normal file
152
apps/x/apps/renderer/src/components/mention-popover.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useMemo, useEffect, useState, useCallback } from 'react'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { wikiLabel, stripKnowledgePrefix } from '@/lib/wiki-links'
|
||||
import { FileTextIcon } from 'lucide-react'
|
||||
import type { CaretCoordinates } from '@/lib/textarea-caret'
|
||||
|
||||
interface MentionPopoverProps {
|
||||
files: string[]
|
||||
query: string
|
||||
position: CaretCoordinates | null
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
onSelect: (path: string, displayName: string) => void
|
||||
onClose: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FILES = 8
|
||||
|
||||
export function MentionPopover({
|
||||
files,
|
||||
query,
|
||||
position,
|
||||
containerRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
open,
|
||||
}: MentionPopoverProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Filter files based on query
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!query) return files.slice(0, MAX_VISIBLE_FILES)
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return files
|
||||
.filter((path) => {
|
||||
const label = wikiLabel(path).toLowerCase()
|
||||
const normalized = stripKnowledgePrefix(path).toLowerCase()
|
||||
return label.includes(lowerQuery) || normalized.includes(lowerQuery)
|
||||
})
|
||||
.slice(0, MAX_VISIBLE_FILES)
|
||||
}, [files, query])
|
||||
|
||||
// Reset selection when filtered list changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [filteredFiles.length, query])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!open) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredFiles.length)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredFiles.length) % filteredFiles.length)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (filteredFiles[selectedIndex]) {
|
||||
const path = filteredFiles[selectedIndex]
|
||||
onSelect(path, wikiLabel(path))
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
break
|
||||
case 'Tab':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (filteredFiles[selectedIndex]) {
|
||||
const path = filteredFiles[selectedIndex]
|
||||
onSelect(path, wikiLabel(path))
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[open, filteredFiles, selectedIndex, onSelect, onClose]
|
||||
)
|
||||
|
||||
// Attach keyboard listener
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
// Use capture phase to intercept before textarea handles it
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
if (!open || !position || filteredFiles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
className="mention-popover-anchor"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: position.left,
|
||||
top: position.top + position.height + 4,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-64 p-1"
|
||||
align="start"
|
||||
side="bottom"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<CommandEmpty>No files found</CommandEmpty>
|
||||
) : (
|
||||
filteredFiles.map((path, index) => (
|
||||
<CommandItem
|
||||
key={path}
|
||||
value={path}
|
||||
onSelect={() => onSelect(path, wikiLabel(path))}
|
||||
className={index === selectedIndex ? 'bg-accent' : ''}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<FileTextIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{wikiLabel(path)}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -144,12 +144,13 @@ function InputGroupInput({
|
|||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
const InputGroupTextarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Textarea
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
|
|
@ -158,7 +159,8 @@ function InputGroupTextarea({
|
|||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
InputGroupTextarea.displayName = "InputGroupTextarea"
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
|
|
|
|||
111
apps/x/apps/renderer/src/hooks/use-mention-detection.ts
Normal file
111
apps/x/apps/renderer/src/hooks/use-mention-detection.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useState, useEffect, useCallback, type RefObject } from 'react'
|
||||
import { getCaretCoordinates, type CaretCoordinates } from '@/lib/textarea-caret'
|
||||
|
||||
export interface ActiveMention {
|
||||
query: string
|
||||
triggerIndex: number
|
||||
}
|
||||
|
||||
export interface UseMentionDetectionResult {
|
||||
activeMention: ActiveMention | null
|
||||
cursorCoords: CaretCoordinates | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that detects when a user types @ in a textarea and provides
|
||||
* the query string and cursor coordinates for showing a mention popover.
|
||||
*/
|
||||
export function useMentionDetection(
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
||||
value: string,
|
||||
enabled: boolean
|
||||
): UseMentionDetectionResult {
|
||||
const [activeMention, setActiveMention] = useState<ActiveMention | null>(null)
|
||||
const [cursorCoords, setCursorCoords] = useState<CaretCoordinates | null>(null)
|
||||
|
||||
const detectMention = useCallback(() => {
|
||||
if (!enabled) {
|
||||
setActiveMention(null)
|
||||
setCursorCoords(null)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) {
|
||||
setActiveMention(null)
|
||||
setCursorCoords(null)
|
||||
return
|
||||
}
|
||||
|
||||
const cursorPos = textarea.selectionStart
|
||||
const textBeforeCursor = value.substring(0, cursorPos)
|
||||
|
||||
// Find the last @ symbol before cursor
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setActiveMention(null)
|
||||
setCursorCoords(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if @ is the start of an email (has non-whitespace before it)
|
||||
if (lastAtIndex > 0) {
|
||||
const charBefore = textBeforeCursor[lastAtIndex - 1]
|
||||
// If char before @ is not whitespace or newline, it's likely an email
|
||||
if (charBefore && !/[\s\n]/.test(charBefore)) {
|
||||
setActiveMention(null)
|
||||
setCursorCoords(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get text between @ and cursor
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1)
|
||||
|
||||
// If there's a space or newline after @, the mention is closed
|
||||
if (/[\s\n]/.test(textAfterAt)) {
|
||||
setActiveMention(null)
|
||||
setCursorCoords(null)
|
||||
return
|
||||
}
|
||||
|
||||
// We have an active mention
|
||||
const query = textAfterAt
|
||||
setActiveMention({
|
||||
query,
|
||||
triggerIndex: lastAtIndex,
|
||||
})
|
||||
|
||||
// Calculate cursor coordinates
|
||||
const coords = getCaretCoordinates(textarea, lastAtIndex)
|
||||
setCursorCoords(coords)
|
||||
}, [textareaRef, value, enabled])
|
||||
|
||||
// Detect mention on value or cursor position change
|
||||
useEffect(() => {
|
||||
detectMention()
|
||||
}, [detectMention])
|
||||
|
||||
// Also detect on selection change (cursor movement)
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || !enabled) return
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
detectMention()
|
||||
}
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionchange', handleSelectionChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||
}
|
||||
}, [textareaRef, enabled, detectMention])
|
||||
|
||||
return {
|
||||
activeMention,
|
||||
cursorCoords,
|
||||
}
|
||||
}
|
||||
121
apps/x/apps/renderer/src/lib/textarea-caret.ts
Normal file
121
apps/x/apps/renderer/src/lib/textarea-caret.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Get the pixel coordinates of a position within a textarea.
|
||||
* Uses the mirror div technique to calculate cursor position.
|
||||
*/
|
||||
|
||||
// Properties that affect text layout and must be copied to the mirror div
|
||||
const PROPERTIES_TO_COPY = [
|
||||
'direction',
|
||||
'boxSizing',
|
||||
'width',
|
||||
'height',
|
||||
'overflowX',
|
||||
'overflowY',
|
||||
'borderTopWidth',
|
||||
'borderRightWidth',
|
||||
'borderBottomWidth',
|
||||
'borderLeftWidth',
|
||||
'borderStyle',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'fontStyle',
|
||||
'fontVariant',
|
||||
'fontWeight',
|
||||
'fontStretch',
|
||||
'fontSize',
|
||||
'fontSizeAdjust',
|
||||
'lineHeight',
|
||||
'fontFamily',
|
||||
'textAlign',
|
||||
'textTransform',
|
||||
'textIndent',
|
||||
'textDecoration',
|
||||
'letterSpacing',
|
||||
'wordSpacing',
|
||||
'tabSize',
|
||||
'MozTabSize',
|
||||
] as const
|
||||
|
||||
export interface CaretCoordinates {
|
||||
top: number
|
||||
left: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function getCaretCoordinates(
|
||||
textarea: HTMLTextAreaElement,
|
||||
position: number
|
||||
): CaretCoordinates {
|
||||
// Create a mirror div to measure text position
|
||||
const div = document.createElement('div')
|
||||
div.id = 'textarea-caret-position-mirror-div'
|
||||
document.body.appendChild(div)
|
||||
|
||||
const style = div.style
|
||||
const computed = window.getComputedStyle(textarea)
|
||||
|
||||
// Default return value
|
||||
const defaultCoords: CaretCoordinates = { top: 0, left: 0, height: 0 }
|
||||
|
||||
// Position offscreen
|
||||
style.whiteSpace = 'pre-wrap'
|
||||
style.wordWrap = 'break-word'
|
||||
style.position = 'absolute'
|
||||
style.visibility = 'hidden'
|
||||
style.overflow = 'hidden'
|
||||
|
||||
// Copy styles from textarea to mirror div
|
||||
for (const prop of PROPERTIES_TO_COPY) {
|
||||
style[prop as keyof CSSStyleDeclaration] = computed[prop as keyof CSSStyleDeclaration] as string
|
||||
}
|
||||
|
||||
// Firefox-specific handling
|
||||
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
|
||||
if (isFirefox) {
|
||||
if (textarea.scrollHeight > parseInt(computed.height)) {
|
||||
style.overflowY = 'scroll'
|
||||
}
|
||||
} else {
|
||||
style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
// Set the text content up to the position
|
||||
div.textContent = textarea.value.substring(0, position)
|
||||
|
||||
// Create a span at the cursor position
|
||||
const span = document.createElement('span')
|
||||
// Add a zero-width space to ensure the span has height
|
||||
span.textContent = textarea.value.substring(position) || '\u200B'
|
||||
div.appendChild(span)
|
||||
|
||||
try {
|
||||
const coordinates: CaretCoordinates = {
|
||||
top: span.offsetTop + parseInt(computed.borderTopWidth) - textarea.scrollTop,
|
||||
left: span.offsetLeft + parseInt(computed.borderLeftWidth) - textarea.scrollLeft,
|
||||
height: parseInt(computed.lineHeight) || parseInt(computed.fontSize) * 1.2,
|
||||
}
|
||||
|
||||
return coordinates
|
||||
} finally {
|
||||
document.body.removeChild(div)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get absolute coordinates relative to the viewport
|
||||
*/
|
||||
export function getCaretAbsoluteCoordinates(
|
||||
textarea: HTMLTextAreaElement,
|
||||
position: number
|
||||
): CaretCoordinates {
|
||||
const relative = getCaretCoordinates(textarea, position)
|
||||
const rect = textarea.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
top: rect.top + relative.top,
|
||||
left: rect.left + relative.left,
|
||||
height: relative.height,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue