diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 768ff02b..10e68e02 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -35,6 +35,7 @@ import { 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 { AppActionCard } from '@/components/ai-elements/app-action-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; @@ -62,6 +63,7 @@ import { type ToolCall, createEmptyChatTabViewState, getWebSearchCardData, + getAppActionCardData, inferRunTitleFromMessage, isChatMessage, isErrorMessage, @@ -499,6 +501,9 @@ function App() { const recentLocalMarkdownWritesRef = useRef>(new Map()) const untitledRenameReadyPathsRef = useRef>(new Set()) + // Pending app-navigation result to process once navigation functions are ready + const pendingAppNavRef = useRef | null>(null) + // Global navigation history (back/forward) across views (chat/file/graph/task) const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] }) const [viewHistory, setViewHistory] = useState(historyRef.current) @@ -1660,6 +1665,15 @@ function App() { } 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 } @@ -2563,6 +2577,106 @@ function App() { } }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + // External search set by app-navigation tool (passed to BasesView) + const [externalBaseSearch, setExternalBaseSearch] = useState(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 | undefined + if (updates) { + setBaseConfigByPath(prev => { + const current = prev[targetPath] ?? { ...DEFAULT_BASE_CONFIG } + const next = { ...current } + + // Apply filter updates + const filterUpdates = updates.filters as Record | 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 | 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(() => { // Only treat this as navigation when coming from another view if (currentViewState.type !== 'chat') { @@ -3184,6 +3298,10 @@ function App() { } if (isToolCall(item)) { + const appActionData = getAppActionCardData(item) + if (appActionData) { + return + } const webSearchData = getWebSearchCardData(item) if (webSearchData) { return ( @@ -3492,6 +3610,8 @@ function App() { onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)} isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH} onSave={(name) => void handleBaseSave(name)} + externalSearch={externalBaseSearch} + onExternalSearchConsumed={() => setExternalBaseSearch(undefined)} /> ) : isGraphOpen ? ( diff --git a/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx new file mode 100644 index 00000000..35f605c5 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx @@ -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 = { + "open-note": , + "open-view": , + "update-base-view": , + "create-base": , +}; + +export function AppActionCard({ data, status }: AppActionCardProps) { + const isRunning = status === "pending" || status === "running"; + const isError = status === "error"; + + return ( +
+ + {actionIcons[data.action] || } + + {data.label} + {isRunning ? ( + + ) : isError ? ( + Failed + ) : ( + + )} +
+ ); +} diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx index 83fc07c0..7462f5b5 100644 --- a/apps/x/apps/renderer/src/components/bases-view.tsx +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -87,6 +87,10 @@ type BasesViewProps = { onConfigChange: (config: BaseConfig) => void isDefaultBase: boolean 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 }[] { @@ -142,7 +146,7 @@ function getSortValue(note: NoteEntry, column: string): string | number { const isBuiltin = (col: string): col is BuiltinColumn => (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 const notes = useMemo(() => { return collectFiles(tree).map((f) => ({ @@ -300,6 +304,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul // Search const [searchOpen, setSearchOpen] = useState(false) 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 [searchMatchPaths, setSearchMatchPaths] = useState | null>(null) const searchInputRef = useRef(null) diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 830e250b..256de6d8 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -150,6 +150,89 @@ export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null = return null } +// App navigation action card data +export type AppActionCardData = { + action: string + label: string + details?: Record +} + +const summarizeFilterUpdates = (updates: Record): string => { + const filters = updates.filters as Record | 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 | 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 | undefined + + // While pending/running, derive label from input + if (!result || !result.success) { + const input = normalizeToolInput(tool.input) as Record | 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 || {}), + details: result.updates as Record, + } + 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. export const parseAttachedFiles = (content: string): { message: string; files: string[] } => { const attachedFilesRegex = /\s*([\s\S]*?)\s*<\/attached-files>/ diff --git a/apps/x/packages/core/src/application/assistant/skills/app-navigation/skill.ts b/apps/x/packages/core/src/application/assistant/skills/app-navigation/skill.ts new file mode 100644 index 00000000..99f143f4 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/app-navigation/skill.ts @@ -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; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 97217f76..f0b9186f 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -11,6 +11,7 @@ import slackSkill from "./slack/skill.js"; import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/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 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.", 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) => ({ diff --git a/apps/x/packages/shared/src/bases.ts b/apps/x/packages/shared/src/bases.ts new file mode 100644 index 00000000..544dcf00 --- /dev/null +++ b/apps/x/packages/shared/src/bases.ts @@ -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: [], +}; diff --git a/apps/x/packages/shared/src/frontmatter.ts b/apps/x/packages/shared/src/frontmatter.ts new file mode 100644 index 00000000..575db100 --- /dev/null +++ b/apps/x/packages/shared/src/frontmatter.ts @@ -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; 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 = {}; + 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 }; +} diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index dc08d3ab..40b80759 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -8,4 +8,6 @@ export * as agentSchedule from './agent-schedule.js'; export * as agentScheduleState from './agent-schedule-state.js'; export * as serviceEvents from './service-events.js' export * as inlineTask from './inline-task.js'; +export * as frontmatter from './frontmatter.js'; +export * as bases from './bases.js'; export { PrefixLogger };