mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +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 { 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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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>/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue