mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 03:42:38 +02:00
adding sidebar
This commit is contained in:
parent
b293d83edd
commit
73ba7fee99
6 changed files with 352 additions and 33 deletions
|
|
@ -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>
|
||||
|
|
|
|||
18
apps/x/apps/renderer/src/components/chat-button.tsx
Normal file
18
apps/x/apps/renderer/src/components/chat-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
285
apps/x/apps/renderer/src/components/chat-sidebar.tsx
Normal file
285
apps/x/apps/renderer/src/components/chat-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue