mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
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:
commit
fc93c00baf
12 changed files with 1169 additions and 371 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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 }) }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…</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"> · </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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue