Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Search, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatInputBar } 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 { SidebarContentPanel } from '@/components/sidebar-content';
import { SidebarSectionProvider, type ActiveSection } from '@/contexts/sidebar-context';
import { SidebarSectionProvider, useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context';
import {
Conversation,
ConversationContent,
Expand All @@ -36,6 +36,7 @@ import {
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
Expand Down Expand Up @@ -130,8 +131,8 @@ const TITLEBAR_BUTTON_PX = 32
const TITLEBAR_BUTTON_GAP_PX = 4
const TITLEBAR_HEADER_GAP_PX = 8
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
const TITLEBAR_BUTTONS_COLLAPSED = 4
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
const TITLEBAR_BUTTONS_COLLAPSED = 5
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4

const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value))
Expand Down Expand Up @@ -486,6 +487,7 @@ function FixedSidebarToggle({
leftInsetPx: number
}) {
const { toggleSidebar, state } = useSidebar()
const { searchOpen, setSearchOpen } = useSidebarSection()
const isCollapsed = state === "collapsed"
return (
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
Expand All @@ -509,6 +511,20 @@ function FixedSidebarToggle({
>
<SquarePen className="size-5" />
</button>
<button
type="button"
onClick={() => setSearchOpen(!searchOpen)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
searchOpen
? "text-foreground bg-accent"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label="Search"
>
<Search className="size-5" />
</button>
{/* Back / Forward navigation */}
{isCollapsed && (
<>
Expand Down Expand Up @@ -2203,6 +2219,18 @@ function App() {
}

if (isToolCall(item)) {
if (item.name === 'web-search') {
const input = normalizeToolInput(item.input) as Record<string, unknown> | undefined
const result = item.result as Record<string, unknown> | undefined
return (
<WebSearchResult
key={item.id}
query={(input?.query as string) || ''}
results={(result?.results as Array<{ title: string; url: string; description: string }>) || []}
status={item.status}
/>
)
}
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
Expand Down
108 changes: 108 additions & 0 deletions apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"use client";

import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
CheckCircleIcon,
ChevronDownIcon,
GlobeIcon,
LoaderIcon,
} from "lucide-react";

interface WebSearchResultProps {
query: string;
results: Array<{ title: string; url: string; description: string }>;
status: "pending" | "running" | "completed" | "error";
}

function getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}

export function WebSearchResult({ query, results, status }: WebSearchResultProps) {
const isRunning = status === "pending" || status === "running";

return (
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<div className="flex items-center gap-2">
<GlobeIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">Searched the web</span>
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-3">
{/* Query + result count */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<GlobeIcon className="size-3.5 shrink-0" />
<span className="truncate">{query}</span>
</div>
{results.length > 0 && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{results.length} result{results.length !== 1 ? "s" : ""}
</span>
)}
</div>

{/* Results list */}
{results.length > 0 && (
<div className="rounded-md border max-h-64 overflow-y-auto">
{results.map((result, index) => {
const domain = getDomain(result.url);
return (
<a
key={index}
href={result.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
window.open(result.url, "_blank");
}}
className="flex items-center justify-between gap-3 px-3 py-2 text-sm hover:bg-muted/50 transition-colors border-b last:border-b-0"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="size-4 shrink-0"
/>
<span className="truncate">{result.title}</span>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{domain}
</span>
</a>
);
})}
</div>
)}

{/* Status */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{isRunning ? (
<>
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching...</span>
</>
) : (
<>
<CheckCircleIcon className="size-3.5 text-green-600" />
<span>Done</span>
</>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
106 changes: 89 additions & 17 deletions apps/x/apps/renderer/src/components/sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import {
Pencil,
Plug,
LoaderIcon,
Search,
Settings,
Square,
SquarePen,
Trash2,
X,
} from "lucide-react"

import {
Expand Down Expand Up @@ -354,6 +356,36 @@ function SyncStatusBar() {
)
}

function filterTree(nodes: TreeNode[], query: string): TreeNode[] {
const lowerQuery = query.toLowerCase()
return nodes.reduce<TreeNode[]>((acc, node) => {
if (node.kind === "dir") {
const filteredChildren = filterTree(node.children || [], query)
if (filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren })
}
} else if (node.name.toLowerCase().includes(lowerQuery)) {
acc.push(node)
}
return acc
}, [])
}

function collectDirPaths(nodes: TreeNode[]): Set<string> {
const paths = new Set<string>()
for (const node of nodes) {
if (node.kind === "dir") {
paths.add(node.path)
if (node.children) {
for (const p of collectDirPaths(node.children)) {
paths.add(p)
}
}
}
}
return paths
}

export function SidebarContentPanel({
tree,
selectedPath,
Expand All @@ -369,14 +401,42 @@ export function SidebarContentPanel({
selectedBackgroundTask,
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
const { activeSection, setActiveSection, searchOpen, setSearchOpen } = useSidebarSection()
const [searchQuery, setSearchQuery] = useState("")
const searchInputRef = useRef<HTMLInputElement>(null)

// Auto-focus when search opens
useEffect(() => {
if (searchOpen) {
// Small delay so the input is rendered before focusing
requestAnimationFrame(() => searchInputRef.current?.focus())
}
}, [searchOpen])

// Clear query when search closes or section changes
useEffect(() => {
if (!searchOpen) setSearchQuery("")
}, [searchOpen])
useEffect(() => {
setSearchQuery("")
}, [activeSection])

// Compute filtered data
const filteredRuns = searchQuery
? runs.filter((r) => (r.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
: runs
const filteredBackgroundTasks = searchQuery
? backgroundTasks.filter((t) => t.name.toLowerCase().includes(searchQuery.toLowerCase()))
: backgroundTasks
const filteredTree = searchQuery ? filterTree(tree, searchQuery) : tree
const searchExpandedPaths = searchQuery ? collectDirPaths(filteredTree) : expandedPaths

return (
<Sidebar className="border-r-0" {...props}>
<SidebarHeader className="titlebar-drag-region">
{/* Top spacer to clear the traffic lights + fixed toggle row */}
<div className="h-8" />
{/* Tab switcher - centered below the traffic lights row */}
{/* Tab switcher */}
<div className="flex items-center px-2 py-1.5">
<div className="titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5">
{sectionTabs.map((tab) => (
Expand All @@ -395,25 +455,48 @@ export function SidebarContentPanel({
))}
</div>
</div>
{searchOpen && (
<div className="titlebar-no-drag flex items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-accent/30 mx-2 mb-1.5 px-2 py-1">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setSearchOpen(false)
}}
placeholder={activeSection === "tasks" ? "Search chats..." : "Search files..."}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="rounded p-0.5 text-muted-foreground hover:text-sidebar-foreground transition-colors"
>
<X className="size-3.5" />
</button>
)}
</div>
)}
</SidebarHeader>
<SidebarContent>
{activeSection === "knowledge" && (
<KnowledgeSection
tree={tree}
tree={filteredTree}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
expandedPaths={searchExpandedPaths}
onSelectFile={onSelectFile}
actions={knowledgeActions}
onVoiceNoteCreated={onVoiceNoteCreated}
/>
)}
{activeSection === "tasks" && (
<TasksSection
runs={runs}
runs={filteredRuns}
currentRunId={currentRunId}
processingRunIds={processingRunIds}
actions={tasksActions}
backgroundTasks={backgroundTasks}
backgroundTasks={filteredBackgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/>
)}
Expand Down Expand Up @@ -1006,17 +1089,6 @@ function TasksSection({
}) {
return (
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
{/* Sticky New Chat button - matches Knowledge section height */}
<div className="sticky top-0 z-10 bg-sidebar border-b border-sidebar-border py-0.5">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={actions?.onNewChat} className="gap-2">
<SquarePen className="size-4 shrink-0" />
<span className="text-sm">New chat</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
<SidebarGroupContent className="flex-1 overflow-y-auto">
{/* Background Tasks Section */}
{backgroundTasks.length > 0 && (
Expand Down
7 changes: 6 additions & 1 deletion apps/x/apps/renderer/src/contexts/sidebar-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type ActiveSection = "knowledge" | "tasks"
type SidebarSectionContextProps = {
activeSection: ActiveSection
setActiveSection: (section: ActiveSection) => void
searchOpen: boolean
setSearchOpen: (open: boolean) => void
}

const SidebarSectionContext = React.createContext<SidebarSectionContextProps | null>(null)
Expand All @@ -29,6 +31,7 @@ export function SidebarSectionProvider({
children: React.ReactNode
}) {
const [activeSection, setActiveSectionState] = React.useState<ActiveSection>(defaultSection)
const [searchOpen, setSearchOpen] = React.useState(false)

const setActiveSection = React.useCallback((section: ActiveSection) => {
setActiveSectionState(section)
Expand All @@ -39,8 +42,10 @@ export function SidebarSectionProvider({
() => ({
activeSection,
setActiveSection,
searchOpen,
setSearchOpen,
}),
[activeSection, setActiveSection]
[activeSection, setActiveSection, searchOpen]
)

return (
Expand Down
Loading