diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 895a0e90..e780bd72 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,8 +6,10 @@ import type { ChatStatus, LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { MessageSquare, CheckIcon, LoaderIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon } from 'lucide-react'; import { MarkdownEditor } from './components/markdown-editor'; +import { ChatButton } from './components/chat-button'; +import { ChatSidebar } from './components/chat-sidebar'; import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarIcon } from '@/components/sidebar-icon'; @@ -286,6 +288,7 @@ function App() { }) const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') const [graphError, setGraphError] = useState(null) + const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(false) // Auto-save state const [isSaving, setIsSaving] = useState(false) @@ -760,7 +763,9 @@ function App() { onOpen: (path: string) => { void openWikiLink(path) }, - onCreate: (path: string) => ensureWikiFile(path), + onCreate: (path: string) => { + void ensureWikiFile(path) + }, }), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile]) useEffect(() => { @@ -998,31 +1003,19 @@ function App() { {headerTitle} {selectedPath && ( - <> - {/* Save status indicator */} -
- {isSaving ? ( - <> - - Saving... - - ) : lastSaved ? ( - <> - - Saved - - ) : null} -
- - +
+ {isSaving ? ( + <> + + Saving... + + ) : lastSaved ? ( + <> + + Saved + + ) : null} +
)} {!selectedPath && isGraphOpen && ( + ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx new file mode 100644 index 00000000..787ee963 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -0,0 +1,285 @@ +import { X } from 'lucide-react' +import type { ChatStatus, LanguageModelUsage, ToolUIPart } from 'ai' +import { Button } from '@/components/ui/button' +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from '@/components/ai-elements/conversation' +import { + Message, + MessageContent, + MessageResponse, +} from '@/components/ai-elements/message' +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from '@/components/ai-elements/prompt-input' +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 { + Context, + ContextCacheUsage, + ContextContent, + ContextContentBody, + ContextContentHeader, + ContextInputUsage, + ContextOutputUsage, + ContextReasoningUsage, + ContextTrigger, +} from '@/components/ai-elements/context' + +interface ChatMessage { + id: string + role: 'user' | 'assistant' + content: string + timestamp: number +} + +interface ToolCall { + id: string + name: string + input: ToolUIPart['input'] + result?: ToolUIPart['output'] + status: 'pending' | 'running' | 'completed' | 'error' + timestamp: number +} + +interface ReasoningBlock { + id: string + content: string + timestamp: number +} + +type ConversationItem = ChatMessage | ToolCall | ReasoningBlock + +type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' + +const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item +const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item +const isReasoningBlock = (item: ConversationItem): item is ReasoningBlock => + 'content' in item && !('role' in item) && !('name' in item) + +const toToolState = (status: ToolCall['status']): ToolState => { + switch (status) { + case 'pending': + return 'input-streaming' + case 'running': + return 'input-available' + case 'completed': + return 'output-available' + case 'error': + return 'output-error' + default: + return 'input-available' + } +} + +const normalizeToolInput = (input: ToolCall['input'] | string | undefined): ToolCall['input'] => { + if (input === undefined || input === null) return {} + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) return {} + try { + return JSON.parse(trimmed) + } catch { + return input + } + } + return input +} + +const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: ToolCall['status']) => { + if (output === undefined || output === null) { + return status === 'completed' ? 'No output returned.' : null + } + if (output === '') return '(empty output)' + if (typeof output === 'boolean' || typeof output === 'number') return String(output) + return output +} + +interface ChatSidebarProps { + width?: number + onClose: () => void + conversation: ConversationItem[] + currentAssistantMessage: string + currentReasoning: string + isProcessing: boolean + message: string + onMessageChange: (message: string) => void + onSubmit: (message: PromptInputMessage) => void + contextUsage: LanguageModelUsage + maxTokens: number + usedTokens: number +} + +export function ChatSidebar({ + width = 400, + onClose, + conversation, + currentAssistantMessage, + currentReasoning, + isProcessing, + message, + onMessageChange, + onSubmit, + contextUsage, + maxTokens, + usedTokens, +}: ChatSidebarProps) { + const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning + const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' + const canSubmit = Boolean(message.trim()) && !isProcessing + + const renderConversationItem = (item: ConversationItem) => { + if (isChatMessage(item)) { + return ( + + + {item.role === 'assistant' ? ( + {item.content} + ) : ( + item.content + )} + + + ) + } + + if (isToolCall(item)) { + const errorText = item.status === 'error' ? 'Tool error' : '' + const output = normalizeToolOutput(item.result, item.status) + const input = normalizeToolInput(item.input) + return ( + + + + + {output !== null ? ( + + ) : null} + + + ) + } + + if (isReasoningBlock(item)) { + return ( + + + {item.content} + + ) + } + + return null + } + + return ( +
+ {/* Header */} +
+ Chat + +
+ + {/* Conversation area */} +
+ + + {!hasConversation ? ( + +
+ Ask anything... +
+
+ ) : ( + <> + {conversation.map(item => renderConversationItem(item))} + + {currentReasoning && ( + + + {currentReasoning} + + )} + + {currentAssistantMessage && ( + + + {currentAssistantMessage} + + + )} + + {isProcessing && !currentAssistantMessage && !currentReasoning && ( + + + Thinking... + + + )} + + )} +
+ +
+ + {/* Prompt input */} +
+ + + onMessageChange(e.target.value)} + placeholder="Type your message..." + disabled={isProcessing} + className="min-h-[60px] max-h-[120px]" + /> + + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 8ddb5492..716f432d 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -75,7 +75,8 @@ export function MarkdownEditor({ content: '', onUpdate: ({ editor }) => { if (isInternalUpdate.current) return - const markdown = editor.storage.markdown.getMarkdown() + const storage = editor.storage as unknown as Record string }> + const markdown = storage.markdown?.getMarkdown?.() ?? '' onChange(markdown) }, editorProps: { @@ -173,7 +174,8 @@ export function MarkdownEditor({ // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { - const currentContent = editor.storage.markdown?.getMarkdown() || '' + const storage = editor.storage as unknown as Record string }> + const currentContent = storage.markdown?.getMarkdown?.() ?? '' if (currentContent !== content) { isInternalUpdate.current = true editor.commands.setContent(content) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 69b214fb..bc03888e 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -3,7 +3,6 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" import { - ArrowDownAZ, CalendarDays, ChevronRight, ChevronsDownUp, @@ -258,7 +257,6 @@ function KnowledgeSection({ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, { icon: Network, label: "Graph View", action: () => actions.openGraph() }, - { icon: ArrowDownAZ, label: "Sort", action: () => {} }, ] return ( diff --git a/apps/x/apps/renderer/src/extensions/wiki-link.ts b/apps/x/apps/renderer/src/extensions/wiki-link.ts index 5b7c5c4d..6fa1446d 100644 --- a/apps/x/apps/renderer/src/extensions/wiki-link.ts +++ b/apps/x/apps/renderer/src/extensions/wiki-link.ts @@ -119,12 +119,12 @@ export const WikiLink = Node.create({ addStorage() { return { markdown: { - serialize(state, node) { + serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) { const path = node.attrs.path ?? '' state.write(`[[${path}]]`) }, parse: { - updateDOM(element) { + updateDOM(element: HTMLElement) { replaceWikiLinksInTextNodes(element) }, },