mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-06 05:42:37 +02:00
feat(suggested-topics): populate and integrate suggested topics
This commit is contained in:
parent
e9cdd3f6eb
commit
eaab438666
6 changed files with 448 additions and 66 deletions
|
|
@ -262,6 +262,60 @@ 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 getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
const normalized = category?.trim().toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'people':
|
||||
case 'person':
|
||||
return 'People'
|
||||
case 'organizations':
|
||||
case 'organization':
|
||||
return 'Organizations'
|
||||
case 'projects':
|
||||
case 'project':
|
||||
return 'Projects'
|
||||
case 'meetings':
|
||||
case 'meeting':
|
||||
return 'Meetings'
|
||||
case 'topics':
|
||||
case 'topic':
|
||||
default:
|
||||
return 'Topics'
|
||||
}
|
||||
}
|
||||
|
||||
const buildSuggestedTopicExplorePrompt = ({
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
category?: string
|
||||
}) => {
|
||||
const folder = getSuggestedTopicTargetFolder(category)
|
||||
const categoryLabel = category?.trim() || 'Topics'
|
||||
return [
|
||||
'I am exploring a suggested topic card from the Suggested Topics panel.',
|
||||
'This card may represent a person, organization, topic, or project.',
|
||||
'',
|
||||
'Card context:',
|
||||
`- Title: ${title}`,
|
||||
`- Category: ${categoryLabel}`,
|
||||
`- Description: ${description}`,
|
||||
`- Target folder if we set this up: knowledge/${folder}/`,
|
||||
'',
|
||||
`Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`,
|
||||
'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.',
|
||||
'Do not create or modify anything yet.',
|
||||
'Treat a clear confirmation from me as explicit approval to proceed.',
|
||||
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
|
||||
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
|
||||
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||
|
|
@ -585,7 +639,7 @@ function App() {
|
|||
const [isGraphOpen, setIsGraphOpen] = 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; suggestedTopics: boolean } | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
nodes: [],
|
||||
|
|
@ -2664,15 +2718,16 @@ function App() {
|
|||
}
|
||||
handleNewChat()
|
||||
// Left-pane "new chat" should always open full chat view.
|
||||
if (selectedPath || isGraphOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||
} else {
|
||||
setExpandedFrom(null)
|
||||
}
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||
|
||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||
const handleNewChatTabInSidebar = useCallback(() => {
|
||||
|
|
@ -2766,19 +2821,26 @@ function App() {
|
|||
|
||||
const handleOpenFullScreenChat = useCallback(() => {
|
||||
// Remember where we came from so the close button can return
|
||||
if (selectedPath || isGraphOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||
}
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
}, [selectedPath, isGraphOpen])
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||
|
||||
const handleCloseFullScreenChat = useCallback(() => {
|
||||
if (expandedFrom) {
|
||||
if (expandedFrom.graph) {
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
} else if (expandedFrom.suggestedTopics) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
} else if (expandedFrom.path) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setSelectedPath(expandedFrom.path)
|
||||
}
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -3179,7 +3241,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3257,12 +3319,16 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
|
||||
const targetPane: ShortcutPane = rightPaneAvailable
|
||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||
: 'left'
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
|
||||
const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
|
||||
const selectedKnowledgePath = isGraphOpen
|
||||
? GRAPH_TAB_PATH
|
||||
: isSuggestedTopicsOpen
|
||||
? SUGGESTED_TOPICS_TAB_PATH
|
||||
: selectedPath
|
||||
const targetFileTabId = activeFileTabId ?? (
|
||||
selectedKnowledgePath
|
||||
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
|
||||
|
|
@ -3316,7 +3382,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('keydown', handleTabKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||
}, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
|
|
@ -3341,7 +3407,7 @@ function App() {
|
|||
}),
|
||||
},
|
||||
}))
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -3463,14 +3529,14 @@ function App() {
|
|||
},
|
||||
openGraph: () => {
|
||||
// From chat-only landing state, open graph directly in full knowledge view.
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -4042,7 +4108,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen)
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4084,7 +4150,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4095,7 +4161,7 @@ function App() {
|
|||
return
|
||||
}
|
||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4119,14 +4185,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} else if (runId === runIdToDelete) {
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4152,6 +4218,7 @@ function App() {
|
|||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||
/>
|
||||
<SidebarInset
|
||||
|
|
@ -4233,7 +4300,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4248,7 +4315,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4263,7 +4330,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4287,8 +4354,8 @@ function App() {
|
|||
) : 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}`
|
||||
onExploreTopic={(topic) => {
|
||||
const prompt = buildSuggestedTopicExplorePrompt(topic)
|
||||
submitFromPalette(prompt, null)
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ type SidebarContentPanelProps = {
|
|||
onToggleMeeting?: () => void
|
||||
isBrowserOpen?: boolean
|
||||
onToggleBrowser?: () => void
|
||||
isSuggestedTopicsOpen?: boolean
|
||||
onOpenSuggestedTopics?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
|
|
@ -418,6 +419,7 @@ export function SidebarContentPanel({
|
|||
onToggleMeeting,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
isSuggestedTopicsOpen = false,
|
||||
onOpenSuggestedTopics,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
|
|
@ -579,6 +581,21 @@ export function SidebarContentPanel({
|
|||
<span>Run browser task</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenSuggestedTopics && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSuggestedTopics}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSuggestedTopicsOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Lightbulb className="size-4" />
|
||||
<span>Suggested Topics</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
|
@ -707,18 +724,6 @@ 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" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react'
|
||||
import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js'
|
||||
|
||||
const SUGGESTED_TOPICS_PATH = 'suggested-topics.md'
|
||||
const LEGACY_SUGGESTED_TOPICS_PATHS = [
|
||||
'config/suggested-topics.md',
|
||||
'knowledge/Notes/Suggested Topics.md',
|
||||
]
|
||||
|
||||
/** Parse suggestedtopic code-fence blocks from the markdown file content. */
|
||||
function parseTopics(content: string): SuggestedTopicBlock[] {
|
||||
const topics: SuggestedTopicBlock[] = []
|
||||
|
|
@ -16,13 +22,42 @@ function parseTopics(content: string): SuggestedTopicBlock[] {
|
|||
// Skip malformed blocks
|
||||
}
|
||||
}
|
||||
|
||||
if (topics.length > 0) return topics
|
||||
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
const topic = SuggestedTopicBlockSchema.parse(parsed)
|
||||
topics.push(topic)
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return topics
|
||||
}
|
||||
|
||||
function serializeTopics(topics: SuggestedTopicBlock[]): string {
|
||||
const blocks = topics.map((topic) => [
|
||||
'```suggestedtopic',
|
||||
JSON.stringify(topic),
|
||||
'```',
|
||||
].join('\n'))
|
||||
|
||||
return ['# Suggested Topics', ...blocks].join('\n\n') + '\n'
|
||||
}
|
||||
|
||||
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',
|
||||
Organizations: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400',
|
||||
Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
}
|
||||
|
||||
|
|
@ -33,10 +68,11 @@ function getCategoryColor(category?: string): string {
|
|||
|
||||
interface TopicCardProps {
|
||||
topic: SuggestedTopicBlock
|
||||
onExplore: (topic: SuggestedTopicBlock) => void
|
||||
onTrack: () => void
|
||||
isRemoving: boolean
|
||||
}
|
||||
|
||||
function TopicCard({ topic, onExplore }: TopicCardProps) {
|
||||
function TopicCard({ topic, onTrack, isRemoving }: 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">
|
||||
|
|
@ -55,32 +91,72 @@ function TopicCard({ topic, onExplore }: TopicCardProps) {
|
|||
{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"
|
||||
type="button"
|
||||
onClick={onTrack}
|
||||
disabled={isRemoving}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Explore
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
{isRemoving ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Tracking…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Track
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SuggestedTopicsViewProps {
|
||||
onExploreTopic: (title: string, description: string) => void
|
||||
onExploreTopic: (topic: SuggestedTopicBlock) => void
|
||||
}
|
||||
|
||||
export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) {
|
||||
const [topics, setTopics] = useState<SuggestedTopicBlock[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [removingIndex, setRemovingIndex] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: 'config/suggested-topics.md',
|
||||
})
|
||||
let result
|
||||
try {
|
||||
result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: SUGGESTED_TOPICS_PATH,
|
||||
})
|
||||
} catch {
|
||||
let legacyResult: { data?: string } | null = null
|
||||
let legacyPath: string | null = null
|
||||
for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) {
|
||||
try {
|
||||
legacyResult = await window.ipc.invoke('workspace:readFile', { path })
|
||||
legacyPath = path
|
||||
break
|
||||
} catch {
|
||||
// Try next legacy location.
|
||||
}
|
||||
}
|
||||
if (!legacyResult || !legacyPath) {
|
||||
throw new Error('Suggested topics file not found')
|
||||
}
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: SUGGESTED_TOPICS_PATH,
|
||||
data: legacyResult.data,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
await window.ipc.invoke('workspace:remove', {
|
||||
path: legacyPath,
|
||||
opts: { trash: true },
|
||||
})
|
||||
result = legacyResult
|
||||
}
|
||||
if (cancelled) return
|
||||
if (result.data) {
|
||||
setTopics(parseTopics(result.data))
|
||||
|
|
@ -95,11 +171,30 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps
|
|||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const handleExplore = useCallback(
|
||||
(topic: SuggestedTopicBlock) => {
|
||||
onExploreTopic(topic.title, topic.description)
|
||||
const handleTrack = useCallback(
|
||||
async (topic: SuggestedTopicBlock, topicIndex: number) => {
|
||||
if (removingIndex !== null) return
|
||||
const nextTopics = topics.filter((_, idx) => idx !== topicIndex)
|
||||
setRemovingIndex(topicIndex)
|
||||
setError(null)
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: SUGGESTED_TOPICS_PATH,
|
||||
data: serializeTopics(nextTopics),
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
setTopics(nextTopics)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove suggested topic:', err)
|
||||
setError('Failed to update suggested topics. Please try again.')
|
||||
return
|
||||
} finally {
|
||||
setRemovingIndex(null)
|
||||
}
|
||||
|
||||
onExploreTopic(topic)
|
||||
},
|
||||
[onExploreTopic],
|
||||
[onExploreTopic, removingIndex, topics],
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -131,13 +226,18 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps
|
|||
<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.
|
||||
Suggested notes surfaced from your knowledge graph. Track one to start a tracking note.
|
||||
</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} />
|
||||
<TopicCard
|
||||
key={`${topic.title}-${i}`}
|
||||
topic={topic}
|
||||
onTrack={() => { void handleTrack(topic, i) }}
|
||||
isRemoving={removingIndex === i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue