From f78f1380ebda999917c6bf0749b6bcbbfd1b0993 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 27 May 2026 23:17:25 +0530 Subject: [PATCH 01/29] added expand buttons for middle and side pane and fixed issue with moving new chat to side pane --- apps/x/apps/renderer/src/App.tsx | 24 ++++++--- .../renderer/src/components/chat-sidebar.tsx | 53 ++++++------------- 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a35fbca7..f7a50074 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon, X } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -3391,8 +3391,10 @@ function App() { setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay]) - const handleCloseFullScreenChat = useCallback(() => { + const handleCloseFullScreenChat = useCallback((): boolean => { + let restored = false if (expandedFrom) { + restored = true if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) @@ -3434,10 +3436,16 @@ function App() { setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(expandedFrom.path) + } else { + // expandedFrom was captured from a view this restorer doesn't track + // (e.g. Home): there's nothing to re-open, so report it and let the + // caller fall back instead of leaving a blank full-screen chat. + restored = false } setExpandedFrom(null) setIsRightPaneMaximized(false) } + return restored }, [expandedFrom]) const currentViewState = React.useMemo(() => { @@ -3885,12 +3893,13 @@ function App() { const pushChatToSidePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(true) - if (expandedFrom) { - handleCloseFullScreenChat() - } else { + // Restore the view we expanded from; if there was nothing to restore + // (e.g. the chat was started fresh from Home), fall back to Home so a + // single click always docks the chat instead of needing two. + if (!handleCloseFullScreenChat()) { void navigateToView({ type: 'home' }) } - }, [expandedFrom, handleCloseFullScreenChat, navigateToView]) + }, [handleCloseFullScreenChat, navigateToView]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -5375,7 +5384,7 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: toggleRightPaneMaximize, icon: , label: 'Expand chat' } + ? { onClick: () => setIsChatSidebarOpen(false), icon: , label: 'Expand pane' } : null return ( @@ -5899,7 +5908,6 @@ function App() { }} onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })} onOpenFullScreen={toggleRightPaneMaximize} - onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }} conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 0c054bb7..9197753b 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowRight, X } from 'lucide-react' +import { ArrowLeft, ArrowRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -125,7 +125,6 @@ interface ChatSidebarProps { onSelectRun?: (runId: string) => void onOpenChatHistory?: () => void onOpenFullScreen?: () => void - onCloseChat?: () => void conversation: ConversationItem[] currentAssistantMessage: string chatTabStates?: Record @@ -183,7 +182,6 @@ export function ChatSidebar({ onSelectRun, onOpenChatHistory, onOpenFullScreen, - onCloseChat, conversation, currentAssistantMessage, chatTabStates = {}, @@ -515,40 +513,21 @@ export function ChatSidebar({ onSelectRun={onSelectRun} onOpenChatHistory={onOpenChatHistory} /> - {isMaximized ? ( - onOpenFullScreen && ( - - - - - Dock to side pane - - ) - ) : ( - onCloseChat && ( - - - - - Close chat - - ) + {onOpenFullScreen && ( + + + + + {isMaximized ? 'Dock to side pane' : 'Expand chat'} + )} From 2f9ce051c0fddaaef531fc4c03c8bbb9a55efa87 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 27 May 2026 23:17:47 +0530 Subject: [PATCH 02/29] Add chat log download menu --- apps/x/apps/main/src/ipc.ts | 30 ++++++++++ apps/x/apps/renderer/src/App.tsx | 56 ++++++++++++++++++- .../renderer/src/components/chat-sidebar.tsx | 56 ++++++++++++++++++- apps/x/packages/shared/src/ipc.ts | 9 +++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 78f8b55e..638af656 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -8,6 +8,7 @@ import { listProviders, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; +import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; @@ -531,6 +532,35 @@ export function setupIpcHandlers() { await runsCore.deleteRun(args.runId); return { success: true }; }, + 'runs:downloadLog': async (event, args) => { + const runFileName = `${args.runId}.jsonl`; + if (path.basename(runFileName) !== runFileName) { + return { success: false, error: 'Invalid run id' }; + } + + const sourcePath = path.join(WorkDir, 'runs', runFileName); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${runFileName}.log`, + filters: [ + { name: 'Chat Log', extensions: ['log'] }, + { name: 'JSONL', extensions: ['jsonl'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { success: false }; + } + + try { + await fs.copyFile(sourcePath, result.filePath); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download chat log'; + return { success: false, error: message }; + } + }, 'models:list': async () => { if (await isSignedIn()) { return await listGatewayModels(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e6c050b3..cd668fa2 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react'; +import { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, SquarePen, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -61,6 +61,12 @@ import { } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' @@ -4662,6 +4668,25 @@ function App() { return chatViewStateByTab[tabId] ?? emptyChatTabState }, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState]) const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage + const activeRunIdForDownload = activeChatTabState.runId + const handleDownloadActiveChatLog = useCallback(async () => { + if (!activeRunIdForDownload) { + toast.error('No chat log available yet') + return + } + + try { + const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunIdForDownload }) + if (result.success) { + toast.success('Chat log saved') + } else if (result.error) { + toast.error(result.error) + } + } catch (err) { + console.error('Download chat log failed:', err) + toast.error('Failed to download chat log') + } + }, [activeRunIdForDownload]) const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null @@ -4885,6 +4910,35 @@ function App() { New chat tab )} + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && ( + + + + + + + + Chat options + + + { + void handleDownloadActiveChatLog() + }} + > + + Download chat log + + + + )} {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isBrowserOpen && expandedFrom && ( diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 06680652..aeb0c167 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,9 +1,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Maximize2, Minimize2, SquarePen } from 'lucide-react' +import { Bug, Maximize2, Minimize2, MoreHorizontal, SquarePen } from 'lucide-react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Conversation, ConversationContent, @@ -381,6 +388,25 @@ export function ChatSidebar({ return chatTabStates[tabId] ?? emptyTabState }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage) + const activeRunId = activeTabState.runId + const handleDownloadChatLog = useCallback(async () => { + if (!activeRunId) { + toast.error('No chat log available yet') + return + } + + try { + const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId }) + if (result.success) { + toast.success('Chat log saved') + } else if (result.error) { + toast.error(result.error) + } + } catch (err) { + console.error('Download chat log failed:', err) + toast.error('Failed to download chat log') + } + }, [activeRunId]) const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { @@ -585,6 +611,34 @@ export function ChatSidebar({ New chat tab + + + + + + + + Chat options + + + { + void handleDownloadChatLog() + }} + > + + Download chat log + + + {onOpenFullScreen && ( diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 37cf41e7..b1f1e80c 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -246,6 +246,15 @@ const ipcSchemas = { }), res: z.object({ success: z.boolean() }), }, + 'runs:downloadLog': { + req: z.object({ + runId: z.string().min(1), + }), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, 'runs:events': { req: z.null(), res: z.null(), From 0af48ecd4ad0ec22757215aba09a4af13383529a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:33:21 +0530 Subject: [PATCH 03/29] new knowledge view --- apps/x/apps/renderer/src/App.tsx | 6 +- .../src/components/knowledge-view.tsx | 803 +++++++++++++----- 2 files changed, 597 insertions(+), 212 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f758e180..dd671eda 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4554,10 +4554,8 @@ function App() { void navigateToView({ type: 'workspace', path }) }, openKnowledgeView: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { - setIsChatSidebarOpen(false) - setIsRightPaneMaximized(false) - } + // Open in the middle pane without touching the chat sidebar — leave it + // open or closed exactly as the user had it (matches Email/Meetings). void navigateToView({ type: 'knowledge-view' }) }, createWorkspace: async (name: string): Promise => { diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index a757a34c..c7674fb4 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + ArrowLeft, ChevronRight, Copy, ExternalLink, - File as FileIcon, FilePlus, - Folder as FolderIcon, + FileText, FolderOpen, FolderPlus, Network, @@ -56,9 +56,48 @@ type KnowledgeViewProps = { onVoiceNoteCreated?: (path: string) => void } -type FlatRow = { - node: TreeNode - depth: number +// Folders that have their own dedicated destinations elsewhere in the app. +const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace']) + +// Theme-aware accent palette for folder avatars — colored letter on a faint +// tint of the same hue. Mirrors the design's six-colour rotation. +const AVATAR_PALETTE = [ + 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400', + 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + 'bg-sky-500/10 text-sky-600 dark:text-sky-400', +] as const + +function avatarClass(name: string): string { + let hash = 0 + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0 + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length] +} + +function isMarkdown(node: TreeNode): boolean { + return node.kind === 'file' && node.name.toLowerCase().endsWith('.md') +} + +// All markdown notes within a node (recurses into subfolders). +function collectNotes(node: TreeNode): TreeNode[] { + if (node.kind === 'file') return isMarkdown(node) ? [node] : [] + const out: TreeNode[] = [] + for (const child of node.children ?? []) out.push(...collectNotes(child)) + return out +} + +function recentNotes(node: TreeNode, limit: number): TreeNode[] { + return collectNotes(node) + .sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0)) + .slice(0, limit) +} + +function latestMtime(node: TreeNode): number { + let max = node.stat?.mtimeMs ?? 0 + for (const child of node.children ?? []) max = Math.max(max, latestMtime(child)) + return max } function sortNodes(nodes: TreeNode[]): TreeNode[] { @@ -68,23 +107,22 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { }) } -function flatten( - nodes: TreeNode[], - expanded: Set, - depth: number, - out: FlatRow[], -): void { - for (const node of sortNodes(nodes)) { - out.push({ node, depth }) - if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) { - flatten(node.children, expanded, depth + 1, out) +function findNode(nodes: TreeNode[], path: string): TreeNode | null { + for (const node of nodes) { + if (node.path === path) return node + if (node.children) { + const found = findNode(node.children, path) + if (found) return found } } + return null } function formatModified(mtimeMs?: number): string { if (!mtimeMs) return '' - return formatRelativeTime(new Date(mtimeMs).toISOString()) + const rel = formatRelativeTime(new Date(mtimeMs).toISOString()) + if (!rel || rel === 'just now') return rel + return `${rel} ago` } function getFileManagerName(): string { @@ -96,15 +134,10 @@ function getFileManagerName(): string { } function displayName(node: TreeNode): string { - if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) { - return node.name.slice(0, -3) - } + if (isMarkdown(node)) return node.name.slice(0, -3) return node.name } -const INDENT_PX = 16 -const ROW_PADDING_PX = 12 - export function KnowledgeView({ tree, actions, @@ -114,191 +147,594 @@ export function KnowledgeView({ onOpenBases, onVoiceNoteCreated, }: KnowledgeViewProps) { - const [expanded, setExpanded] = useState>(new Set()) + // null = root (folder overview); otherwise the path of the folder being browsed. + const [folderPath, setFolderPath] = useState(null) const [renameTarget, setRenameTarget] = useState(null) - const rows = useMemo(() => { - const out: FlatRow[] = [] - // Meetings and Workspace have dedicated destinations, so hide them here. - const visible = tree.filter((n) => n.path !== 'knowledge/Meetings' && n.path !== 'knowledge/Workspace') - flatten(visible, expanded, 0, out) - return out - }, [tree, expanded]) - - const handleRowClick = useCallback( - (node: TreeNode) => { - if (node.kind === 'dir') { - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(node.path)) next.delete(node.path) - else next.add(node.path) - return next - }) - } else { - onOpenNote(node.path) - } - }, - [onOpenNote], + const topLevel = useMemo( + () => tree.filter((n) => !HIDDEN_PATHS.has(n.path)), + [tree], ) + const folders = useMemo( + () => sortNodes(topLevel.filter((n) => n.kind === 'dir')), + [topLevel], + ) + const looseNotes = useMemo( + () => sortNodes(topLevel.filter((n) => isMarkdown(n))), + [topLevel], + ) + + const totalNotes = useMemo( + () => topLevel.reduce((sum, n) => sum + collectNotes(n).length, 0), + [topLevel], + ) + + const openFolder = useCallback((path: string) => setFolderPath(path), []) + + // When the open folder no longer exists (deleted/renamed externally), fall + // back to the root overview rather than holding a dangling drill-down. + const currentFolder = folderPath ? findNode(tree, folderPath) : null + return (
-
-

Notes

-
+
+
+

Notes

+

+ {totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '} + {folders.length === 1 ? 'folder' : 'folders'} +

+
+
+ + + - - - - - -
-
-
-
Page name
-
Modified
-
- - {rows.length === 0 ? ( -
No pages yet.
+
+ {currentFolder ? ( + setRenameTarget(null)} + onNavigate={setFolderPath} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> ) : ( - rows.map(({ node, depth }) => ( - setRenameTarget(p)} - onClearRename={() => setRenameTarget(null)} - onClick={handleRowClick} - /> - )) + <> + + {folders.length === 0 ? ( + + ) : ( +
+ {folders.map((node, i) => ( +
0 && 'border-t border-border/60')}> + setRenameTarget(null)} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> +
+ ))} +
+ )} + + {looseNotes.length > 0 && ( +
+ +
+ {looseNotes.map((node, i) => ( +
0 && 'border-t border-border/60')}> + setRenameTarget(null)} + onOpenFolder={openFolder} + onOpenNote={onOpenNote} + /> +
+ ))} +
+
+ )} + )} + +
) } -function KnowledgeRow({ - node, - depth, - isExpanded, +function QuickActions({ actions, - renameActive, - onRequestRename, - onClearRename, + currentFolder, + onOpenBases, + onFolderCreated, +}: { + actions: KnowledgeViewActions + currentFolder: TreeNode | null + onOpenBases: () => void + onFolderCreated: (path: string) => void +}) { + // Inside a folder these target that folder; at the root they target knowledge/. + const parent = currentFolder?.path + return ( +
+ +
+ actions.createNote(parent)} /> + { + try { + const path = await actions.createFolder(parent) + onFolderCreated(path) + } catch { /* ignore */ } + }} + /> + + actions.revealInFileManager(parent ?? 'knowledge', true)} + /> +
+
+ ) +} + +function SecondaryButton({ + icon: Icon, + label, onClick, +}: { + icon: typeof SearchIcon + label: string + onClick: () => void +}) { + return ( + + ) +} + +function QuickAction({ + icon: Icon, + label, + onClick, +}: { + icon: typeof FilePlus + label: string + onClick: () => void +}) { + return ( + + ) +} + +function SectionHeader({ label, aside }: { label: string; aside?: string }) { + return ( +
+ + {label} + + {aside && {aside}} +
+ ) +} + +function EmptyState({ text }: { text: string }) { + return ( +
+ {text} +
+ ) +} + +function FolderAvatar({ name, className }: { name: string; className?: string }) { + return ( +
+ {name.charAt(0).toUpperCase() || '?'} +
+ ) +} + +function FolderCard({ + node, + actions, + renameTarget, + onRequestRename, + onClearRename, + onOpenFolder, + onOpenNote, }: { node: TreeNode - depth: number - isExpanded: boolean actions: KnowledgeViewActions - renameActive: boolean + renameTarget: string | null onRequestRename: (path: string) => void onClearRename: () => void - onClick: (node: TreeNode) => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void +}) { + const count = useMemo(() => collectNotes(node).length, [node]) + const peek = useMemo(() => recentNotes(node, 3), [node]) + const modified = formatModified(latestMtime(node)) + const renameActive = renameTarget === node.path + + const card = ( +
onOpenFolder(node.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFolder(node.path) + } + }} + className="group flex w-full cursor-pointer items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50" + > + +
+ {renameActive ? ( + + ) : ( + + {node.name} + + )} +
+ {count} {count === 1 ? 'note' : 'notes'} +
+ {peek.length > 0 && ( +
+ {peek.map((n) => ( + + ))} +
+ )} +
+
+ + {modified} + + +
+
+ ) + + return ( + + {card} + + ) +} + +function FolderDetail({ + folder, + actions, + renameTarget, + onRequestRename, + onClearRename, + onNavigate, + onOpenFolder, + onOpenNote, +}: { + folder: TreeNode + actions: KnowledgeViewActions + renameTarget: string | null + onRequestRename: (path: string) => void + onClearRename: () => void + onNavigate: (path: string | null) => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void +}) { + const items = useMemo(() => sortNodes(folder.children ?? []), [folder]) + + // Breadcrumb segments from "knowledge/A/B" → [{ name: 'A', path }, ...]. + const crumbs = useMemo(() => { + const rel = folder.path.startsWith('knowledge/') + ? folder.path.slice('knowledge/'.length) + : folder.path + const parts = rel.split('/').filter(Boolean) + const out: { name: string; path: string }[] = [] + let acc = 'knowledge' + for (const part of parts) { + acc = `${acc}/${part}` + out.push({ name: part, path: acc }) + } + return out + }, [folder.path]) + + return ( + <> +
+ + + {crumbs.map((c, i) => ( + + + {i === crumbs.length - 1 ? ( + {c.name} + ) : ( + + )} + + ))} +
+ + + {items.length === 0 ? ( + + ) : ( +
+ {items.map((node, i) => ( +
0 && 'border-t border-border/60')}> + +
+ ))} +
+ )} + + ) +} + +function ItemRow({ + node, + actions, + renameTarget, + onRequestRename, + onClearRename, + onOpenFolder, + onOpenNote, +}: { + node: TreeNode + actions: KnowledgeViewActions + renameTarget: string | null + onRequestRename: (path: string) => void + onClearRename: () => void + onOpenFolder: (path: string) => void + onOpenNote: (path: string) => void }) { const isDir = node.kind === 'dir' - const Icon = isDir ? FolderIcon : FileIcon - const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX - const baseName = displayName(node) + const renameActive = renameTarget === node.path + const modified = formatModified(isDir ? latestMtime(node) : node.stat?.mtimeMs) + const count = useMemo(() => (isDir ? collectNotes(node).length : 0), [isDir, node]) - const [newName, setNewName] = useState(baseName) + const handleOpen = useCallback(() => { + if (isDir) onOpenFolder(node.path) + else onOpenNote(node.path) + }, [isDir, node.path, onOpenFolder, onOpenNote]) + + const row = ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleOpen() + } + }} + className="group flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50" + > + {isDir ? ( + + ) : ( +
+ +
+ )} +
+ {renameActive ? ( + + ) : ( + {displayName(node)} + )} + {isDir && ( +
+ {count} {count === 1 ? 'note' : 'notes'} +
+ )} +
+
+ + {modified} + + {isDir && ( + + )} +
+
+ ) + + return ( + + {row} + + ) +} + +function RenameField({ + initial, + isDir, + path, + actions, + onDone, +}: { + initial: string + isDir: boolean + path: string + actions: KnowledgeViewActions + onDone: () => void +}) { + const [value, setValue] = useState(initial) const inputRef = useRef(null) const isSubmittingRef = useRef(false) useEffect(() => { - if (renameActive) { - setNewName(baseName) - isSubmittingRef.current = false - // focus on next tick after mount - requestAnimationFrame(() => { - inputRef.current?.focus() - inputRef.current?.select() - }) - } - }, [renameActive, baseName]) + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + }, []) - const handleRenameSubmit = useCallback(async () => { + const submit = useCallback(async () => { if (isSubmittingRef.current) return isSubmittingRef.current = true - const trimmed = newName.trim() - if (trimmed && trimmed !== baseName) { + const trimmed = value.trim() + if (trimmed && trimmed !== initial) { try { - await actions.rename(node.path, trimmed, isDir) + await actions.rename(path, trimmed, isDir) toast('Renamed successfully', 'success') } catch { toast('Failed to rename', 'error') } } - onClearRename() - setTimeout(() => { - isSubmittingRef.current = false - }, 100) - }, [actions, baseName, isDir, newName, node.path, onClearRename]) + onDone() + }, [actions, initial, isDir, onDone, path, value]) - const cancelRename = useCallback(() => { + const cancel = useCallback(() => { isSubmittingRef.current = true - setNewName(baseName) - onClearRename() - setTimeout(() => { - isSubmittingRef.current = false - }, 100) - }, [baseName, onClearRename]) + onDone() + }, [onDone]) + + return ( + setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + void submit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }} + onBlur={() => { + if (!isSubmittingRef.current) void submit() + }} + className="h-7 text-sm" + /> + ) +} + +function RowContextMenu({ + node, + actions, + onRequestRename, + children, +}: { + node: TreeNode + actions: KnowledgeViewActions + onRequestRename: (path: string) => void + children: React.ReactNode +}) { + const isDir = node.kind === 'dir' const handleDelete = useCallback(async () => { try { @@ -314,58 +750,9 @@ function KnowledgeRow({ toast('Path copied', 'success') }, [actions, node.path]) - const row = ( - - ) - return ( - {row} + {children} e.preventDefault()}> {isDir && ( <> From 373d1ee92be0523d99e36710163708d9b706ba35 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:33:38 +0530 Subject: [PATCH 04/29] search defaults to knowledge --- apps/x/apps/renderer/src/App.tsx | 9 ++++++--- .../x/apps/renderer/src/components/search-dialog.tsx | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index dd671eda..690c6790 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -80,7 +80,7 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' -import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog' import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' @@ -1253,6 +1253,8 @@ function App() { // Search state const [isSearchOpen, setIsSearchOpen] = useState(false) + // Optional scope override for the next time search opens (cleared on close). + const [searchDefaultScope, setSearchDefaultScope] = useState(undefined) // Background tasks state type BackgroundTaskItem = { @@ -5553,7 +5555,7 @@ function App() { }} onOpenNote={(path) => navigateToFile(path)} onOpenGraph={() => knowledgeActions.openGraph()} - onOpenSearch={() => setIsSearchOpen(true)} + onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }} onOpenBases={() => knowledgeActions.openBases()} onVoiceNoteCreated={handleVoiceNoteCreated} /> @@ -6016,7 +6018,8 @@ function App() {
{ setIsSearchOpen(o); if (!o) setSearchDefaultScope(undefined) }} + defaultScope={searchDefaultScope} onSelectFile={navigateToFile} onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }} /> diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx index 56f0875a..4e50fa0b 100644 --- a/apps/x/apps/renderer/src/components/search-dialog.tsx +++ b/apps/x/apps/renderer/src/components/search-dialog.tsx @@ -21,7 +21,7 @@ interface SearchResult { path: string } -type SearchType = 'knowledge' | 'chat' +export type SearchType = 'knowledge' | 'chat' function activeTabToTypes(section: ActiveSection): SearchType[] { if (section === 'knowledge') return ['knowledge'] @@ -46,6 +46,9 @@ interface CommandPaletteProps { onOpenChange: (open: boolean) => void onSelectFile: (path: string) => void onSelectRun: (runId: string) => void + // Overrides the sidebar-section default for the initial scope (e.g. the + // knowledge view opens search scoped to knowledge). + defaultScope?: SearchType } export function CommandPalette({ @@ -53,6 +56,7 @@ export function CommandPalette({ onOpenChange, onSelectFile, onSelectRun, + defaultScope, }: CommandPaletteProps) { const { activeSection } = useSidebarSection() const searchInputRef = useRef(null) @@ -61,7 +65,7 @@ export function CommandPalette({ const [results, setResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const [activeTypes, setActiveTypes] = useState>( - () => new Set(activeTabToTypes(activeSection)) + () => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)) ) const debouncedQuery = useDebounce(query, 250) @@ -69,9 +73,9 @@ export function CommandPalette({ useEffect(() => { if (open) { setQuery('') - setActiveTypes(new Set(activeTabToTypes(activeSection))) + setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))) } - }, [open, activeSection]) + }, [open, activeSection, defaultScope]) useEffect(() => { if (!open) return From 78c5ad2e6f36d96afd548b8549c932ef2861a74d Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 00:41:42 +0530 Subject: [PATCH 05/29] elevate folder navigation to app view state --- apps/x/apps/renderer/src/App.tsx | 19 ++++++++++++++----- .../src/components/knowledge-view.tsx | 12 ++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 690c6790..3c653b1b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -587,7 +587,7 @@ type ViewState = | { type: 'live-notes' } | { type: 'email' } | { type: 'workspace'; path?: string } - | { type: 'knowledge-view' } + | { type: 'knowledge-view'; folderPath?: string } | { type: 'chat-history' } | { type: 'home' } @@ -597,6 +597,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') + if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') return true // both graph } @@ -644,8 +645,10 @@ function parseDeepLink(input: string): ViewState | null { const path = params.get('path') return { type: 'workspace', path: path ?? undefined } } - case 'knowledge-view': - return { type: 'knowledge-view' } + case 'knowledge-view': { + const folderPath = params.get('folderPath') + return { type: 'knowledge-view', folderPath: folderPath ?? undefined } + } case 'chat-history': return { type: 'chat-history' } case 'home': @@ -762,6 +765,9 @@ function App() { const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + // Folder being browsed inside the knowledge view (null = root overview). + // Lives in ViewState so folder drill-down participates in back/forward history. + const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) // Default landing view: Home in the middle with the chat docked on the right. const [isHomeOpen, setIsHomeOpen] = useState(true) @@ -3463,13 +3469,13 @@ function App() { if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } - if (isKnowledgeViewOpen) return { type: 'knowledge-view' } + if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3809,6 +3815,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) + setKnowledgeViewFolderPath(view.folderPath ?? null) setIsChatHistoryOpen(false) setIsHomeOpen(false) ensureKnowledgeViewFileTab() @@ -5553,6 +5560,8 @@ function App() { revealInFileManager: knowledgeActions.revealInFileManager, onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + folderPath={knowledgeViewFolderPath} + onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }} onOpenNote={(path) => navigateToFile(path)} onOpenGraph={() => knowledgeActions.openGraph()} onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }} diff --git a/apps/x/apps/renderer/src/components/knowledge-view.tsx b/apps/x/apps/renderer/src/components/knowledge-view.tsx index c7674fb4..e7ebe780 100644 --- a/apps/x/apps/renderer/src/components/knowledge-view.tsx +++ b/apps/x/apps/renderer/src/components/knowledge-view.tsx @@ -49,6 +49,10 @@ export type KnowledgeViewActions = { type KnowledgeViewProps = { tree: TreeNode[] actions: KnowledgeViewActions + // Folder currently being browsed (null = root overview). Controlled by the + // app so drill-down participates in the global back/forward history. + folderPath: string | null + onNavigateFolder: (path: string | null) => void onOpenNote: (path: string) => void onOpenGraph: () => void onOpenSearch: () => void @@ -141,14 +145,14 @@ function displayName(node: TreeNode): string { export function KnowledgeView({ tree, actions, + folderPath, + onNavigateFolder, onOpenNote, onOpenGraph, onOpenSearch, onOpenBases, onVoiceNoteCreated, }: KnowledgeViewProps) { - // null = root (folder overview); otherwise the path of the folder being browsed. - const [folderPath, setFolderPath] = useState(null) const [renameTarget, setRenameTarget] = useState(null) const topLevel = useMemo( @@ -170,7 +174,7 @@ export function KnowledgeView({ [topLevel], ) - const openFolder = useCallback((path: string) => setFolderPath(path), []) + const openFolder = useCallback((path: string) => onNavigateFolder(path), [onNavigateFolder]) // When the open folder no longer exists (deleted/renamed externally), fall // back to the root overview rather than holding a dangling drill-down. @@ -210,7 +214,7 @@ export function KnowledgeView({ renameTarget={renameTarget} onRequestRename={setRenameTarget} onClearRename={() => setRenameTarget(null)} - onNavigate={setFolderPath} + onNavigate={onNavigateFolder} onOpenFolder={openFolder} onOpenNote={onOpenNote} /> From daff21481affb5ef10ec3cee68e2dae0a5b618bb Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 01:07:12 +0530 Subject: [PATCH 06/29] show recording status in sidebar --- .../src/components/sidebar-content.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index dbb5edb5..0368b1da 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -691,10 +691,20 @@ export function SidebarContentPanel({ // Single preview shown as a sublabel on the Email / Meetings nav buttons. const previewEmail = emailThreads[0] const previewMeeting = meetings[0] - const meetingIsRecording = previewMeeting != null - && recordingMeetingSource === previewMeeting.source - && (meetingRecordingState === 'recording' || meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping') - const meetingIsBusy = meetingIsRecording && (meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping') + // Drive the recording indicator off the global recording state — there is only + // one active recording, so it must show even for ad-hoc recordings or meetings + // that aren't the upcoming one previewed here. + const meetingIsRecording = meetingRecordingState === 'recording' + || meetingRecordingState === 'connecting' + || meetingRecordingState === 'stopping' + const meetingIsBusy = meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping' + // Title of the meeting being recorded, when it's the upcoming one we preview. + const recordingMeeting = previewMeeting != null && recordingMeetingSource === previewMeeting.source + ? previewMeeting + : null + const meetingSublabel = meetingIsRecording + ? (recordingMeeting?.summary ?? 'Recording…') + : (previewMeeting ? `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}` : null) return ( @@ -750,19 +760,22 @@ export function SidebarContentPanel({ - +
Meetings - {previewMeeting && ( - - {meetingIsRecording ? previewMeeting.summary : `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}`} + {meetingSublabel && ( + + {meetingSublabel} )}
- {previewMeeting && (meetingIsRecording ? ( + {meetingIsRecording ? (
@@ -786,7 +799,7 @@ export function SidebarContentPanel({
- ) : ( + ) : previewMeeting ? (
@@ -819,7 +832,7 @@ export function SidebarContentPanel({ )}
- ))} + ) : null} Date: Thu, 28 May 2026 01:57:46 +0530 Subject: [PATCH 07/29] feat: redesign web search & tool-call cards (rolling reveal, shared surface, action summaries) (#579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: roll web search sources in one-by-one with settle animation * fix: keep web search toggle on for the rest of the chat session * feat: redesign collapsed web search card with favicon stack and source summary * style: tune web search card surface tints for light and dark mode * feat: rounder web search card with subtle expand/collapse animation * feat: apply web search card design to tool-call box with action summary Shared --card-surface token, rounded card, hover, collapse animation, and a state-driven lead icon (spinner/check/cross). Single tools and the group now match. Completed group shows 'Ran N tools · , more...' with the action summary in lighter gray. * style: drop lead icon from tool group child rows and round them more --- apps/x/apps/renderer/src/App.css | 28 ++ .../src/components/ai-elements/tool.tsx | 101 ++++--- .../ai-elements/web-search-result.tsx | 249 +++++++++++++++--- .../components/chat-input-with-mentions.tsx | 3 +- .../renderer/src/lib/chat-conversation.ts | 57 ++++ 5 files changed, 343 insertions(+), 95 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 46763d5c..86c6535d 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -35,6 +35,30 @@ } } +/* Radix Collapsible expand/collapse — animate height (via the radix CSS var) + plus a subtle fade. Used by the web search card. */ +@keyframes collapsible-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -1176,6 +1200,10 @@ --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); + /* Subtle raised-card surface: tints toward foreground, so it reads a hair + darker than the background in light mode and a hair lighter in dark mode. + Shared by the web search card and tool-call group. */ + --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground)); --rowboat-panel: oklch(0.97 0 0); --rowboat-raised: oklch(1 0 0); --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 5f65fa32..61ba6fbd 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -1,6 +1,5 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, @@ -9,17 +8,15 @@ import { import { cn } from "@/lib/utils"; import type { ToolUIPart } from "ai"; import { - CheckCircleIcon, ChevronDownIcon, - CircleIcon, - ClockIcon, - WrenchIcon, + CircleCheck, + LoaderIcon, XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation"; -import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; +import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; @@ -52,7 +49,10 @@ export type ToolProps = ComponentProps; export const Tool = ({ className, ...props }: ToolProps) => ( ); @@ -62,37 +62,17 @@ export type ToolHeaderProps = { type: ToolUIPart["type"]; state: ToolUIPart["state"]; className?: string; + /** Hide the leading status icon (used for child rows inside a tool group). */ + hideLeadIcon?: boolean; }; -const getStatusBadge = (status: ToolUIPart["state"]) => { - const labels: Record = { - "input-streaming": "Pending", - "input-available": "Running", - // @ts-expect-error state only available in AI SDK v6 - "approval-requested": "Awaiting Approval", - "approval-responded": "Responded", - "output-available": "Completed", - "output-error": "Error", - "output-denied": "Denied", - }; - - const icons: Record = { - "input-streaming": , - "input-available": , - // @ts-expect-error state only available in AI SDK v6 - "approval-requested": , - "approval-responded": , - "output-available": , - "output-error": , - "output-denied": , - }; - - return ( - - {icons[status]} - {labels[status]} - - ); +// Lead icon shown to the left of the tool label: spinner while running, a +// green check when done, a red cross on error. Shared by ToolHeader (single +// tools) and the tool-call group. +const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => { + if (state === "output-available") return ; + if (state === "output-error") return ; + return ; }; export const ToolHeader = ({ @@ -100,6 +80,7 @@ export const ToolHeader = ({ title, type, state, + hideLeadIcon, ...props }: ToolHeaderProps) => { const displayTitle = title ?? type.split("-").slice(1).join("-") @@ -107,13 +88,13 @@ export const ToolHeader = ({ return (
- + {!hideLeadIcon && getLeadIcon(state)}
-
- {getStatusBadge(state)} - -
+
) }; @@ -134,7 +112,7 @@ export type ToolContentProps = ComponentProps; export const ToolContent = ({ className, ...props }: ToolContentProps) => ( t.status === 'running' || t.status === 'pending') const currentTool = runningTool ?? group.items[group.items.length - 1] - const summary = isCompleted - ? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}` + const toolCount = group.items.length + const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}` + const actions = isCompleted ? getToolActionsSummary(group.items) : '' + // Plain string used as the AnimatePresence key + tooltip; the rendered node + // shows the action summary in a lighter gray than the "Ran N tools" prefix. + const summaryText = isCompleted + ? `${ranLabel} · ${actions}` : currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items) + const summaryNode: ReactNode = isCompleted + ? <>{ranLabel} {`· ${actions}`} + : summaryText + + const leadIcon = getLeadIcon(state) return ( - +
- + {leadIcon}
- {summary} + {summaryNode}
-
- {getStatusBadge(state)} - -
+
- +
{group.items.map((tool) => { const toolState = toToolState(tool.status) @@ -291,12 +276,14 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool key={tool.id} open={isOpen} onOpenChange={(o) => onToolOpenChange(tool.id, o)} - className="mb-0 border-border/60" + className="mb-0 rounded-[20px] border-border/60 bg-transparent hover:border-border/60" > (); + const out: string[] = []; + for (const result of results) { + const domain = getDomain(result.url); + if (seen.has(domain)) continue; + seen.add(domain); + out.push(domain); + } + return out; +} + +// Summary with text hierarchy: "Searched" + "and N others" are secondary +// weight/color, the domain names are primary text at medium weight. +function buildSearchedSummary(domains: string[]): React.ReactNode { + const muted = "font-normal text-muted-foreground"; + const name = (d: string) => {d}; + if (domains.length === 1) { + return ( + <> + Searched + {name(domains[0])} + + ); + } + if (domains.length === 2) { + return ( + <> + Searched + {name(domains[0])} + and + {name(domains[1])} + + ); + } + const others = domains.length - 2; + return ( + <> + Searched + {name(domains[0])} + , + {name(domains[1])} + {` and ${others} other${others !== 1 ? "s" : ""}`} + + ); +} + +type RollPhase = "searching" | "rolling" | "settled"; + export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { const isRunning = status === "pending" || status === "running"; + const [open, setOpen] = useState(false); - return ( - - -
- - {title} -
- -
- -
- {/* Query + result count */} -
-
- - {query} -
- {results.length > 0 && ( - - {results.length} result{results.length !== 1 ? "s" : ""} + const domains = useMemo(() => uniqueDomains(results), [results]); + + // Drive the one-shot rolling reveal. Results arrive all at once, so we + // simulate "fetching one site at a time" by stepping through them with the + // same slide animation the tool group uses, then settle on a summary. + // `settled` is seeded from the initial status so a card loaded already- + // complete from history skips straight to the summary (no roll). + const [settled, setSettled] = useState(() => !isRunning); + const [rollIndex, setRollIndex] = useState(0); + + // Phase is fully derived: searching while the tool runs, rolling once + // results land, then settled. No setState-in-effect needed for transitions. + const phase: RollPhase = isRunning + ? "searching" + : !settled && results.length > 0 + ? "rolling" + : "settled"; + + // Warm the browser cache for every favicon the moment results arrive, so + // each icon is already loaded by the time its row rolls in (~700ms each). + // Without this the network fetch lags the text and rows flash icon-less. + useEffect(() => { + for (const result of results) { + const img = new Image(); + img.src = faviconUrl(getDomain(result.url)); + } + }, [results]); + + // Advance the roll, then settle after the last site has had its moment. + // setState only fires inside the timeout callback, never synchronously. + useEffect(() => { + if (phase !== "rolling") return; + const isLast = rollIndex >= results.length - 1; + const timer = setTimeout( + () => (isLast ? setSettled(true) : setRollIndex((i) => i + 1)), + ROLL_INTERVAL_MS, + ); + return () => clearTimeout(timer); + }, [phase, rollIndex, results.length]); + + // Build the content for the compact (collapsed) header line. Each distinct + // value gets a unique key so AnimatePresence runs the slide transition. + let headerKey: string; + let headerContent: React.ReactNode; + if (phase === "searching") { + headerKey = "searching"; + headerContent = ( + + + Searching the web… + + ); + } else if (phase === "rolling") { + const result = results[rollIndex]; + const domain = getDomain(result.url); + headerKey = `roll-${rollIndex}`; + headerContent = ( + + + + {domain} + · + {result.title} + + + ); + } else { + headerKey = "settled"; + const stack = domains.slice(0, MAX_STACK); + // Chip count matches the "and N others" in the text (total minus the 2 + // named domains), shown only when there are sites beyond the stack. + const overflow = domains.length > MAX_STACK ? domains.length - 2 : 0; + headerContent = ( + + {domains.length > 0 ? ( + + {stack.map((domain, i) => ( + + ))} + {overflow > 0 && ( + + +{overflow} )} + + ) : ( + + )} + + {domains.length > 0 ? buildSearchedSummary(domains) : title} + + + ); + } + + return ( + + + {/* Rolling header: clipped, fixed height so sliding lines stay contained */} +
+ + + {headerContent} + + +
+
+ {phase === "settled" && domains.length > 0 && ( + + {domains.length} source{domains.length !== 1 ? "s" : ""} + + )} + +
+
+ +
+ {/* Query */} +
+ + {query}
{/* Results list */} @@ -73,7 +255,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the >
@@ -88,20 +270,13 @@ export function WebSearchResult({ query, results, status, title = "Searched the
)} - {/* Status */} -
- {isRunning ? ( - <> - - Searching... - - ) : ( - <> - - Done - - )} -
+ {/* Status — only while the search is still running. */} + {isRunning && ( +
+ + Searching... +
+ )}
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 013a68ad..d59b4047 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -382,7 +382,8 @@ function ChatInputInner({ controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) - setSearchEnabled(false) + // Web search toggle stays on for the rest of the chat session; the user + // turns it off explicitly. (Not persisted across app restarts.) }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 576997ad..41344107 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -653,6 +653,63 @@ export const getToolGroupSummary = (tools: ToolCall[]): string => { return names.join(' · ') } +// Past-tense action phrases for summarizing a finished tool group, e.g. +// "read 3 files, listed directory". Keyed by builtin tool name. +const TOOL_ACTION_VERBS: Record = { + 'file-readText': { verb: 'read', one: 'file', many: 'files' }, + 'file-writeText': { verb: 'wrote', one: 'file', many: 'files' }, + 'file-editText': { verb: 'edited', one: 'file', many: 'files' }, + 'file-list': { verb: 'listed', one: 'directory', many: 'directories' }, + 'file-exists': { verb: 'checked', one: 'path', many: 'paths' }, + 'file-stat': { verb: 'inspected', one: 'file', many: 'files' }, + 'file-glob': { verb: 'searched for', one: 'file', many: 'files' }, + 'file-grep': { verb: 'searched', one: 'file', many: 'files' }, + 'file-mkdir': { verb: 'created', one: 'directory', many: 'directories' }, + 'file-rename': { verb: 'renamed', one: 'file', many: 'files' }, + 'file-copy': { verb: 'copied', one: 'file', many: 'files' }, + 'file-remove': { verb: 'removed', one: 'file', many: 'files' }, + 'file-getRoot': { verb: 'resolved', one: 'file root', many: 'file roots' }, + 'executeCommand': { verb: 'ran', one: 'command', many: 'commands' }, + 'executeMcpTool': { verb: 'ran', one: 'MCP tool', many: 'MCP tools' }, + 'listMcpServers': { verb: 'listed', one: 'MCP server', many: 'MCP servers' }, + 'listMcpTools': { verb: 'listed', one: 'MCP tool', many: 'MCP tools' }, + 'save-to-memory': { verb: 'saved', one: 'memory', many: 'memories' }, + 'loadSkill': { verb: 'loaded', one: 'skill', many: 'skills' }, + 'parseFile': { verb: 'parsed', one: 'file', many: 'files' }, +} + +// Summarize what a group of tools actually did, grouping identical actions +// and counting them: "read 3 files, listed directory". Unmapped tools fall +// back to their lowercased display name. +export const getToolActionsSummary = (tools: ToolCall[]): string => { + const order: string[] = [] + const grouped = new Map() + for (const tool of tools) { + const phrase = TOOL_ACTION_VERBS[tool.name] ?? null + const key = phrase ? `${phrase.verb}|${phrase.one}` : tool.name + const existing = grouped.get(key) + if (existing) { + existing.count++ + } else { + grouped.set(key, { phrase, count: 1, fallback: getToolDisplayName(tool) }) + order.push(key) + } + } + const phrases = order.map((key) => { + const { phrase, count, fallback } = grouped.get(key)! + if (!phrase) return fallback.toLowerCase() + if (count > 1) return `${phrase.verb} ${count} ${phrase.many}` + const article = /^[aeiou]/i.test(phrase.one) ? 'an' : 'a' + return `${phrase.verb} ${article} ${phrase.one}` + }) + // Show at most two operations; collapse the rest into "more...". + const MAX_ACTIONS = 2 + if (phrases.length > MAX_ACTIONS) { + return `${phrases.slice(0, MAX_ACTIONS).join(', ')}, more...` + } + return phrases.join(', ') +} + export const inferRunTitleFromMessage = (content: string): string | undefined => { const { message } = parseAttachedFiles(content) const normalized = message.replace(/\s+/g, ' ').trim() From 537b6f66bbe14a37b557f6855228ec654e44915f Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 28 May 2026 14:52:09 +0530 Subject: [PATCH 08/29] Code Mode: in-chat toggle, settings tab, and permission/command UX (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add in-chat code mode toggle with claude/codex swap * feat: show agent and add swap-and-retry on acpx permission card * style: reorder permission card buttons (approve, deny, swap) * feat: add tooltips to composer plus and web search buttons * feat: add code mode settings tab with agent install/auth checks * feat: show sign-in command when agent installed but signed out * style: refine code-mode permission and command block UX - Render permission block before the command block - Collapse permission details after a response; click header to expand - Drop status icons/badge; use minimal green / bold red blocks - Auto-collapse the running command block once it completes * feat: rotating progress labels for code-mode commands; darker tool borders - Code-mode (acpx) command block shows status-aware labels: rotating 'Working on the task…' phrases (5s each, holding on the last) while running, then 'Completed the task' / "Couldn't complete the task" - Darken outer border on all tool blocks in light and dark modes * fix: detect Claude Code sign-in via macOS Keychain On macOS, Claude Code stores OAuth credentials in the login Keychain (service 'Claude Code-credentials'), not in ~/.claude/.credentials.json. Read the Keychain as a fallback so signed-in Mac users are detected. * feat: persistent per-chat sessions for code-mode coding agents - Use a named acpx session (rowboat-) per chat so follow-up coding requests resume the same agent and keep context - Create the session once at chat start (sessions new --name), then prompt with -s ; reuse on follow-ups (no re-create) - Drop the redundant in-chat 'reply yes' confirmation (the executeCommand permission card is the confirmation) - Code-mode output uses plain-text paths (overrides global filepath rule) - On not-installed/auth errors, point user to Settings -> Code Mode * fix: code-mode session creation uses idempotent ensure, run sequentially - Use 'sessions ensure --name' instead of 'sessions new' so reopening a chat resumes the existing session instead of erroring on a name clash - Create the session and run the prompt as separate sequential calls so the permission/command blocks render one at a time (not all at once) * fix: reliable Claude Code session resume on Windows (avoid claude.cmd EINVAL) Resuming a code-mode chat after restarting the app spawns a fresh ACP agent. On Windows + Node >=20.12 the bridge spawning claude.cmd throws EINVAL, so the session queue owner fails to start. Rowboat injects CLAUDE_CODE_EXECUTABLE=claude.exe to dodge this, but the override didn't reliably reach the spawn. Windows-only; no-op on macOS/Linux. - executeCommand now accepts an env override and the non-abortable fallback path passes it through (was silently dropped) - resolveClaudeExeOnWindows also scans known npm/pnpm/volta global bin dirs, not just PATH (Electron's runtime PATH can omit them) - add --timeout 600 to acpx prompt commands so a genuine stall fails cleanly instead of hanging on 'Running' forever --- apps/x/apps/main/src/ipc.ts | 19 +- apps/x/apps/renderer/src/App.tsx | 41 +++- .../ai-elements/ask-human-request.tsx | 79 ++++--- .../ai-elements/permission-request.tsx | 89 +++++--- .../components/chat-input-with-mentions.tsx | 187 ++++++++++++++-- .../renderer/src/components/chat-sidebar.tsx | 2 +- .../src/components/settings-dialog.tsx | 208 +++++++++++++++++- .../renderer/src/lib/chat-conversation.ts | 32 +++ apps/x/packages/core/src/agents/runtime.ts | 56 ++++- .../src/application/assistant/instructions.ts | 37 ++-- .../skills/code-with-agents/skill.ts | 160 +++++++++----- .../core/src/application/lib/builtin-tools.ts | 44 +++- .../src/application/lib/command-executor.ts | 2 + .../core/src/application/lib/message-queue.ts | 8 +- apps/x/packages/core/src/code-mode/index.ts | 3 + apps/x/packages/core/src/code-mode/repo.ts | 42 ++++ apps/x/packages/core/src/code-mode/status.ts | 199 +++++++++++++++++ apps/x/packages/core/src/code-mode/types.ts | 18 ++ apps/x/packages/core/src/di/container.ts | 2 + apps/x/packages/core/src/runs/runs.ts | 4 +- apps/x/packages/shared/src/ipc.ts | 22 ++ apps/x/packages/shared/src/runs.ts | 1 + 22 files changed, 1084 insertions(+), 171 deletions(-) create mode 100644 apps/x/packages/core/src/code-mode/index.ts create mode 100644 apps/x/packages/core/src/code-mode/repo.ts create mode 100644 apps/x/packages/core/src/code-mode/status.ts create mode 100644 apps/x/packages/core/src/code-mode/types.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ed8b1a7c..2f5730ce 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -31,6 +31,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; +import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; @@ -526,7 +529,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -630,6 +633,20 @@ export function setupIpcHandlers() { const config = await repo.getConfig(); return { enabled: config.enabled }; }, + 'codeMode:getConfig': async () => { + const repo = container.resolve('codeModeConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled }; + }, + 'codeMode:setConfig': async (_event, args) => { + const repo = container.resolve('codeModeConfigRepo'); + await repo.setConfig({ enabled: args.enabled }); + invalidateCopilotInstructionsCache(); + return { success: true }; + }, + 'codeMode:checkAgentStatus': async () => { + return await checkCodeModeAgentStatus(); + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3c653b1b..2bf0c571 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -966,7 +966,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -2190,6 +2190,19 @@ function App() { status: 'running', timestamp: Date.now(), }]) + // Detect acpx-driven coding-agent runs so the composer can retroactively + // flip code mode on with the right agent (when the user reached the skill + // via plain prompt rather than the explicit toggle). + if (llmEvent.toolName === 'executeCommand') { + const input = llmEvent.input as { command?: unknown } | undefined + const cmd = typeof input?.command === 'string' ? input.command : '' + const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) + if (match) { + window.dispatchEvent(new CustomEvent('code-mode-detected', { + detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, + })) + } + } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2304,7 +2317,7 @@ function App() { return next }) - if (event.toolCallId && event.toolName !== 'executeCommand') { + if (event.toolCallId) { setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) } @@ -2482,6 +2495,7 @@ function App() { mentions?: FileMention[], stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, + codeMode?: 'claude' | 'codex', ) => { if (isProcessing) return @@ -2593,6 +2607,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -2608,6 +2623,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -5836,7 +5852,6 @@ function App() { const response = tabState.permissionResponses.get(item.id) || null return ( - {rendered} handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + onSwitchAgent={async (newAgent) => { + const runIdForSwitch = tab.runId + await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') + window.dispatchEvent(new CustomEvent('code-mode-detected', { + detail: { runId: runIdForSwitch, agent: newAgent }, + })) + if (runIdForSwitch) { + try { + await window.ipc.invoke('runs:createMessage', { + runId: runIdForSwitch, + message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, + codeMode: newAgent, + }) + } catch (err) { + console.error('Failed to send swap-agent follow-up', err) + } + } + }} isProcessing={isActive && isProcessing} response={response} /> + {rendered} ) } @@ -5858,6 +5892,7 @@ function App() { handleAskHumanResponse(request.toolCallId, request.subflow, response)} isProcessing={isActive && isProcessing} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx index 2e92e2ca..6571e54e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx @@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react"; export type AskHumanRequestProps = ComponentProps<"div"> & { query: string; + options?: string[]; onResponse: (response: string) => void; isProcessing?: boolean; }; @@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & { export const AskHumanRequest = ({ className, query, + options, onResponse, isProcessing = false, ...props }: AskHumanRequestProps) => { const [response, setResponse] = useState(""); const textareaRef = useRef(null); + const hasOptions = Array.isArray(options) && options.length > 0; useEffect(() => { - // Auto-focus the textarea when component mounts - textareaRef.current?.focus(); - }, []); + // Auto-focus the textarea when in free-text mode; nothing to focus for buttons. + if (!hasOptions) { + textareaRef.current?.focus(); + } + }, [hasOptions]); const handleSubmit = () => { const trimmed = response.trim(); @@ -36,6 +41,11 @@ export const AskHumanRequest = ({ } }; + const handleOptionClick = (option: string) => { + if (isProcessing) return; + onResponse(option); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -65,30 +75,47 @@ export const AskHumanRequest = ({ {query}

-
-