mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-22 18:45:19 +02:00
app navigation
This commit is contained in:
parent
8f1adfb6a5
commit
d150294af1
9 changed files with 433 additions and 1 deletions
|
|
@ -35,6 +35,7 @@ import {
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
|
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||||
|
|
@ -62,6 +63,7 @@ import {
|
||||||
type ToolCall,
|
type ToolCall,
|
||||||
createEmptyChatTabViewState,
|
createEmptyChatTabViewState,
|
||||||
getWebSearchCardData,
|
getWebSearchCardData,
|
||||||
|
getAppActionCardData,
|
||||||
inferRunTitleFromMessage,
|
inferRunTitleFromMessage,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
isErrorMessage,
|
isErrorMessage,
|
||||||
|
|
@ -499,6 +501,9 @@ function App() {
|
||||||
const recentLocalMarkdownWritesRef = useRef<Map<string, number>>(new Map())
|
const recentLocalMarkdownWritesRef = useRef<Map<string, number>>(new Map())
|
||||||
const untitledRenameReadyPathsRef = useRef<Set<string>>(new Set())
|
const untitledRenameReadyPathsRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Pending app-navigation result to process once navigation functions are ready
|
||||||
|
const pendingAppNavRef = useRef<Record<string, unknown> | null>(null)
|
||||||
|
|
||||||
// Global navigation history (back/forward) across views (chat/file/graph/task)
|
// Global navigation history (back/forward) across views (chat/file/graph/task)
|
||||||
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
|
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
|
||||||
const [viewHistory, setViewHistory] = useState(historyRef.current)
|
const [viewHistory, setViewHistory] = useState(historyRef.current)
|
||||||
|
|
@ -1660,6 +1665,15 @@ function App() {
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle app-navigation tool results — trigger UI side effects
|
||||||
|
if (event.toolName === 'app-navigation') {
|
||||||
|
const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined
|
||||||
|
if (result?.success) {
|
||||||
|
pendingAppNavRef.current = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2563,6 +2577,106 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [selectedPath, baseConfigByPath, loadDirectory, navigateToView])
|
}, [selectedPath, baseConfigByPath, loadDirectory, navigateToView])
|
||||||
|
|
||||||
|
// External search set by app-navigation tool (passed to BasesView)
|
||||||
|
const [externalBaseSearch, setExternalBaseSearch] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// Process pending app-navigation results
|
||||||
|
useEffect(() => {
|
||||||
|
const result = pendingAppNavRef.current
|
||||||
|
if (!result) return
|
||||||
|
pendingAppNavRef.current = null
|
||||||
|
|
||||||
|
switch (result.action) {
|
||||||
|
case 'open-note':
|
||||||
|
navigateToFile(result.path as string)
|
||||||
|
break
|
||||||
|
case 'open-view':
|
||||||
|
if (result.view === 'graph') void navigateToView({ type: 'graph' })
|
||||||
|
if (result.view === 'bases') {
|
||||||
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'update-base-view': {
|
||||||
|
// Navigate to bases if not already there
|
||||||
|
const targetPath = selectedPath && isBaseFilePath(selectedPath) ? selectedPath : BASES_DEFAULT_TAB_PATH
|
||||||
|
if (!selectedPath || !isBaseFilePath(selectedPath)) {
|
||||||
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates to the base config
|
||||||
|
const updates = result.updates as Record<string, unknown> | undefined
|
||||||
|
if (updates) {
|
||||||
|
setBaseConfigByPath(prev => {
|
||||||
|
const current = prev[targetPath] ?? { ...DEFAULT_BASE_CONFIG }
|
||||||
|
const next = { ...current }
|
||||||
|
|
||||||
|
// Apply filter updates
|
||||||
|
const filterUpdates = updates.filters as Record<string, unknown> | undefined
|
||||||
|
if (filterUpdates) {
|
||||||
|
if (filterUpdates.clear) {
|
||||||
|
next.filters = []
|
||||||
|
}
|
||||||
|
if (filterUpdates.set) {
|
||||||
|
next.filters = filterUpdates.set as Array<{ category: string; value: string }>
|
||||||
|
}
|
||||||
|
if (filterUpdates.add) {
|
||||||
|
const toAdd = filterUpdates.add as Array<{ category: string; value: string }>
|
||||||
|
const existing = next.filters
|
||||||
|
for (const f of toAdd) {
|
||||||
|
if (!existing.some(e => e.category === f.category && e.value === f.value)) {
|
||||||
|
existing.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterUpdates.remove) {
|
||||||
|
const toRemove = filterUpdates.remove as Array<{ category: string; value: string }>
|
||||||
|
next.filters = next.filters.filter(
|
||||||
|
e => !toRemove.some(r => r.category === e.category && r.value === e.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply column updates
|
||||||
|
const colUpdates = updates.columns as Record<string, unknown> | undefined
|
||||||
|
if (colUpdates) {
|
||||||
|
if (colUpdates.set) {
|
||||||
|
next.visibleColumns = colUpdates.set as string[]
|
||||||
|
}
|
||||||
|
if (colUpdates.add) {
|
||||||
|
const toAdd = colUpdates.add as string[]
|
||||||
|
for (const col of toAdd) {
|
||||||
|
if (!next.visibleColumns.includes(col)) next.visibleColumns.push(col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colUpdates.remove) {
|
||||||
|
const toRemove = new Set(colUpdates.remove as string[])
|
||||||
|
next.visibleColumns = next.visibleColumns.filter(c => !toRemove.has(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort
|
||||||
|
if (updates.sort) {
|
||||||
|
next.sort = updates.sort as { field: string; dir: 'asc' | 'desc' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, [targetPath]: next }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply search externally
|
||||||
|
if (updates.search !== undefined) {
|
||||||
|
setExternalBaseSearch(updates.search as string || undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'create-base':
|
||||||
|
if (result.path) {
|
||||||
|
navigateToFile(result.path as string)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const navigateToFullScreenChat = useCallback(() => {
|
const navigateToFullScreenChat = useCallback(() => {
|
||||||
// Only treat this as navigation when coming from another view
|
// Only treat this as navigation when coming from another view
|
||||||
if (currentViewState.type !== 'chat') {
|
if (currentViewState.type !== 'chat') {
|
||||||
|
|
@ -3184,6 +3298,10 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isToolCall(item)) {
|
if (isToolCall(item)) {
|
||||||
|
const appActionData = getAppActionCardData(item)
|
||||||
|
if (appActionData) {
|
||||||
|
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
||||||
|
}
|
||||||
const webSearchData = getWebSearchCardData(item)
|
const webSearchData = getWebSearchCardData(item)
|
||||||
if (webSearchData) {
|
if (webSearchData) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -3492,6 +3610,8 @@ function App() {
|
||||||
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
|
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
|
||||||
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
|
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
|
||||||
onSave={(name) => void handleBaseSave(name)}
|
onSave={(name) => void handleBaseSave(name)}
|
||||||
|
externalSearch={externalBaseSearch}
|
||||||
|
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : isGraphOpen ? (
|
) : isGraphOpen ? (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FilterIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PlusCircleIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AppActionCardData } from "@/lib/chat-conversation";
|
||||||
|
|
||||||
|
interface AppActionCardProps {
|
||||||
|
data: AppActionCardData;
|
||||||
|
status: "pending" | "running" | "completed" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionIcons: Record<string, React.ReactNode> = {
|
||||||
|
"open-note": <FileTextIcon className="size-4" />,
|
||||||
|
"open-view": <NetworkIcon className="size-4" />,
|
||||||
|
"update-base-view": <FilterIcon className="size-4" />,
|
||||||
|
"create-base": <PlusCircleIcon className="size-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppActionCard({ data, status }: AppActionCardProps) {
|
||||||
|
const isRunning = status === "pending" || status === "running";
|
||||||
|
const isError = status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="not-prose mb-4 flex items-center gap-2 rounded-md border px-3 py-2">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{actionIcons[data.action] || <LayoutGridIcon className="size-4" />}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm flex-1">{data.label}</span>
|
||||||
|
{isRunning ? (
|
||||||
|
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : isError ? (
|
||||||
|
<span className="text-xs text-destructive">Failed</span>
|
||||||
|
) : (
|
||||||
|
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,10 @@ type BasesViewProps = {
|
||||||
onConfigChange: (config: BaseConfig) => void
|
onConfigChange: (config: BaseConfig) => void
|
||||||
isDefaultBase: boolean
|
isDefaultBase: boolean
|
||||||
onSave: (name: string | null) => void
|
onSave: (name: string | null) => void
|
||||||
|
/** Search query set externally (e.g. by app-navigation tool). */
|
||||||
|
externalSearch?: string
|
||||||
|
/** Called after the external search has been consumed (applied to internal state). */
|
||||||
|
onExternalSearchConsumed?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||||
|
|
@ -142,7 +146,7 @@ function getSortValue(note: NoteEntry, column: string): string | number {
|
||||||
const isBuiltin = (col: string): col is BuiltinColumn =>
|
const isBuiltin = (col: string): col is BuiltinColumn =>
|
||||||
(BUILTIN_COLUMNS as readonly string[]).includes(col)
|
(BUILTIN_COLUMNS as readonly string[]).includes(col)
|
||||||
|
|
||||||
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) {
|
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) {
|
||||||
// Build notes instantly from tree
|
// Build notes instantly from tree
|
||||||
const notes = useMemo<NoteEntry[]>(() => {
|
const notes = useMemo<NoteEntry[]>(() => {
|
||||||
return collectFiles(tree).map((f) => ({
|
return collectFiles(tree).map((f) => ({
|
||||||
|
|
@ -300,6 +304,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
|
||||||
// Search
|
// Search
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Apply external search from app-navigation tool
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalSearch !== undefined) {
|
||||||
|
setSearchQuery(externalSearch)
|
||||||
|
setSearchOpen(true)
|
||||||
|
onExternalSearchConsumed?.()
|
||||||
|
}
|
||||||
|
}, [externalSearch, onExternalSearchConsumed])
|
||||||
const debouncedSearch = useDebounce(searchQuery, 250)
|
const debouncedSearch = useDebounce(searchQuery, 250)
|
||||||
const [searchMatchPaths, setSearchMatchPaths] = useState<Set<string> | null>(null)
|
const [searchMatchPaths, setSearchMatchPaths] = useState<Set<string> | null>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,89 @@ export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null =
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App navigation action card data
|
||||||
|
export type AppActionCardData = {
|
||||||
|
action: string
|
||||||
|
label: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const summarizeFilterUpdates = (updates: Record<string, unknown>): string => {
|
||||||
|
const filters = updates.filters as Record<string, unknown> | undefined
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
if (filters.clear) parts.push('Cleared filters')
|
||||||
|
const set = filters.set as Array<{ category: string; value: string }> | undefined
|
||||||
|
if (set?.length) parts.push(`Set ${set.length} filter${set.length !== 1 ? 's' : ''}: ${set.map(f => `${f.category}=${f.value}`).join(', ')}`)
|
||||||
|
const add = filters.add as Array<{ category: string; value: string }> | undefined
|
||||||
|
if (add?.length) parts.push(`Added ${add.length} filter${add.length !== 1 ? 's' : ''}`)
|
||||||
|
const remove = filters.remove as Array<{ category: string; value: string }> | undefined
|
||||||
|
if (remove?.length) parts.push(`Removed ${remove.length} filter${remove.length !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.sort) {
|
||||||
|
const sort = updates.sort as { field: string; dir: string }
|
||||||
|
parts.push(`Sorted by ${sort.field} ${sort.dir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.search !== undefined) {
|
||||||
|
parts.push(updates.search ? `Searching "${updates.search}"` : 'Cleared search')
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = updates.columns as Record<string, unknown> | undefined
|
||||||
|
if (columns) {
|
||||||
|
const set = columns.set as string[] | undefined
|
||||||
|
if (set) parts.push(`Set ${set.length} column${set.length !== 1 ? 's' : ''}`)
|
||||||
|
const add = columns.add as string[] | undefined
|
||||||
|
if (add?.length) parts.push(`Added ${add.length} column${add.length !== 1 ? 's' : ''}`)
|
||||||
|
const remove = columns.remove as string[] | undefined
|
||||||
|
if (remove?.length) parts.push(`Removed ${remove.length} column${remove.length !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(', ') : 'Updated view'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null => {
|
||||||
|
if (tool.name !== 'app-navigation') return null
|
||||||
|
const result = tool.result as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
// While pending/running, derive label from input
|
||||||
|
if (!result || !result.success) {
|
||||||
|
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||||
|
if (!input) return null
|
||||||
|
const action = input.action as string
|
||||||
|
switch (action) {
|
||||||
|
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
|
||||||
|
case 'open-view': return { action, label: `Opening ${input.view} view...` }
|
||||||
|
case 'update-base-view': return { action, label: 'Updating view...' }
|
||||||
|
case 'create-base': return { action, label: `Creating "${input.name}"...` }
|
||||||
|
case 'get-base-state': return null // renders as normal tool block
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.action) {
|
||||||
|
case 'open-note': {
|
||||||
|
const filePath = result.path as string || ''
|
||||||
|
const name = filePath.split('/').pop()?.replace(/\.md$/, '') || 'note'
|
||||||
|
return { action: 'open-note', label: `Opened ${name}` }
|
||||||
|
}
|
||||||
|
case 'open-view':
|
||||||
|
return { action: 'open-view', label: `Opened ${result.view} view` }
|
||||||
|
case 'update-base-view':
|
||||||
|
return {
|
||||||
|
action: 'update-base-view',
|
||||||
|
label: summarizeFilterUpdates(result.updates as Record<string, unknown> || {}),
|
||||||
|
details: result.updates as Record<string, unknown>,
|
||||||
|
}
|
||||||
|
case 'create-base':
|
||||||
|
return { action: 'create-base', label: `Created base "${result.name}"` }
|
||||||
|
default:
|
||||||
|
return null // get-base-state renders as normal tool block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse attached files from message content and return clean message + file paths.
|
// Parse attached files from message content and return clean message + file paths.
|
||||||
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||||
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
export const skill = String.raw`
|
||||||
|
# App Navigation Skill
|
||||||
|
|
||||||
|
You have access to the **app-navigation** tool which lets you control the Rowboat UI directly — opening notes, switching views, filtering the knowledge base, and creating saved views.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
### open-note
|
||||||
|
Open a specific knowledge file in the editor pane.
|
||||||
|
|
||||||
|
**When to use:** When the user asks to see, open, or view a specific note (e.g., "open John's note", "show me the Acme project page").
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename.
|
||||||
|
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
|
||||||
|
|
||||||
|
### open-view
|
||||||
|
Switch the UI to the graph or bases view.
|
||||||
|
|
||||||
|
**When to use:** When the user asks to see the knowledge graph, view all notes, or open the bases/table view.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- ` + "`view`" + `: ` + "`\"graph\"`" + ` or ` + "`\"bases\"`" + `
|
||||||
|
|
||||||
|
### update-base-view
|
||||||
|
Change filters, columns, sort order, or search in the bases (table) view.
|
||||||
|
|
||||||
|
**When to use:** When the user asks to find, filter, sort, or search notes. Examples: "show me all active customers", "filter by topic=hiring", "sort by name", "search for pricing".
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- ` + "`filters`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, ` + "`remove`" + `, or ` + "`clear`" + ` — each takes an array of ` + "`{ category, value }`" + ` pairs.
|
||||||
|
- ` + "`set`" + `: Replace ALL current filters with these.
|
||||||
|
- ` + "`add`" + `: Append filters without removing existing ones.
|
||||||
|
- ` + "`remove`" + `: Remove specific filters.
|
||||||
|
- ` + "`clear: true`" + `: Remove all filters.
|
||||||
|
- ` + "`columns`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, or ` + "`remove`" + ` — each takes an array of column names (frontmatter keys).
|
||||||
|
- ` + "`sort`" + `: ` + "`{ field, dir }`" + ` where dir is ` + "`\"asc\"`" + ` or ` + "`\"desc\"`" + `.
|
||||||
|
- ` + "`search`" + `: Free-text search string.
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- If unsure what categories/values are available, call ` + "`get-base-state`" + ` first.
|
||||||
|
- For "show me X", prefer ` + "`filters.set`" + ` to start fresh rather than ` + "`filters.add`" + `.
|
||||||
|
- Categories come from frontmatter keys (e.g., relationship, status, topic, type).
|
||||||
|
|
||||||
|
### get-base-state
|
||||||
|
Retrieve information about what's in the knowledge base — available filter categories, values, and note count.
|
||||||
|
|
||||||
|
**When to use:** When you need to know what properties exist before filtering, or when the user asks "what can I filter by?", "how many notes are there?", etc.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- ` + "`base_name`" + ` (optional): Name of a saved base to inspect.
|
||||||
|
|
||||||
|
### create-base
|
||||||
|
Save the current view configuration as a named base.
|
||||||
|
|
||||||
|
**When to use:** When the user asks to save a filtered view, create a saved search, or says "save this as [name]".
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- ` + "`name`" + `: Human-readable name for the base.
|
||||||
|
|
||||||
|
## Workflow Example
|
||||||
|
|
||||||
|
1. User: "Show me all people who are customers"
|
||||||
|
2. First, check what properties are available:
|
||||||
|
` + "`app-navigation({ action: \"get-base-state\" })`" + `
|
||||||
|
3. Apply filters based on the available properties:
|
||||||
|
` + "`app-navigation({ action: \"update-base-view\", filters: { set: [{ category: \"relationship\", value: \"customer\" }] } })`" + `
|
||||||
|
4. If the user wants to save it:
|
||||||
|
` + "`app-navigation({ action: \"create-base\", name: \"Customers\" })`" + `
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
- The ` + "`update-base-view`" + ` action will automatically navigate to the bases view if the user isn't already there.
|
||||||
|
- ` + "`open-note`" + ` validates that the file exists before navigating.
|
||||||
|
- Filter categories and values come from frontmatter in knowledge files.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default skill;
|
||||||
|
|
@ -11,6 +11,7 @@ import slackSkill from "./slack/skill.js";
|
||||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||||
import webSearchSkill from "./web-search/skill.js";
|
import webSearchSkill from "./web-search/skill.js";
|
||||||
|
import appNavigationSkill from "./app-navigation/skill.js";
|
||||||
|
|
||||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||||
|
|
@ -95,6 +96,12 @@ const definitions: SkillDefinition[] = [
|
||||||
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
||||||
content: deletionGuardrailsSkill,
|
content: deletionGuardrailsSkill,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "app-navigation",
|
||||||
|
title: "App Navigation",
|
||||||
|
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
||||||
|
content: appNavigationSkill,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const skillEntries = definitions.map((definition) => ({
|
const skillEntries = definitions.map((definition) => ({
|
||||||
|
|
|
||||||
22
apps/x/packages/shared/src/bases.ts
Normal file
22
apps/x/packages/shared/src/bases.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Shared types for the Bases view (saved filtered views over the knowledge graph).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface ActiveFilter {
|
||||||
|
category: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseConfig {
|
||||||
|
filters: ActiveFilter[];
|
||||||
|
columns: string[];
|
||||||
|
sort?: { field: string; dir: SortDir };
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BASE_CONFIG: BaseConfig = {
|
||||||
|
filters: [],
|
||||||
|
columns: [],
|
||||||
|
};
|
||||||
60
apps/x/packages/shared/src/frontmatter.ts
Normal file
60
apps/x/packages/shared/src/frontmatter.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* Frontmatter parsing utilities for knowledge base markdown files.
|
||||||
|
* Used by core (get-base-state) to scan knowledge files for available properties.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a markdown file's YAML frontmatter into key-value pairs.
|
||||||
|
* Handles both scalar values (`key: value`) and list values (`key:\n - item`).
|
||||||
|
* Returns `{ fields, body }` where fields maps keys to string or string[].
|
||||||
|
*/
|
||||||
|
export function parseFrontmatter(content: string): { fields: Record<string, string | string[]>; body: string } {
|
||||||
|
if (!content.startsWith('---')) {
|
||||||
|
return { fields: {}, body: content };
|
||||||
|
}
|
||||||
|
const endIndex = content.indexOf('\n---', 3);
|
||||||
|
if (endIndex === -1) {
|
||||||
|
return { fields: {}, body: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBlock = content.slice(4, endIndex); // skip opening '---\n'
|
||||||
|
const body = content.slice(endIndex + 4).replace(/^\n/, '');
|
||||||
|
const fields: Record<string, string | string[]> = {};
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
|
||||||
|
for (const line of rawBlock.split('\n')) {
|
||||||
|
if (line.trim() === '' || line === '---') {
|
||||||
|
currentKey = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level key: value
|
||||||
|
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/);
|
||||||
|
if (topMatch) {
|
||||||
|
const key = topMatch[1];
|
||||||
|
const value = topMatch[2].trim();
|
||||||
|
if (value) {
|
||||||
|
fields[key] = value;
|
||||||
|
currentKey = null;
|
||||||
|
} else {
|
||||||
|
// List will follow
|
||||||
|
currentKey = key;
|
||||||
|
fields[key] = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List item under current key
|
||||||
|
if (currentKey) {
|
||||||
|
const itemMatch = line.match(/^\s+-\s+(.+)$/);
|
||||||
|
if (itemMatch) {
|
||||||
|
const arr = fields[currentKey];
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
arr.push(itemMatch[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fields, body };
|
||||||
|
}
|
||||||
|
|
@ -8,4 +8,6 @@ export * as agentSchedule from './agent-schedule.js';
|
||||||
export * as agentScheduleState from './agent-schedule-state.js';
|
export * as agentScheduleState from './agent-schedule-state.js';
|
||||||
export * as serviceEvents from './service-events.js'
|
export * as serviceEvents from './service-events.js'
|
||||||
export * as inlineTask from './inline-task.js';
|
export * as inlineTask from './inline-task.js';
|
||||||
|
export * as frontmatter from './frontmatter.js';
|
||||||
|
export * as bases from './bases.js';
|
||||||
export { PrefixLogger };
|
export { PrefixLogger };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue