adding sidebar

This commit is contained in:
tusharmagar 2026-01-14 10:54:37 +05:30 committed by Ramnique Singh
parent b293d83edd
commit 73ba7fee99
6 changed files with 352 additions and 33 deletions

View file

@ -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<string | null>(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}
</span>
{selectedPath && (
<>
{/* Save status indicator */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{isSaving ? (
<>
<LoaderIcon className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
) : lastSaved ? (
<>
<CheckIcon className="h-3 w-3 text-green-500" />
<span>Saved</span>
</>
) : null}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedPath(null)}
className="ml-auto text-foreground"
>
<MessageSquare className="h-4 w-4 mr-2" />
Back to Chat
</Button>
</>
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-auto">
{isSaving ? (
<>
<LoaderIcon className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
) : lastSaved ? (
<>
<CheckIcon className="h-3 w-3 text-green-500" />
<span>Saved</span>
</>
) : null}
</div>
)}
{!selectedPath && isGraphOpen && (
<Button
@ -1150,7 +1143,30 @@ function App() {
</div>
)}
</SidebarInset>
{/* Chat sidebar - shown when viewing files/graph */}
{isChatSidebarOpen && (selectedPath || isGraphOpen) && (
<ChatSidebar
width={400}
onClose={() => setIsChatSidebarOpen(false)}
conversation={conversation}
currentAssistantMessage={currentAssistantMessage}
currentReasoning={currentReasoning}
isProcessing={isProcessing}
message={message}
onMessageChange={setMessage}
onSubmit={handlePromptSubmit}
contextUsage={contextUsage}
maxTokens={maxTokens}
usedTokens={usedTokens}
/>
)}
</SidebarProvider>
{/* Floating chat button - shown when viewing files/graph and chat sidebar is closed */}
{(selectedPath || isGraphOpen) && !isChatSidebarOpen && (
<ChatButton onClick={() => setIsChatSidebarOpen(true)} />
)}
</div>
</SidebarSectionProvider>
</TooltipProvider>

View file

@ -0,0 +1,18 @@
import { MessageSquare } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ChatButtonProps {
onClick: () => void
}
export function ChatButton({ onClick }: ChatButtonProps) {
return (
<Button
onClick={onClick}
size="icon"
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-lg hover:shadow-xl transition-shadow z-50"
>
<MessageSquare className="h-5 w-5" />
</Button>
)
}

View file

@ -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 (
<Message key={item.id} from={item.role}>
<MessageContent>
{item.role === 'assistant' ? (
<MessageResponse>{item.content}</MessageResponse>
) : (
item.content
)}
</MessageContent>
</Message>
)
}
if (isToolCall(item)) {
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
<Tool key={item.id}>
<ToolHeader
title={item.name}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolInput input={input} />
{output !== null ? (
<ToolOutput output={output} errorText={errorText} />
) : null}
</ToolContent>
</Tool>
)
}
if (isReasoningBlock(item)) {
return (
<Reasoning key={item.id}>
<ReasoningTrigger />
<ReasoningContent>{item.content}</ReasoningContent>
</Reasoning>
)
}
return null
}
return (
<div
className="flex flex-col border-l border-border bg-background shrink-0"
style={{ width }}
>
{/* Header */}
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border px-3">
<span className="text-sm font-medium">Chat</span>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</header>
{/* Conversation area */}
<div className="flex min-h-0 flex-1 flex-col">
<Conversation className="relative flex-1 overflow-y-auto">
<ConversationContent className={hasConversation ? "px-3 pb-24" : "px-3 min-h-full items-center justify-center"}>
{!hasConversation ? (
<ConversationEmptyState className="h-auto">
<div className="text-lg font-medium text-muted-foreground">
Ask anything...
</div>
</ConversationEmptyState>
) : (
<>
{conversation.map(item => renderConversationItem(item))}
{currentReasoning && (
<Reasoning isStreaming>
<ReasoningTrigger />
<ReasoningContent>{currentReasoning}</ReasoningContent>
</Reasoning>
)}
{currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse>{currentAssistantMessage}</MessageResponse>
</MessageContent>
</Message>
)}
{isProcessing && !currentAssistantMessage && !currentReasoning && (
<Message from="assistant">
<MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer>
</MessageContent>
</Message>
)}
</>
)}
</ConversationContent>
<ConversationScrollButton className="bottom-20" />
</Conversation>
{/* Prompt input */}
<div className="relative sticky bottom-0 z-10 bg-background pb-3 pt-4 px-3 border-t border-border">
<PromptInput onSubmit={onSubmit}>
<PromptInputBody>
<PromptInputTextarea
value={message}
onChange={(e) => onMessageChange(e.target.value)}
placeholder="Type your message..."
disabled={isProcessing}
className="min-h-[60px] max-h-[120px]"
/>
</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>
</div>
</div>
</div>
)
}

View file

@ -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, { getMarkdown?: () => 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, { getMarkdown?: () => string }>
const currentContent = storage.markdown?.getMarkdown?.() ?? ''
if (currentContent !== content) {
isInternalUpdate.current = true
editor.commands.setContent(content)

View file

@ -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 (

View file

@ -119,12 +119,12 @@ export const WikiLink = Node.create<WikiLinkOptions>({
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)
},
},