feat(ui): add Suggested Topics feature

This commit is contained in:
tusharmagar 2026-04-17 21:28:57 +05:30 committed by arkml
parent 50df9ed178
commit e9cdd3f6eb
4 changed files with 230 additions and 4 deletions

View file

@ -15,6 +15,7 @@ import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-vi
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
import { useDebounce } from './hooks/use-debounce'; import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import { import {
Conversation, Conversation,
@ -129,6 +130,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
const TITLEBAR_BUTTONS_COLLAPSED = 4 const TITLEBAR_BUTTONS_COLLAPSED = 4
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
const GRAPH_TAB_PATH = '__rowboat_graph_view__' const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) => const clampNumber = (value: number, min: number, max: number) =>
@ -257,6 +259,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
} }
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => { const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
@ -439,6 +442,7 @@ type ViewState =
| { type: 'file'; path: string } | { type: 'file'; path: string }
| { type: 'graph' } | { type: 'graph' }
| { type: 'task'; name: string } | { type: 'task'; name: string }
| { type: 'suggested-topics' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean { function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false if (a.type !== b.type) return false
@ -580,6 +584,7 @@ function App() {
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([]) const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
const [isGraphOpen, setIsGraphOpen] = useState(false) const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({}) const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -875,6 +880,7 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => { const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View' if (isGraphTabPath(tab.path)) return 'Graph View'
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -2570,9 +2576,17 @@ function App() {
if (isGraphTabPath(tab.path)) { if (isGraphTabPath(tab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
return return
} }
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized]) }, [fileTabs, isRightPaneMaximized])
@ -2600,6 +2614,7 @@ function App() {
setActiveFileTabId(null) setActiveFileTabId(null)
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
return [] return []
} }
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
@ -2612,8 +2627,14 @@ function App() {
if (isGraphTabPath(newActiveTab.path)) { if (isGraphTabPath(newActiveTab.path)) {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
} else { } else {
setIsGraphOpen(false) setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setSelectedPath(newActiveTab.path) setSelectedPath(newActiveTab.path)
} }
} }
@ -2767,10 +2788,11 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => { const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath } if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' } if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId } return { type: 'chat', runId }
}, [selectedBackgroundTask, selectedPath, isGraphOpen, runId]) }, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1] const last = stack[stack.length - 1]
@ -2816,6 +2838,17 @@ function App() {
setActiveFileTabId(id) setActiveFileTabId(id)
}, [fileTabs]) }, [fileTabs])
const ensureSuggestedTopicsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isSuggestedTopicsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: SUGGESTED_TOPICS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const applyViewState = useCallback(async (view: ViewState) => { const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) { switch (view.type) {
case 'file': case 'file':
@ -2824,6 +2857,7 @@ function App() {
// Navigating to a file dismisses the browser overlay so the file is // Navigating to a file dismisses the browser overlay so the file is
// visible in the middle pane. // visible in the middle pane.
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files. // Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file. // Only exit chat-only maximize, because that would hide the selected file.
@ -2837,6 +2871,7 @@ function App() {
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setSelectedPath(null) setSelectedPath(null)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
ensureGraphFileTab() ensureGraphFileTab()
@ -2848,10 +2883,21 @@ function App() {
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setIsBrowserOpen(false) setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name) setSelectedBackgroundTask(view.name)
return return
case 'suggested-topics':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
ensureSuggestedTopicsFileTab()
return
case 'chat': case 'chat':
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false) setIsGraphOpen(false)
@ -2860,6 +2906,7 @@ function App() {
setExpandedFrom(null) setExpandedFrom(null)
setIsRightPaneMaximized(false) setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
if (view.runId) { if (view.runId) {
await loadRun(view.runId) await loadRun(view.runId)
} else { } else {
@ -2867,7 +2914,7 @@ function App() {
} }
return return
} }
}, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun]) }, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => { const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState const current = currentViewState
@ -4105,6 +4152,7 @@ function App() {
onToggleMeeting={() => { void handleToggleMeeting() }} onToggleMeeting={() => { void handleToggleMeeting() }}
isBrowserOpen={isBrowserOpen} isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser} onToggleBrowser={handleToggleBrowser}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
/> />
<SidebarInset <SidebarInset
className={cn( className={cn(
@ -4124,7 +4172,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -4132,7 +4180,7 @@ function App() {
getTabId={(t) => t.id} getTabId={(t) => t.id}
onSwitchTab={switchFileTab} onSwitchTab={switchFileTab}
onCloseTab={closeFileTab} onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/> />
) : ( ) : (
<TabBar <TabBar
@ -4236,6 +4284,15 @@ function App() {
{isBrowserOpen ? ( {isBrowserOpen ? (
<BrowserPane onClose={handleCloseBrowser} /> <BrowserPane onClose={handleCloseBrowser} />
) : isSuggestedTopicsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<SuggestedTopicsView
onExploreTopic={(title, description) => {
const prompt = `I'd like to explore the topic: ${title}. ${description}`
submitFromPalette(prompt, null)
}}
/>
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? ( ) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView <BasesView

View file

@ -23,6 +23,7 @@ import {
SquarePen, SquarePen,
Table2, Table2,
Plug, Plug,
Lightbulb,
LoaderIcon, LoaderIcon,
Settings, Settings,
Square, Square,
@ -185,6 +186,7 @@ type SidebarContentPanelProps = {
onToggleMeeting?: () => void onToggleMeeting?: () => void
isBrowserOpen?: boolean isBrowserOpen?: boolean
onToggleBrowser?: () => void onToggleBrowser?: () => void
onOpenSuggestedTopics?: () => void
} & React.ComponentProps<typeof Sidebar> } & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [ const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -416,6 +418,7 @@ export function SidebarContentPanel({
onToggleMeeting, onToggleMeeting,
isBrowserOpen = false, isBrowserOpen = false,
onToggleBrowser, onToggleBrowser,
onOpenSuggestedTopics,
...props ...props
}: SidebarContentPanelProps) { }: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection() const { activeSection, setActiveSection } = useSidebarSection()
@ -704,6 +707,18 @@ export function SidebarContentPanel({
</AlertDialog> </AlertDialog>
)} )}
</div> </div>
{onOpenSuggestedTopics && (
<button
onClick={onOpenSuggestedTopics}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Lightbulb className="size-4" />
<span>Suggested Topics</span>
<span className="ml-auto rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-blue-600 dark:text-blue-400">
NEW
</span>
</button>
)}
<SettingsDialog> <SettingsDialog>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"> <button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Settings className="size-4" /> <Settings className="size-4" />

View file

@ -0,0 +1,146 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react'
import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js'
/** Parse suggestedtopic code-fence blocks from the markdown file content. */
function parseTopics(content: string): SuggestedTopicBlock[] {
const topics: SuggestedTopicBlock[] = []
const regex = /```suggestedtopic\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1].trim())
const topic = SuggestedTopicBlockSchema.parse(parsed)
topics.push(topic)
} catch {
// Skip malformed blocks
}
}
return topics
}
const CATEGORY_COLORS: Record<string, string> = {
Meetings: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
Projects: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
People: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
}
function getCategoryColor(category?: string): string {
if (!category) return 'bg-muted text-muted-foreground'
return CATEGORY_COLORS[category] ?? 'bg-muted text-muted-foreground'
}
interface TopicCardProps {
topic: SuggestedTopicBlock
onExplore: (topic: SuggestedTopicBlock) => void
}
function TopicCard({ topic, onExplore }: TopicCardProps) {
return (
<div className="group flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-5 transition-all hover:border-border hover:shadow-sm">
<div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-semibold leading-snug text-foreground">
{topic.title}
</h3>
{topic.category && (
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${getCategoryColor(topic.category)}`}
>
{topic.category}
</span>
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{topic.description}
</p>
<button
onClick={() => onExplore(topic)}
className="mt-auto flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
>
Explore
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</button>
</div>
)
}
interface SuggestedTopicsViewProps {
onExploreTopic: (title: string, description: string) => void
}
export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) {
const [topics, setTopics] = useState<SuggestedTopicBlock[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const result = await window.ipc.invoke('workspace:readFile', {
path: 'config/suggested-topics.md',
})
if (cancelled) return
if (result.data) {
setTopics(parseTopics(result.data))
}
} catch {
if (!cancelled) setError('No suggested topics yet. Check back once your knowledge graph has more data.')
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [])
const handleExplore = useCallback(
(topic: SuggestedTopicBlock) => {
onExploreTopic(topic.title, topic.description)
},
[onExploreTopic],
)
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (error || topics.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Lightbulb className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
{error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'}
</p>
</div>
)
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center gap-2">
<Lightbulb className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Suggested Topics</h2>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Topics surfaced from your knowledge graph. Explore them to create new notes.
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{topics.map((topic, i) => (
<TopicCard key={`${topic.title}-${i}`} topic={topic} onExplore={handleExplore} />
))}
</div>
</div>
</div>
)
}

View file

@ -81,3 +81,11 @@ export const TranscriptBlockSchema = z.object({
}); });
export type TranscriptBlock = z.infer<typeof TranscriptBlockSchema>; export type TranscriptBlock = z.infer<typeof TranscriptBlockSchema>;
export const SuggestedTopicBlockSchema = z.object({
title: z.string(),
description: z.string(),
category: z.string().optional(),
});
export type SuggestedTopicBlock = z.infer<typeof SuggestedTopicBlockSchema>;