app navigation

This commit is contained in:
Arjun 2026-03-13 10:26:08 +05:30
parent 8f1adfb6a5
commit e730c118dc
9 changed files with 433 additions and 1 deletions

View file

@ -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<Map<string, number>>(new Map())
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)
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<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(() => {
// 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 <AppActionCard key={item.id} data={appActionData} status={item.status} />
}
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)}
/>
</div>
) : isGraphOpen ? (

View file

@ -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>
);
}

View file

@ -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<NoteEntry[]>(() => {
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<Set<string> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)

View file

@ -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<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.
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/

View file

@ -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;

View file

@ -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) => ({

View 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: [],
};

View 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 };
}

View file

@ -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 };