mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
Merge 14f082e71a into ab0147d475
This commit is contained in:
commit
685365cdb9
4 changed files with 230 additions and 5 deletions
|
|
@ -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 { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
Conversation,
|
||||
|
|
@ -127,6 +128,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
|||
const TITLEBAR_BUTTONS_COLLAPSED = 4
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
|
|
@ -255,6 +257,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
|
|||
}
|
||||
|
||||
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 normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
|
|
@ -437,6 +440,7 @@ type ViewState =
|
|||
| { type: 'file'; path: string }
|
||||
| { type: 'graph' }
|
||||
| { type: 'task'; name: string }
|
||||
| { type: 'suggested-topics' }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
|
|
@ -605,6 +609,7 @@ function App() {
|
|||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
|
|
@ -900,6 +905,7 @@ function App() {
|
|||
|
||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||
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.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -2563,9 +2569,17 @@ function App() {
|
|||
if (isGraphTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
return
|
||||
}
|
||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
return
|
||||
}
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setSelectedPath(tab.path)
|
||||
}, [fileTabs, isRightPaneMaximized])
|
||||
|
||||
|
|
@ -2593,6 +2607,7 @@ function App() {
|
|||
setActiveFileTabId(null)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
|
|
@ -2605,8 +2620,14 @@ function App() {
|
|||
if (isGraphTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -2721,10 +2742,11 @@ function App() {
|
|||
|
||||
const currentViewState = React.useMemo<ViewState>(() => {
|
||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, selectedPath, isGraphOpen, runId])
|
||||
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
|
|
@ -2770,11 +2792,23 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [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) => {
|
||||
switch (view.type) {
|
||||
case 'file':
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -2787,6 +2821,7 @@ function App() {
|
|||
case 'graph':
|
||||
setSelectedBackgroundTask(null)
|
||||
setSelectedPath(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -2797,16 +2832,27 @@ function App() {
|
|||
case 'task':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
return
|
||||
case 'suggested-topics':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
ensureSuggestedTopicsFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
if (view.runId) {
|
||||
await loadRun(view.runId)
|
||||
} else {
|
||||
|
|
@ -2814,7 +2860,7 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
|
|
@ -4044,6 +4090,7 @@ function App() {
|
|||
}}
|
||||
backgroundTasks={backgroundTasks}
|
||||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
|
|
@ -4063,7 +4110,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4071,7 +4118,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
onCloseTab={closeFileTab}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -4173,7 +4220,16 @@ function App() {
|
|||
)}
|
||||
</ContentHeader>
|
||||
|
||||
{selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
{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) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
tree={tree}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
Pencil,
|
||||
Table2,
|
||||
Plug,
|
||||
Lightbulb,
|
||||
LoaderIcon,
|
||||
Settings,
|
||||
Square,
|
||||
|
|
@ -172,6 +173,7 @@ type SidebarContentPanelProps = {
|
|||
tasksActions?: TasksActions
|
||||
backgroundTasks?: BackgroundTaskItem[]
|
||||
selectedBackgroundTask?: string | null
|
||||
onOpenSuggestedTopics?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -395,6 +397,7 @@ export function SidebarContentPanel({
|
|||
tasksActions,
|
||||
backgroundTasks = [],
|
||||
selectedBackgroundTask,
|
||||
onOpenSuggestedTopics,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -615,6 +618,18 @@ export function SidebarContentPanel({
|
|||
</AlertDialog>
|
||||
)}
|
||||
</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>
|
||||
<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" />
|
||||
|
|
|
|||
146
apps/x/apps/renderer/src/components/suggested-topics-view.tsx
Normal file
146
apps/x/apps/renderer/src/components/suggested-topics-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -81,3 +81,11 @@ export const TranscriptBlockSchema = z.object({
|
|||
});
|
||||
|
||||
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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue