Merge remote-tracking branch 'origin/dev' into feat/code-mode-chip-and-sessions

# Conflicts:
#	apps/x/apps/renderer/src/components/ai-elements/tool.tsx
#	apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
This commit is contained in:
Gagancreates 2026-05-28 14:47:53 +05:30
commit fc93c00baf
12 changed files with 1169 additions and 371 deletions

View file

@ -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';
@ -552,6 +553,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();

View file

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

View file

@ -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 { Bug, CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, MoreHorizontal, Plus, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@ -65,6 +65,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 { BillingErrorDialog } from "@/components/billing-error-dialog"
@ -74,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'
@ -581,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' }
@ -591,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
}
@ -638,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':
@ -756,6 +765,9 @@ function App() {
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(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<string | null>(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)
@ -1247,6 +1259,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<SearchType | undefined>(undefined)
// Background tasks state
type BackgroundTaskItem = {
@ -3407,8 +3421,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)
@ -3450,10 +3466,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<ViewState>(() => {
@ -3463,13 +3485,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 +3831,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(true)
setKnowledgeViewFolderPath(view.folderPath ?? null)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
ensureKnowledgeViewFileTab()
@ -3901,12 +3924,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
@ -4555,10 +4579,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<string> => {
@ -5193,6 +5215,25 @@ function App() {
if (tabId === activeChatTabId) return activeChatTabState
return chatViewStateByTab[tabId] ?? emptyChatTabState
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
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
@ -5380,6 +5421,33 @@ function App() {
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
aria-label="Chat options"
>
<MoreHorizontal className="size-5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunIdForDownload}
onSelect={() => {
void handleDownloadActiveChatLog()
}}
>
<Bug className="size-4" />
Download chat log
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Trailing layout control. Always mounted (just toggled invisible
when inactive) so its -webkit-app-region:no-drag rect is stable
a freshly-mounted no-drag button inside the drag-region header
@ -5391,7 +5459,7 @@ function App() {
: (viewOpen && !isChatSidebarOpen)
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
: (viewOpen && isChatSidebarOpen && !isRightPaneMaximized)
? { onClick: toggleRightPaneMaximize, icon: <X className="size-5" />, label: 'Expand chat' }
? { onClick: () => setIsChatSidebarOpen(false), icon: <ArrowRight className="size-5" />, label: 'Expand pane' }
: null
return (
<Tooltip>
@ -5508,9 +5576,11 @@ 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={() => setIsSearchOpen(true)}
onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }}
onOpenBases={() => knowledgeActions.openBases()}
onVoiceNoteCreated={handleVoiceNoteCreated}
/>
@ -5934,7 +6004,6 @@ function App() {
}}
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
onOpenFullScreen={toggleRightPaneMaximize}
onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }}
conversation={conversation}
currentAssistantMessage={currentAssistantMessage}
chatTabStates={chatViewStateByTab}
@ -5993,7 +6062,8 @@ function App() {
</div>
<CommandPalette
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
onOpenChange={(o) => { setIsSearchOpen(o); if (!o) setSearchDefaultScope(undefined) }}
defaultScope={searchDefaultScope}
onSelectFile={navigateToFile}
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
/>

View file

@ -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<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full rounded-md border border-foreground/25 dark:border-foreground/30", className)}
className={cn(
"not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
className
)}
{...props}
/>
);
@ -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<ToolUIPart["state"], string> = {
"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<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
// @ts-expect-error state only available in AI SDK v6
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
// 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 <CircleCheck className="size-4 shrink-0 text-green-600" />;
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
};
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 (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
className
)}
{...props}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
{!hideLeadIcon && getLeadIcon(state)}
<span
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
title={displayTitle}
@ -121,10 +102,7 @@ export const ToolHeader = ({
{displayTitle}
</span>
</div>
<div className="flex shrink-0 items-center gap-3">
{getStatusBadge(state)}
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
)
};
@ -134,7 +112,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
"overflow-hidden text-popover-foreground outline-none data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]",
className
)}
{...props}
@ -247,41 +225,48 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
const isCompleted = state === 'output-available' || state === 'output-error'
const runningTool = group.items.find(t => 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} <span className="font-normal text-muted-foreground">{`· ${actions}`}</span></>
: summaryText
const leadIcon = getLeadIcon(state)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-md border border-foreground/25 dark:border-foreground/30"
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
{leadIcon}
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={summary}
key={summaryText}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
title={summary}
title={summaryText}
>
{summary}
{summaryNode}
</motion.span>
</AnimatePresence>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{getStatusBadge(state)}
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
</div>
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
<div className="flex flex-col gap-2 p-2">
{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"
>
<ToolHeader
title={getToolDisplayName(tool)}
type={`tool-${tool.name}`}
state={toolState}
className="text-muted-foreground"
hideLeadIcon
/>
<ToolContent>
<ToolTabbedContent

View file

@ -5,12 +5,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckCircleIcon,
ChevronDownIcon,
GlobeIcon,
LoaderIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
interface WebSearchResultProps {
query: string;
@ -19,39 +21,219 @@ interface WebSearchResultProps {
title?: string;
}
// How long each fetched website stays on the rolling header before the
// next one slides in. Kept slow enough to read the domain + title.
const ROLL_INTERVAL_MS = 700;
// How many favicons to show in the settled stack before the rest collapse
// into a "+N" chip. The text names this many domains too, so the chip count
// (total - MAX_STACK) lines up with the "and N others" in the summary.
const MAX_STACK = 3;
function getDomain(url: string): string {
try {
return new URL(url).hostname;
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
function faviconUrl(domain: string, size = 32): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
}
// Collapse the result list into unique domains, preserving order.
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
const seen = new Set<string>();
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) => <span className="font-medium text-foreground">{d}</span>;
if (domains.length === 1) {
return (
<>
<span className={muted}>Searched </span>
{name(domains[0])}
</>
);
}
if (domains.length === 2) {
return (
<>
<span className={muted}>Searched </span>
{name(domains[0])}
<span className={muted}> and </span>
{name(domains[1])}
</>
);
}
const others = domains.length - 2;
return (
<>
<span className={muted}>Searched </span>
{name(domains[0])}
<span className={muted}>, </span>
{name(domains[1])}
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
</>
);
}
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 (
<Collapsible defaultOpen className="not-prose mb-4 w-full rounded-md border">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
<div className="flex items-center gap-2">
<GlobeIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-3">
{/* Query + result count */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<GlobeIcon className="size-3.5 shrink-0" />
<span className="truncate">{query}</span>
</div>
{results.length > 0 && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{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 = (
<span className="flex min-w-0 flex-1 items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 shrink-0 animate-spin" />
<span className="truncate">Searching the web&hellip;</span>
</span>
);
} else if (phase === "rolling") {
const result = results[rollIndex];
const domain = getDomain(result.url);
headerKey = `roll-${rollIndex}`;
headerContent = (
<span className="flex min-w-0 flex-1 items-center gap-2">
<img src={faviconUrl(domain)} alt="" className="size-4 shrink-0 rounded-sm bg-muted/60" />
<span className="truncate">
<span className="text-muted-foreground">{domain}</span>
<span className="text-muted-foreground/50"> &middot; </span>
<span>{result.title}</span>
</span>
</span>
);
} 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 = (
<span className="flex min-w-0 flex-1 items-center gap-2.5">
{domains.length > 0 ? (
<span className="flex shrink-0 items-center">
{stack.map((domain, i) => (
<img
key={domain}
src={faviconUrl(domain)}
alt=""
className="size-5 rounded-full bg-muted object-cover -ml-[5px] first:ml-0"
style={{ zIndex: stack.length - i }}
/>
))}
{overflow > 0 && (
<span className="ml-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground/10 dark:bg-muted text-[10px] font-medium text-muted-foreground">
+{overflow}
</span>
)}
</span>
) : (
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate text-sm">
{domains.length > 0 ? buildSearchedSummary(domains) : title}
</span>
</span>
);
}
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
{/* Rolling header: clipped, fixed height so sliding lines stay contained */}
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: "1.5rem" }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={headerKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="absolute inset-0 flex items-center text-left font-medium text-sm"
>
{headerContent}
</motion.span>
</AnimatePresence>
</div>
<div className="flex shrink-0 items-center gap-2">
{phase === "settled" && domains.length > 0 && (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{domains.length} source{domains.length !== 1 ? "s" : ""}
</span>
)}
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
<div className="px-4 pb-3 space-y-3">
{/* Query */}
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<GlobeIcon className="size-3.5 shrink-0" />
<span className="truncate">{query}</span>
</div>
{/* Results list */}
@ -73,7 +255,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
src={faviconUrl(domain)}
alt=""
className="size-4 shrink-0"
/>
@ -88,20 +270,13 @@ export function WebSearchResult({ query, results, status, title = "Searched the
</div>
)}
{/* Status */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{isRunning ? (
<>
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching...</span>
</>
) : (
<>
<CheckCircleIcon className="size-3.5 text-green-600" />
<span>Done</span>
</>
)}
</div>
{/* Status — only while the search is still running. */}
{isRunning && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching...</span>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>

View file

@ -486,7 +486,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, codeModeEnabled, codingAgent, workDir])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {

View file

@ -1,11 +1,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowRight, X } from 'lucide-react'
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } 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 { ChatHeader } from '@/components/chat-header'
import { ChatEmptyState } from '@/components/chat-empty-state'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Conversation,
ConversationContent,
@ -125,7 +132,6 @@ interface ChatSidebarProps {
onSelectRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenFullScreen?: () => void
onCloseChat?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
chatTabStates?: Record<string, ChatTabViewState>
@ -183,7 +189,6 @@ export function ChatSidebar({
onSelectRun,
onOpenChatHistory,
onOpenFullScreen,
onCloseChat,
conversation,
currentAssistantMessage,
chatTabStates = {},
@ -333,6 +338,25 @@ export function ChatSidebar({
if (tabId === activeChatTabId) return activeTabState
return chatTabStates[tabId] ?? emptyTabState
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
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)) {
@ -515,40 +539,49 @@ export function ChatSidebar({
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
{isMaximized ? (
onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Dock chat to side pane"
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Chat options"
>
<ArrowRight className="size-5" />
<MoreHorizontal className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Dock to side pane</TooltipContent>
</Tooltip>
)
) : (
onCloseChat && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onCloseChat}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Close chat"
>
<X className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close chat</TooltipContent>
</Tooltip>
)
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Chat options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem
disabled={!activeRunId}
onSelect={() => {
void handleDownloadChatLog()
}}
>
<Bug className="size-4" />
Download chat log
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onOpenFullScreen}
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Dock chat to side pane' : 'Expand chat'}
>
{isMaximized ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
</Tooltip>
)}
</header>

View file

@ -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,
@ -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
@ -56,9 +60,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 +111,22 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
})
}
function flatten(
nodes: TreeNode[],
expanded: Set<string>,
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,209 +138,607 @@ 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,
folderPath,
onNavigateFolder,
onOpenNote,
onOpenGraph,
onOpenSearch,
onOpenBases,
onVoiceNoteCreated,
}: KnowledgeViewProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const rows = useMemo<FlatRow[]>(() => {
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) => 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.
const currentFolder = folderPath ? findNode(tree, folderPath) : null
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<div className="flex items-center gap-2">
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<p className="mt-1 text-sm text-muted-foreground">
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
<button
type="button"
onClick={() => actions.createNote()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
onClick={() => actions.createNote(currentFolder?.path)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<FilePlus className="size-4" />
<span>New note</span>
</button>
<button
type="button"
onClick={async () => {
try {
const path = await actions.createFolder()
setRenameTarget(path)
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderPlus className="size-4" />
<span>New folder</span>
</button>
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<button
type="button"
onClick={onOpenSearch}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<SearchIcon className="size-4" />
<span>Search</span>
</button>
<button
type="button"
onClick={onOpenBases}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Table2 className="size-4" />
<span>Bases</span>
</button>
<button
type="button"
onClick={onOpenGraph}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Network className="size-4" />
<span>Graph view</span>
</button>
<button
type="button"
onClick={() => actions.revealInFileManager('knowledge', true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<FolderOpen className="size-4" />
<span>Open in {getFileManagerName()}</span>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Page name</div>
<div className="w-32 shrink-0">Modified</div>
</div>
{rows.length === 0 ? (
<div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div>
<div className="mx-auto w-full max-w-3xl px-8 py-6">
{currentFolder ? (
<FolderDetail
folder={currentFolder}
actions={actions}
renameTarget={renameTarget}
onRequestRename={setRenameTarget}
onClearRename={() => setRenameTarget(null)}
onNavigate={onNavigateFolder}
onOpenFolder={openFolder}
onOpenNote={onOpenNote}
/>
) : (
rows.map(({ node, depth }) => (
<KnowledgeRow
key={node.path}
node={node}
depth={depth}
isExpanded={expanded.has(node.path)}
actions={actions}
renameActive={renameTarget === node.path}
onRequestRename={(p) => setRenameTarget(p)}
onClearRename={() => setRenameTarget(null)}
onClick={handleRowClick}
/>
))
<>
<SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" />
{folders.length === 0 ? (
<EmptyState text="No folders yet." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
{folders.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<FolderCard
node={node}
actions={actions}
renameTarget={renameTarget}
onRequestRename={setRenameTarget}
onClearRename={() => setRenameTarget(null)}
onOpenFolder={openFolder}
onOpenNote={onOpenNote}
/>
</div>
))}
</div>
)}
{looseNotes.length > 0 && (
<div className="mt-8">
<SectionHeader label={`Loose notes · ${looseNotes.length}`} />
<div className="overflow-hidden rounded-xl border border-border">
{looseNotes.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<ItemRow
node={node}
actions={actions}
renameTarget={renameTarget}
onRequestRename={setRenameTarget}
onClearRename={() => setRenameTarget(null)}
onOpenFolder={openFolder}
onOpenNote={onOpenNote}
/>
</div>
))}
</div>
</div>
)}
</>
)}
<QuickActions
actions={actions}
currentFolder={currentFolder}
onOpenBases={onOpenBases}
onFolderCreated={setRenameTarget}
/>
</div>
</div>
</div>
)
}
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 (
<div className="mt-8">
<SectionHeader label="Quick actions" />
<div className="flex flex-wrap gap-2">
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
<QuickAction
icon={FolderPlus}
label="New folder"
onClick={async () => {
try {
const path = await actions.createFolder(parent)
onFolderCreated(path)
} catch { /* ignore */ }
}}
/>
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
<QuickAction
icon={FolderOpen}
label={`Reveal in ${getFileManagerName()}`}
onClick={() => actions.revealInFileManager(parent ?? 'knowledge', true)}
/>
</div>
</div>
)
}
function SecondaryButton({
icon: Icon,
label,
onClick,
}: {
icon: typeof SearchIcon
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<Icon className="size-4" />
<span>{label}</span>
</button>
)
}
function QuickAction({
icon: Icon,
label,
onClick,
}: {
icon: typeof FilePlus
label: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</button>
)
}
function SectionHeader({ label, aside }: { label: string; aside?: string }) {
return (
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
{aside && <span className="text-xs text-muted-foreground">{aside}</span>}
</div>
)
}
function EmptyState({ text }: { text: string }) {
return (
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground">
{text}
</div>
)
}
function FolderAvatar({ name, className }: { name: string; className?: string }) {
return (
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md text-[13px] font-bold',
avatarClass(name),
className,
)}
>
{name.charAt(0).toUpperCase() || '?'}
</div>
)
}
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 = (
<div
role="button"
tabIndex={0}
onClick={() => 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"
>
<FolderAvatar name={node.name} className="mt-0.5" />
<div className="min-w-0 flex-1">
{renameActive ? (
<RenameField
initial={node.name}
isDir
path={node.path}
actions={actions}
onDone={onClearRename}
/>
) : (
<span className="block truncate text-sm font-semibold text-foreground">
{node.name}
</span>
)}
<div className="mt-0.5 text-xs text-muted-foreground">
{count} {count === 1 ? 'note' : 'notes'}
</div>
{peek.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{peek.map((n) => (
<button
key={n.path}
type="button"
onClick={(e) => {
e.stopPropagation()
onOpenNote(n.path)
}}
className="max-w-[200px] truncate rounded-full border border-border/60 bg-muted px-2.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{displayName(n)}
</button>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{modified}
</span>
<ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
</div>
)
return (
<RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}>
{card}
</RowContextMenu>
)
}
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 (
<>
<div className="mb-4 flex min-w-0 items-center gap-1.5 text-sm">
<button
type="button"
onClick={() => {
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
onNavigate(parent)
}}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Back"
>
<ArrowLeft className="size-4" />
</button>
<button
type="button"
onClick={() => onNavigate(null)}
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Notes
</button>
{crumbs.map((c, i) => (
<span key={c.path} className="flex min-w-0 items-center gap-1.5">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
{i === crumbs.length - 1 ? (
<span className="truncate font-medium text-foreground">{c.name}</span>
) : (
<button
type="button"
onClick={() => onNavigate(c.path)}
className="truncate rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{c.name}
</button>
)}
</span>
))}
</div>
<SectionHeader label={`${items.length} ${items.length === 1 ? 'item' : 'items'}`} />
{items.length === 0 ? (
<EmptyState text="This folder is empty." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
{items.map((node, i) => (
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
<ItemRow
node={node}
actions={actions}
renameTarget={renameTarget}
onRequestRename={onRequestRename}
onClearRename={onClearRename}
onOpenFolder={onOpenFolder}
onOpenNote={onOpenNote}
/>
</div>
))}
</div>
)}
</>
)
}
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 = (
<div
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
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 ? (
<FolderAvatar name={node.name} />
) : (
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileText className="size-4" />
</div>
)}
<div className="min-w-0 flex-1">
{renameActive ? (
<RenameField
initial={displayName(node)}
isDir={isDir}
path={node.path}
actions={actions}
onDone={onClearRename}
/>
) : (
<span className="block truncate text-sm text-foreground">{displayName(node)}</span>
)}
{isDir && (
<div className="mt-0.5 text-xs text-muted-foreground">
{count} {count === 1 ? 'note' : 'notes'}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{modified}
</span>
{isDir && (
<ChevronRight className="size-4 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</div>
</div>
)
return (
<RowContextMenu node={node} actions={actions} onRequestRename={onRequestRename}>
{row}
</RowContextMenu>
)
}
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<HTMLInputElement | null>(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 (
<Input
ref={inputRef}
value={value}
onChange={(e) => 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 +754,9 @@ function KnowledgeRow({
toast('Path copied', 'success')
}, [actions, node.path])
const row = (
<button
type="button"
onClick={() => onClick(node)}
className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent"
>
<div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}>
<span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground">
{isDir ? (
<ChevronRight
className={cn(
'size-3.5 transition-transform',
isExpanded && 'rotate-90',
)}
/>
) : null}
</span>
<Icon className="size-4 shrink-0 text-muted-foreground" />
{renameActive ? (
<Input
ref={inputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
void handleRenameSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}}
onBlur={() => {
if (!isSubmittingRef.current) void handleRenameSubmit()
}}
className="h-6 text-sm flex-1"
/>
) : (
<span className="min-w-0 truncate">{baseName}</span>
)}
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatModified(node.stat?.mtimeMs)}
</div>
</button>
)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
{isDir && (
<>

View file

@ -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<HTMLInputElement>(null)
@ -61,7 +65,7 @@ export function CommandPalette({
const [results, setResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
() => 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

View file

@ -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 (
<Sidebar className="rowboat-sidebar border-r-0" {...props}>
@ -750,19 +760,22 @@ export function SidebarContentPanel({
<SidebarMenuButton
isActive={activeNav === 'meetings'}
onClick={onOpenMeetings}
className={previewMeeting ? 'h-auto py-1.5' : undefined}
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
>
<Mic className="size-4 shrink-0" />
<Mic className={cn('size-4 shrink-0', meetingIsRecording && 'text-red-500')} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Meetings</span>
{previewMeeting && (
<span className="truncate text-[11px] text-muted-foreground">
{meetingIsRecording ? previewMeeting.summary : `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}`}
{meetingSublabel && (
<span className={cn(
'truncate text-[11px]',
meetingIsRecording ? 'text-red-500' : 'text-muted-foreground',
)}>
{meetingSublabel}
</span>
)}
</div>
</SidebarMenuButton>
{previewMeeting && (meetingIsRecording ? (
{meetingIsRecording ? (
<div className="absolute inset-y-0 right-1 flex items-center gap-1.5">
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
@ -786,7 +799,7 @@ export function SidebarContentPanel({
</TooltipContent>
</Tooltip>
</div>
) : (
) : previewMeeting ? (
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
@ -819,7 +832,7 @@ export function SidebarContentPanel({
</Tooltip>
)}
</div>
))}
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton

View file

@ -685,6 +685,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<string, { verb: string; one: string; many: string }> = {
'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<string, { phrase: typeof TOOL_ACTION_VERBS[string] | null; count: number; fallback: string }>()
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()

View file

@ -291,6 +291,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(),