mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 03:42:38 +02:00
refactor sidebar structure and components; replace AppSidebar with SidebarContentPanel and SidebarIcon for improved organization and functionality
removed themes
This commit is contained in:
parent
1ce2b3201f
commit
83f426bd98
12 changed files with 991 additions and 906 deletions
|
|
@ -89,71 +89,71 @@
|
|||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--background: var(--bg-color, oklch(1 0 0));
|
||||
--foreground: var(--text-color, oklch(0.145 0 0));
|
||||
--card: var(--bg-color, oklch(1 0 0));
|
||||
--card-foreground: var(--text-color, oklch(0.145 0 0));
|
||||
--popover: var(--bg-color, oklch(1 0 0));
|
||||
--popover-foreground: var(--text-color, oklch(0.145 0 0));
|
||||
--primary: var(--main-color, oklch(0.205 0 0));
|
||||
--primary-foreground: var(--bg-color, oklch(0.985 0 0));
|
||||
--secondary: var(--sub-alt-color, oklch(0.97 0 0));
|
||||
--secondary-foreground: var(--text-color, oklch(0.205 0 0));
|
||||
--muted: var(--sub-alt-color, oklch(0.97 0 0));
|
||||
--muted-foreground: var(--sub-color, oklch(0.556 0 0));
|
||||
--accent: var(--sub-color, oklch(0.97 0 0));
|
||||
--accent-foreground: var(--text-color, oklch(0.205 0 0));
|
||||
--destructive: var(--error-color, oklch(0.577 0.245 27.325));
|
||||
--border: var(--sub-alt-color, oklch(0.922 0 0));
|
||||
--input: var(--sub-alt-color, oklch(0.922 0 0));
|
||||
--ring: var(--main-color, oklch(0.708 0 0));
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar: var(--bg-color, oklch(0.985 0 0));
|
||||
--sidebar-foreground: var(--text-color, oklch(0.145 0 0));
|
||||
--sidebar-primary: var(--main-color, oklch(0.205 0 0));
|
||||
--sidebar-primary-foreground: var(--bg-color, oklch(0.985 0 0));
|
||||
--sidebar-accent: var(--sub-color, oklch(0.97 0 0));
|
||||
--sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0));
|
||||
--sidebar-border: var(--sub-alt-color, oklch(0.922 0 0));
|
||||
--sidebar-ring: var(--main-color, oklch(0.708 0 0));
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--background: var(--bg-color, oklch(0.145 0 0));
|
||||
--foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--card: var(--bg-color, oklch(0.205 0 0));
|
||||
--card-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--popover: var(--bg-color, oklch(0.205 0 0));
|
||||
--popover-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--primary: var(--main-color, oklch(0.922 0 0));
|
||||
--primary-foreground: var(--bg-color, oklch(0.205 0 0));
|
||||
--secondary: var(--sub-alt-color, oklch(0.269 0 0));
|
||||
--secondary-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--muted: var(--sub-alt-color, oklch(0.269 0 0));
|
||||
--muted-foreground: var(--sub-color, oklch(0.708 0 0));
|
||||
--accent: var(--sub-color, oklch(0.269 0 0));
|
||||
--accent-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--destructive: var(--error-color, oklch(0.704 0.191 22.216));
|
||||
--border: var(--sub-alt-color, oklch(1 0 0 / 10%));
|
||||
--input: var(--sub-alt-color, oklch(1 0 0 / 15%));
|
||||
--ring: var(--main-color, oklch(0.556 0 0));
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar: var(--bg-color, oklch(0.205 0 0));
|
||||
--sidebar-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--sidebar-primary: var(--main-color, oklch(0.488 0.243 264.376));
|
||||
--sidebar-primary-foreground: var(--bg-color, oklch(0.985 0 0));
|
||||
--sidebar-accent: var(--sub-color, oklch(0.269 0 0));
|
||||
--sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0));
|
||||
--sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%));
|
||||
--sidebar-ring: var(--main-color, oklch(0.556 0 0));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { workspace } from '@x/shared';
|
||||
import { RunEvent } from '@x/shared/src/runs.js';
|
||||
|
|
@ -6,7 +7,9 @@ import './App.css'
|
|||
import z from 'zod';
|
||||
import { Button } from './components/ui/button';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SidebarIcon } from '@/components/sidebar-icon';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
|
|
@ -41,20 +44,13 @@ import {
|
|||
ContextReasoningUsage,
|
||||
ContextTrigger,
|
||||
} from '@/components/ai-elements/context';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
|
|
@ -115,6 +111,8 @@ const toToolState = (status: ToolCall['status']): ToolState => {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||
|
||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||
|
|
@ -200,15 +198,35 @@ function buildTree(entries: DirEntry[]): TreeNode[] {
|
|||
return sortNodes(roots)
|
||||
}
|
||||
|
||||
// Sample chat history (will be replaced with real data later)
|
||||
const chatHistory = [
|
||||
{
|
||||
id: 'project-kickoff',
|
||||
title: 'Project kickoff',
|
||||
preview: 'Scope, roles, and milestones.',
|
||||
time: 'Today',
|
||||
},
|
||||
{
|
||||
id: 'design-review',
|
||||
title: 'Design review',
|
||||
preview: 'UI polish and sidebar UX.',
|
||||
time: 'Yesterday',
|
||||
},
|
||||
{
|
||||
id: 'tools-audit',
|
||||
title: 'Tools audit',
|
||||
preview: 'MCP inventory and tool gaps.',
|
||||
time: 'Mon',
|
||||
},
|
||||
]
|
||||
|
||||
function App() {
|
||||
// Sidebar view state
|
||||
const [activeSidebarView, setActiveSidebarView] = useState<'files' | 'accounts'>('files')
|
||||
|
||||
// File browser state
|
||||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
// File browser state (for Knowledge section)
|
||||
const [_knowledgeContent, setKnowledgeContent] = useState<string>('')
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
// Chat state
|
||||
const [message, setMessage] = useState<string>('')
|
||||
|
|
@ -235,23 +253,42 @@ function App() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load initial tree
|
||||
// Load knowledge file content
|
||||
const loadKnowledge = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: 'knowledge',
|
||||
encoding: 'utf8'
|
||||
})
|
||||
return result.data
|
||||
} catch (err) {
|
||||
console.error('Failed to load knowledge file:', err)
|
||||
return ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load initial tree and knowledge content
|
||||
useEffect(() => {
|
||||
async function process() {
|
||||
const tree = await loadDirectory();
|
||||
setTree(tree)
|
||||
const [treeData, content] = await Promise.all([
|
||||
loadDirectory(),
|
||||
loadKnowledge()
|
||||
]);
|
||||
setTree(treeData)
|
||||
setKnowledgeContent(content)
|
||||
}
|
||||
process();
|
||||
}, [loadDirectory])
|
||||
}, [loadDirectory, loadKnowledge])
|
||||
|
||||
// Listen to workspace change events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('workspace:didChange', () => {
|
||||
// Reload tree on any change
|
||||
// Reload tree and knowledge on any change
|
||||
loadDirectory().then(result => setTree(result))
|
||||
loadKnowledge().then(result => setKnowledgeContent(result))
|
||||
})
|
||||
return cleanup
|
||||
}, [loadDirectory])
|
||||
}, [loadDirectory, loadKnowledge])
|
||||
|
||||
// Load file content when selected
|
||||
useEffect(() => {
|
||||
|
|
@ -346,7 +383,7 @@ function App() {
|
|||
setCurrentAssistantMessage(currentMsg => {
|
||||
if (currentMsg) {
|
||||
setConversation(prev => {
|
||||
const exists = prev.some(m =>
|
||||
const exists = prev.some(m =>
|
||||
m.id === event.messageId && 'role' in m && m.role === 'assistant'
|
||||
)
|
||||
if (exists) return prev
|
||||
|
|
@ -564,152 +601,151 @@ function App() {
|
|||
} as LanguageModelUsage
|
||||
|
||||
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
|
||||
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
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(16rem + var(--sidebar-width-icon))",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
activeView={activeSidebarView}
|
||||
onViewChange={setActiveSidebarView}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="bg-background sticky top-0 z-20 flex shrink-0 items-center gap-2 border-b p-4 shadow-sm">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href="#">Workspace</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="ask-ai">
|
||||
<div className="flex min-h-svh w-full">
|
||||
{/* Icon sidebar - always visible, fixed position */}
|
||||
<SidebarIcon />
|
||||
|
||||
{/* Spacer for the fixed icon sidebar */}
|
||||
<div className="w-14 shrink-0" />
|
||||
|
||||
{/* Content sidebar with SidebarProvider for collapse functionality */}
|
||||
<SidebarProvider
|
||||
style={{
|
||||
"--sidebar-offset": "3.5rem",
|
||||
"--sidebar-width": `${DEFAULT_SIDEBAR_WIDTH}px`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<SidebarContentPanel
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
chats={chatHistory}
|
||||
/>
|
||||
<SidebarInset>
|
||||
{/* Header with sidebar trigger */}
|
||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{selectedPath ? selectedPath : 'Chat'}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</header>
|
||||
|
||||
{selectedPath ? (
|
||||
<>
|
||||
<div className="border-b border-border p-2 bg-muted flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">{selectedPath}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedPath(null)}
|
||||
className="text-foreground"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Back to Chat
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
|
||||
{fileContent || 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Conversation className="relative flex-1 overflow-y-auto">
|
||||
<ConversationContent className="mx-auto w-full max-w-4xl pb-28">
|
||||
{!hasConversation ? (
|
||||
<ConversationEmptyState
|
||||
description="Type a message below to begin chatting with the agent."
|
||||
icon={<MessageSquare className="size-6" />}
|
||||
title="Start a conversation"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
{selectedPath && (
|
||||
<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>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton className="bottom-24" />
|
||||
</Conversation>
|
||||
</header>
|
||||
|
||||
<div className="relative 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-gradient-to-t from-background to-transparent" />
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
<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>
|
||||
{selectedPath ? (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="text-sm font-mono text-foreground whitespace-pre-wrap">
|
||||
{fileContent || 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Conversation className="relative flex-1 overflow-y-auto">
|
||||
<ConversationContent className={conversationContentClassName}>
|
||||
{!hasConversation ? (
|
||||
<ConversationEmptyState className="h-auto">
|
||||
<div className="text-4xl font-semibold tracking-tight text-foreground/80 sm:text-5xl md:text-6xl">
|
||||
RowboatX
|
||||
</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-24" />
|
||||
</Conversation>
|
||||
|
||||
<div className="relative 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-gradient-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</SidebarSectionProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronRight, File, Folder, Plug } from "lucide-react"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ConnectedAccountsSidebar } from "@/components/connected-accounts-sidebar"
|
||||
|
||||
type TreeNode = {
|
||||
name: string
|
||||
path: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
type SidebarView = 'files' | 'accounts'
|
||||
|
||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: 'file' | 'dir') => void
|
||||
activeView: SidebarView
|
||||
onViewChange: (view: SidebarView) => void
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
activeView,
|
||||
onViewChange,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
const { setOpen } = useSidebar()
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
|
||||
{...props}
|
||||
>
|
||||
{/* This is the first sidebar */}
|
||||
{/* We disable collapsible and adjust width to icon. */}
|
||||
{/* This will make the sidebar appear as icons. */}
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="w-[calc(var(--sidebar-width-icon)+1px)]! border-r"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="px-1.5 md:px-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Files"
|
||||
onClick={() => {
|
||||
onViewChange('files')
|
||||
setOpen(true)
|
||||
}}
|
||||
isActive={activeView === 'files'}
|
||||
className="px-2.5 md:px-2"
|
||||
>
|
||||
<File />
|
||||
<span>Files</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Connected Accounts"
|
||||
onClick={() => {
|
||||
onViewChange('accounts')
|
||||
setOpen(true)
|
||||
}}
|
||||
isActive={activeView === 'accounts'}
|
||||
className="px-2.5 md:px-2"
|
||||
>
|
||||
<Plug />
|
||||
<span>Connected Accounts</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
{/* This is the second sidebar */}
|
||||
{/* We disable collapsible and let it fill remaining space */}
|
||||
{activeView === 'files' ? (
|
||||
<Sidebar collapsible="none" className="hidden flex-1 md:flex">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
) : (
|
||||
<ConnectedAccountsSidebar />
|
||||
)}
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
type TreeProps = {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelect: (path: string, kind: 'file' | 'dir') => void
|
||||
}
|
||||
|
||||
function Tree({ item, selectedPath, expandedPaths, onSelect }: TreeProps) {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedPaths.has(item.path)
|
||||
const isSelected = selectedPath === item.path
|
||||
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
isActive={isSelected}
|
||||
className="data-[active=true]:bg-transparent"
|
||||
onClick={() => onSelect(item.path, item.kind)}
|
||||
>
|
||||
<File />
|
||||
{item.name}
|
||||
</SidebarMenuButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onSelect(item.path, item.kind)}
|
||||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform" />
|
||||
<Folder />
|
||||
{item.name}
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.children!.map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Plug, Database } from "lucide-react"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useOAuth, useAvailableProviders } from "@/hooks/useOAuth"
|
||||
import { toast } from "@/lib/toast"
|
||||
|
||||
type ConnectedAccountsSidebarProps = React.ComponentProps<typeof Sidebar>
|
||||
|
||||
/**
|
||||
* Hook for managing Granola sync config
|
||||
*/
|
||||
function useGranolaConfig() {
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await window.ipc.invoke('granola:getConfig', null);
|
||||
setEnabled(result.enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Granola config:', error);
|
||||
setEnabled(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const updateConfig = useCallback(async (newEnabled: boolean) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await window.ipc.invoke('granola:setConfig', { enabled: newEnabled });
|
||||
setEnabled(newEnabled);
|
||||
toast(
|
||||
newEnabled ? 'Granola sync enabled' : 'Granola sync disabled',
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update Granola config:', error);
|
||||
toast('Failed to update Granola sync settings', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { enabled, isLoading, updateConfig };
|
||||
}
|
||||
|
||||
export function ConnectedAccountsSidebar({ ...props }: ConnectedAccountsSidebarProps) {
|
||||
const { providers, isLoading: providersLoading } = useAvailableProviders()
|
||||
const { enabled: granolaEnabled, isLoading: granolaLoading, updateConfig: updateGranolaConfig } = useGranolaConfig()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="none" className="hidden flex-1 md:flex" {...props}>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Connected Accounts</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{providersLoading ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<Loader2 className="animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : providers.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<span className="text-muted-foreground">No providers available</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
providers.map((provider) => (
|
||||
<ProviderItem key={provider} provider={provider} />
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Data Sources</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 w-full">
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={updateGranolaConfig}
|
||||
disabled={granolaLoading}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Database className="size-4 shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate text-sm">Granola Sync</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync notes from Granola
|
||||
</span>
|
||||
</div>
|
||||
{granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderItem({ provider }: { provider: string }) {
|
||||
const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider)
|
||||
const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center justify-between w-full gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Plug className="size-4 shrink-0" />
|
||||
<span className="truncate">{providerDisplayName}</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
{isConnected ? "Connected" : "Not Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={disconnect}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={connect}
|
||||
disabled={isConnecting || isLoading}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground md:h-8 md:p-0"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
522
apps/x/apps/renderer/src/components/sidebar-content.tsx
Normal file
522
apps/x/apps/renderer/src/components/sidebar-content.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
ArrowDownAZ,
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Database,
|
||||
File,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
Microscope,
|
||||
Network,
|
||||
Plug,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useSidebarSection } from "@/contexts/sidebar-context"
|
||||
import { useOAuth, useAvailableProviders } from "@/hooks/useOAuth"
|
||||
import { toast } from "@/lib/toast"
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: "file" | "dir"
|
||||
children?: TreeNode[]
|
||||
loaded?: boolean
|
||||
}
|
||||
|
||||
type SidebarContentPanelProps = {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
chats: { id: string; title: string; preview: string; time: string }[]
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTitles = {
|
||||
"ask-ai": "Ask AI",
|
||||
knowledge: "Knowledge",
|
||||
agents: "Agents",
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => console.log("New note") },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => console.log("New folder") },
|
||||
{ icon: Network, label: "Graph View", action: () => console.log("Graph view") },
|
||||
{ icon: ArrowDownAZ, label: "Sort", action: () => console.log("Sort") },
|
||||
]
|
||||
|
||||
const agentPresets = [
|
||||
{
|
||||
name: "Email Assistant",
|
||||
description: "Draft replies, summarize threads.",
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
name: "Meeting Prep",
|
||||
description: "Build briefs and talking points.",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
name: "Research",
|
||||
description: "Gather sources, outline findings.",
|
||||
icon: Microscope,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Hook for managing Granola sync config
|
||||
*/
|
||||
function useGranolaConfig() {
|
||||
const [enabled, setEnabled] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const result = await window.ipc.invoke('granola:getConfig', null)
|
||||
setEnabled(result.enabled)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Granola config:', error)
|
||||
setEnabled(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
const updateConfig = useCallback(async (newEnabled: boolean) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await window.ipc.invoke('granola:setConfig', { enabled: newEnabled })
|
||||
setEnabled(newEnabled)
|
||||
toast(
|
||||
newEnabled ? 'Granola sync enabled' : 'Granola sync disabled',
|
||||
'success'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to update Granola config:', error)
|
||||
toast('Failed to update Granola sync settings', 'error')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { enabled, isLoading, updateConfig }
|
||||
}
|
||||
|
||||
export function SidebarContentPanel({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
chats,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [allExpanded, setAllExpanded] = React.useState(false)
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
setAllExpanded(!allExpanded)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r-0" {...props}>
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<span className="font-semibold text-lg">{sectionTitles[activeSection]}</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{activeSection === "ask-ai" && (
|
||||
<ChatSection chats={chats} />
|
||||
)}
|
||||
{activeSection === "knowledge" && (
|
||||
<KnowledgeSection
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
allExpanded={allExpanded}
|
||||
onToggleExpandAll={toggleExpandAll}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "agents" && (
|
||||
<AgentsSection />
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
// Chat Section
|
||||
function ChatSection({ chats }: { chats: { id: string; title: string; preview: string; time: string }[] }) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="flex items-center justify-between">
|
||||
<span>Recent Chats</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1 transition-colors">
|
||||
<MessageSquarePlus className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{chats.map((chat) => (
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton className="h-auto items-start gap-2 py-2">
|
||||
<MessageSquare className="mt-0.5 size-4" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{chat.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{chat.time}</span>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">{chat.preview}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// Knowledge Section
|
||||
function KnowledgeSection({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
allExpanded,
|
||||
onToggleExpandAll,
|
||||
}: {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
allExpanded: boolean
|
||||
onToggleExpandAll: () => void
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center justify-center gap-1 py-1">
|
||||
{quickActions.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={action.action}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onToggleExpandAll}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ChevronsDownUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{allExpanded ? "Collapse All" : "Expand All"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// Tree component for file browser
|
||||
function Tree({
|
||||
item,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
}: {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelect: (path: string, kind: "file" | "dir") => void
|
||||
}) {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedPaths.has(item.path)
|
||||
const isSelected = selectedPath === item.path
|
||||
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={isSelected}
|
||||
onClick={() => onSelect(item.path, item.kind)}
|
||||
>
|
||||
<File className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onSelect(item.path, item.kind)}
|
||||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<Folder className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.children!.map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Agents Section with Connected Accounts
|
||||
function AgentsSection() {
|
||||
return (
|
||||
<>
|
||||
{/* Agent Presets */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="flex items-center justify-between">
|
||||
<span>Agent Presets</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1 transition-colors">
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">New Agent</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{agentPresets.map((agent) => (
|
||||
<SidebarMenuItem key={agent.name}>
|
||||
<SidebarMenuButton className="h-auto items-start gap-2 py-2">
|
||||
<agent.icon className="mt-0.5 size-4" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{agent.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{agent.description}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Connectors (Connected Accounts) */}
|
||||
<ConnectorsSection />
|
||||
|
||||
{/* Data Sources */}
|
||||
<DataSourcesSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Data Sources Section (Granola sync, etc.)
|
||||
function DataSourcesSection() {
|
||||
const { enabled: granolaEnabled, isLoading: granolaLoading, updateConfig: updateGranolaConfig } = useGranolaConfig()
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Data Sources</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 w-full">
|
||||
<Switch
|
||||
checked={granolaEnabled}
|
||||
onCheckedChange={updateGranolaConfig}
|
||||
disabled={granolaLoading}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Database className="size-4 shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate text-sm">Granola Sync</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Sync notes from Granola
|
||||
</span>
|
||||
</div>
|
||||
{granolaLoading && (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// Connectors Section (formerly Connected Accounts)
|
||||
function ConnectorsSection() {
|
||||
const { providers, isLoading: providersLoading } = useAvailableProviders()
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Connectors</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{providersLoading ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
<span>Loading...</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : providers.length === 0 ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<span className="text-muted-foreground">No connectors available</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
providers.map((provider) => (
|
||||
<ProviderItem key={provider} provider={provider} />
|
||||
))
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderItem({ provider }: { provider: string }) {
|
||||
const { isConnected, isLoading, isConnecting, connect, disconnect } = useOAuth(provider)
|
||||
const providerDisplayName = provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center justify-between w-full gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Plug className="size-4 shrink-0" />
|
||||
<span className="truncate text-sm">{providerDisplayName}</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
{isConnected ? "Connected" : "Not Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={disconnect}
|
||||
disabled={isLoading}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={connect}
|
||||
disabled={isConnecting || isLoading}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
101
apps/x/apps/renderer/src/components/sidebar-icon.tsx
Normal file
101
apps/x/apps/renderer/src/components/sidebar-icon.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
HelpCircle,
|
||||
Settings,
|
||||
Ship,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context"
|
||||
|
||||
type NavItem = {
|
||||
id: ActiveSection
|
||||
title: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
type SecondaryItem = {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ElementType
|
||||
action?: () => void
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "ask-ai", title: "Ask AI", icon: Sparkles },
|
||||
{ id: "knowledge", title: "Knowledge", icon: Brain },
|
||||
{ id: "agents", title: "Agents", icon: Bot },
|
||||
]
|
||||
|
||||
const secondaryItems: SecondaryItem[] = [
|
||||
{ id: "settings", title: "Settings", icon: Settings },
|
||||
{ id: "trash", title: "Trash", icon: Trash2 },
|
||||
{ id: "help", title: "Help", icon: HelpCircle },
|
||||
]
|
||||
|
||||
export function SidebarIcon() {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
||||
return (
|
||||
<div className="bg-sidebar border-r border-sidebar-border flex h-svh w-14 flex-col items-center py-2 fixed left-0 top-0 z-50 shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground mb-4">
|
||||
<Ship className="size-5" />
|
||||
</div>
|
||||
|
||||
{/* Main navigation */}
|
||||
<nav className="flex flex-1 flex-col items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
activeSection === item.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Secondary navigation (bottom) */}
|
||||
<nav className="flex flex-col items-center gap-1">
|
||||
{secondaryItems.map((item) => (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={item.action}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<item.icon className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -25,10 +25,13 @@ import {
|
|||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH = 256 // 16rem in pixels
|
||||
const SIDEBAR_WIDTH_MIN = 200
|
||||
const SIDEBAR_WIDTH_MAX = 480
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
const SIDEBAR_OFFSET = "0px" // Default offset for nested sidebars
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
|
|
@ -38,6 +41,10 @@ type SidebarContextProps = {
|
|||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
sidebarWidth: number
|
||||
setSidebarWidth: (width: number) => void
|
||||
isResizing: boolean
|
||||
setIsResizing: (resizing: boolean) => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
|
@ -66,6 +73,8 @@ function SidebarProvider({
|
|||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH)
|
||||
const [isResizing, setIsResizing] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
|
|
@ -120,8 +129,12 @@ function SidebarProvider({
|
|||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
sidebarWidth,
|
||||
setSidebarWidth,
|
||||
isResizing,
|
||||
setIsResizing,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, isResizing]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -129,15 +142,19 @@ function SidebarProvider({
|
|||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
data-resizing={isResizing}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
"--sidebar-offset": SIDEBAR_OFFSET,
|
||||
...style,
|
||||
// Dynamic width must come AFTER spreading style to override any static values
|
||||
"--sidebar-width": `${sidebarWidth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
isResizing && "select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -216,7 +233,8 @@ function Sidebar({
|
|||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
"[[data-resizing=false]_&]:transition-[width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
|
|
@ -227,9 +245,10 @@ function Sidebar({
|
|||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
"[[data-resizing=false]_&]:transition-[left,right,width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
? "left-[var(--sidebar-offset)] group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
|
|
@ -277,24 +296,61 @@ function SidebarTrigger({
|
|||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { setSidebarWidth, setIsResizing, isResizing } = useSidebar()
|
||||
const startXRef = React.useRef(0)
|
||||
const startWidthRef = React.useRef(0)
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startXRef.current = e.clientX
|
||||
const sidebar = document.querySelector('[data-slot="sidebar-wrapper"]')
|
||||
const currentWidth = sidebar
|
||||
? parseInt(getComputedStyle(sidebar).getPropertyValue("--sidebar-width"))
|
||||
: SIDEBAR_WIDTH
|
||||
startWidthRef.current = currentWidth
|
||||
setIsResizing(true)
|
||||
},
|
||||
[setIsResizing]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - startXRef.current
|
||||
const newWidth = Math.min(
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
Math.max(SIDEBAR_WIDTH_MIN, startWidthRef.current + delta)
|
||||
)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isResizing, setSidebarWidth, setIsResizing])
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
aria-label="Resize Sidebar"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 cursor-col-resize group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors",
|
||||
"hover:after:bg-sidebar-border",
|
||||
isResizing && "after:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
44
apps/x/apps/renderer/src/contexts/sidebar-context.tsx
Normal file
44
apps/x/apps/renderer/src/contexts/sidebar-context.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
export type ActiveSection = "ask-ai" | "knowledge" | "agents"
|
||||
|
||||
type SidebarSectionContextProps = {
|
||||
activeSection: ActiveSection
|
||||
setActiveSection: (section: ActiveSection) => void
|
||||
}
|
||||
|
||||
const SidebarSectionContext = React.createContext<SidebarSectionContextProps | null>(null)
|
||||
|
||||
export function useSidebarSection() {
|
||||
const context = React.useContext(SidebarSectionContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebarSection must be used within a SidebarSectionProvider.")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function SidebarSectionProvider({
|
||||
defaultSection = "ask-ai",
|
||||
children,
|
||||
}: {
|
||||
defaultSection?: ActiveSection
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [activeSection, setActiveSection] = React.useState<ActiveSection>(defaultSection)
|
||||
|
||||
const contextValue = React.useMemo<SidebarSectionContextProps>(
|
||||
() => ({
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
}),
|
||||
[activeSection]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarSectionContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SidebarSectionContext.Provider>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue