mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #725 from rowboatlabs/feat/chat-history-polish
Chat history redesign, session-store search fix, chat header cleanup
This commit is contained in:
commit
5ffe6c26a2
12 changed files with 471 additions and 337 deletions
|
|
@ -1920,7 +1920,10 @@ export function setupIpcHandlers() {
|
||||||
},
|
},
|
||||||
// Search handler
|
// Search handler
|
||||||
'search:query': async (_event, args) => {
|
'search:query': async (_event, args) => {
|
||||||
return search(args.query, args.limit, args.types);
|
await sessionsIndexReady;
|
||||||
|
const sessions = container.resolve<ISessions>('sessions').listSessions()
|
||||||
|
.map((s) => ({ sessionId: s.sessionId, title: s.title }));
|
||||||
|
return search(args.query, args.limit, args.types, sessions);
|
||||||
},
|
},
|
||||||
// Inline task schedule classification
|
// Inline task schedule classification
|
||||||
'export:note': async (event, args) => {
|
'export:note': async (event, args) => {
|
||||||
|
|
|
||||||
|
|
@ -6040,7 +6040,7 @@ function App() {
|
||||||
|
|
||||||
if (isTurnUsageMessage(item)) {
|
if (isTurnUsageMessage(item)) {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="-mt-6 flex items-center justify-start gap-1 px-1" data-message-id={item.id}>
|
<div key={item.id} className="-mt-6 -ml-1 flex items-center justify-start gap-1" data-message-id={item.id}>
|
||||||
<TokenUsageMenu
|
<TokenUsageMenu
|
||||||
usage={item.usage}
|
usage={item.usage}
|
||||||
scope="turn"
|
scope="turn"
|
||||||
|
|
@ -6530,6 +6530,11 @@ function App() {
|
||||||
currentRunId={runId}
|
currentRunId={runId}
|
||||||
processingRunIds={processingRunIds}
|
processingRunIds={processingRunIds}
|
||||||
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
|
onRenameRun={(rid, title) => {
|
||||||
|
void window.ipc.invoke('sessions:setTitle', { sessionId: rid, title })
|
||||||
|
.then(() => setRuns((prev) => prev.map((r) => (r.id === rid ? { ...r, title } : r))))
|
||||||
|
.catch((err) => console.error('Failed to rename chat:', err))
|
||||||
|
}}
|
||||||
onDeleteRun={async (rid) => {
|
onDeleteRun={async (rid) => {
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke('sessions:delete', { sessionId: rid })
|
await window.ipc.invoke('sessions:delete', { sessionId: rid })
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Coffee } from "lucide-react"
|
import { Coffee } from "lucide-react"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,6 +29,10 @@ export function CaffeinateIndicator() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Render nothing while off — an invisible placeholder would leave a
|
||||||
|
// permanent 32px hole between the header controls.
|
||||||
|
if (!enabled) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -40,22 +43,15 @@ export function CaffeinateIndicator() {
|
||||||
toast.error("Failed to turn off Caffeinate")
|
toast.error("Failed to turn off Caffeinate")
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
disabled={!enabled}
|
|
||||||
aria-hidden={!enabled}
|
|
||||||
aria-label="Caffeinate is on — click to turn off"
|
aria-label="Caffeinate is on — click to turn off"
|
||||||
className={cn(
|
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-amber-500 transition-colors self-center shrink-0 hover:bg-accent hover:text-amber-600"
|
||||||
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-amber-500 transition-colors self-center shrink-0",
|
|
||||||
enabled ? "hover:bg-accent hover:text-amber-600" : "invisible pointer-events-none",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Coffee className="size-4" />
|
<Coffee className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{enabled && (
|
<TooltipContent side="bottom">
|
||||||
<TooltipContent side="bottom">
|
Caffeinate is on — your Mac won't sleep. Click to turn off.
|
||||||
Caffeinate is on — your Mac won't sleep. Click to turn off.
|
</TooltipContent>
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react'
|
import { useCallback } from 'react'
|
||||||
|
import { ArrowUpRight, Bug, ChevronDown, MessageSquare, MoreHorizontal, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -51,6 +53,31 @@ export function ChatHeader({
|
||||||
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
|
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
|
||||||
const showUsage = hasTokenUsage(sessionUsage)
|
const showUsage = hasTokenUsage(sessionUsage)
|
||||||
|
|
||||||
|
const handleDownloadChatLog = useCallback(async () => {
|
||||||
|
if (!activeRunId) {
|
||||||
|
toast.error('No chat log available yet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Session-first (new runtime); legacy runs fallback covers old
|
||||||
|
// background tabs until stage 7 removes the runs runtime.
|
||||||
|
let result: { success: boolean; error?: string }
|
||||||
|
try {
|
||||||
|
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
|
||||||
|
} catch {
|
||||||
|
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])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasHistory ? (
|
{hasHistory ? (
|
||||||
|
|
@ -123,6 +150,34 @@ export function ChatHeader({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
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={!activeRunId}
|
||||||
|
onSelect={() => {
|
||||||
|
void handleDownloadChatLog()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bug className="size-4" />
|
||||||
|
Download chat log
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { ExternalLink, MessageSquare, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
|
import { ExternalLink, MoreVertical, Pencil, SearchIcon, SquarePen, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
|
|
@ -9,6 +10,13 @@ import {
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu'
|
} from '@/components/ui/context-menu'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -33,6 +41,7 @@ type ChatHistoryViewProps = {
|
||||||
processingRunIds?: Set<string>
|
processingRunIds?: Set<string>
|
||||||
onSelectRun: (runId: string) => void
|
onSelectRun: (runId: string) => void
|
||||||
onOpenInNewTab?: (runId: string) => void
|
onOpenInNewTab?: (runId: string) => void
|
||||||
|
onRenameRun?: (runId: string, title: string) => void
|
||||||
onDeleteRun: (runId: string) => Promise<void> | void
|
onDeleteRun: (runId: string) => Promise<void> | void
|
||||||
onNewChat?: () => void
|
onNewChat?: () => void
|
||||||
onOpenSearch?: () => void
|
onOpenSearch?: () => void
|
||||||
|
|
@ -44,11 +53,14 @@ export function ChatHistoryView({
|
||||||
processingRunIds,
|
processingRunIds,
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
onOpenInNewTab,
|
onOpenInNewTab,
|
||||||
|
onRenameRun,
|
||||||
onDeleteRun,
|
onDeleteRun,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
}: ChatHistoryViewProps) {
|
}: ChatHistoryViewProps) {
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
|
const [renameDraft, setRenameDraft] = useState('')
|
||||||
|
|
||||||
const sortedRuns = useMemo(() => {
|
const sortedRuns = useMemo(() => {
|
||||||
return [...runs].sort((a, b) => {
|
return [...runs].sort((a, b) => {
|
||||||
|
|
@ -65,92 +77,188 @@ export function ChatHistoryView({
|
||||||
await onDeleteRun(id)
|
await onDeleteRun(id)
|
||||||
}, [pendingDeleteId, onDeleteRun])
|
}, [pendingDeleteId, onDeleteRun])
|
||||||
|
|
||||||
|
const startRename = useCallback((run: Run) => {
|
||||||
|
setRenameDraft(run.title || '')
|
||||||
|
setRenamingId(run.id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commitRename = useCallback((runId: string) => {
|
||||||
|
const title = renameDraft.trim()
|
||||||
|
const current = runs.find((r) => r.id === runId)
|
||||||
|
setRenamingId(null)
|
||||||
|
if (!title || title === (current?.title ?? '')) return
|
||||||
|
onRenameRun?.(runId, title)
|
||||||
|
}, [renameDraft, runs, onRenameRun])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-8 py-6">
|
<div className="mx-auto w-full max-w-[1120px] shrink-0 px-[30px] pt-[34px] pb-5">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Chat history</h1>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-[24px] font-[650] tracking-[-0.02em] text-[#0d0e11] dark:text-[#f4f5f7]">Chat history</h1>
|
||||||
{onOpenSearch && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{onOpenSearch && (
|
||||||
type="button"
|
<button
|
||||||
onClick={onOpenSearch}
|
type="button"
|
||||||
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={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>
|
<SearchIcon className="size-4" />
|
||||||
</button>
|
<span>Search</span>
|
||||||
)}
|
</button>
|
||||||
{onNewChat && (
|
)}
|
||||||
<Button size="sm" onClick={onNewChat}>
|
{onNewChat && (
|
||||||
<SquarePen className="size-4" />
|
<Button size="sm" onClick={onNewChat}>
|
||||||
New chat
|
<SquarePen className="size-4" />
|
||||||
</Button>
|
New chat
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-[14px] text-black/50 dark:text-white/[0.52]">
|
||||||
|
{sortedRuns.length === 0
|
||||||
|
? 'Every conversation you have shows up here.'
|
||||||
|
: `${sortedRuns.length} ${sortedRuns.length === 1 ? 'conversation' : 'conversations'}, newest first.`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="min-w-[480px]">
|
<div className="mx-auto w-full max-w-[1120px] px-[30px] pb-12">
|
||||||
<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">Title</div>
|
|
||||||
<div className="w-32 shrink-0 text-right">Last modified</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedRuns.length === 0 ? (
|
{sortedRuns.length === 0 ? (
|
||||||
<div className="px-6 py-8 text-sm text-muted-foreground">No chats yet.</div>
|
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No chats yet.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sortedRuns.map((run) => {
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||||
const isActive = currentRunId === run.id
|
<div className="flex items-center border-b border-border/60 bg-muted/30 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
const isProcessing = processingRunIds?.has(run.id)
|
<div className="min-w-0 flex-1">Title</div>
|
||||||
return (
|
<div className="w-28 shrink-0 text-right">Last modified</div>
|
||||||
<ContextMenu key={run.id}>
|
<div className="w-7 shrink-0" />
|
||||||
<ContextMenuTrigger asChild>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
{sortedRuns.map((run) => {
|
||||||
onClick={(e) => {
|
const isActive = currentRunId === run.id
|
||||||
if (e.metaKey && onOpenInNewTab) {
|
const isProcessing = processingRunIds?.has(run.id)
|
||||||
onOpenInNewTab(run.id)
|
return (
|
||||||
} else {
|
<ContextMenu key={run.id}>
|
||||||
onSelectRun(run.id)
|
<ContextMenuTrigger asChild>
|
||||||
}
|
<div
|
||||||
}}
|
className={cn(
|
||||||
className={[
|
'group relative border-b border-border/50 transition-colors last:border-b-0 hover:bg-muted/20',
|
||||||
'flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent',
|
isActive && 'bg-muted/30',
|
||||||
isActive ? 'bg-accent/60' : '',
|
)}
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
|
||||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
|
||||||
{formatRelativeTime(run.modifiedAt)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent className="w-48">
|
|
||||||
{onOpenInNewTab && (
|
|
||||||
<>
|
|
||||||
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
|
|
||||||
<ExternalLink className="mr-2 size-4" />
|
|
||||||
Open in new tab
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isProcessing && (
|
|
||||||
<ContextMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setPendingDeleteId(run.id)}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
{renamingId === run.id ? (
|
||||||
Delete
|
<div className="flex items-center px-4 py-1.5">
|
||||||
</ContextMenuItem>
|
<input
|
||||||
)}
|
autoFocus
|
||||||
</ContextMenuContent>
|
value={renameDraft}
|
||||||
</ContextMenu>
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
)
|
onKeyDown={(e) => {
|
||||||
})
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
commitRename(run.id)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
setRenamingId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => commitRename(run.id)}
|
||||||
|
className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.metaKey && onOpenInNewTab) {
|
||||||
|
onOpenInNewTab(run.id)
|
||||||
|
} else {
|
||||||
|
onSelectRun(run.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center px-4 py-2.5 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
||||||
|
{run.title || '(Untitled chat)'}
|
||||||
|
</span>
|
||||||
|
<span className="w-28 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||||
|
{formatRelativeTime(run.modifiedAt)}
|
||||||
|
</span>
|
||||||
|
<span className="w-7 shrink-0" />
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Chat options"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{onOpenInNewTab && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => onOpenInNewTab(run.id)}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Open in new tab
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{onRenameRun && (
|
||||||
|
<DropdownMenuItem onClick={() => startRename(run)}>
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{!isProcessing && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setPendingDeleteId(run.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-48">
|
||||||
|
{onOpenInNewTab && (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem onClick={() => onOpenInNewTab(run.id)}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Open in new tab
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{onRenameRun && (
|
||||||
|
<ContextMenuItem onClick={() => startRename(run)}>
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{!isProcessing && (
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setPendingDeleteId(run.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 size-4" />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Pin } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { ChatHeader } from '@/components/chat-header'
|
import { ChatHeader } from '@/components/chat-header'
|
||||||
import { ChatEmptyState } from '@/components/chat-empty-state'
|
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
|
|
@ -379,33 +372,6 @@ export function ChatSidebar({
|
||||||
if (tabId === activeChatTabId) return activeTabState
|
if (tabId === activeChatTabId) return activeTabState
|
||||||
return chatTabStates[tabId] ?? emptyTabState
|
return chatTabStates[tabId] ?? emptyTabState
|
||||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||||
const activeRunId = activeTabState.runId
|
|
||||||
const handleDownloadChatLog = useCallback(async () => {
|
|
||||||
if (!activeRunId) {
|
|
||||||
toast.error('No chat log available yet')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Session-first (new runtime); legacy runs fallback covers old
|
|
||||||
// background tabs until stage 7 removes the runs runtime.
|
|
||||||
let result: { success: boolean; error?: string }
|
|
||||||
try {
|
|
||||||
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
|
|
||||||
} catch {
|
|
||||||
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 = (
|
const renderConversationItem = (
|
||||||
item: ConversationItem,
|
item: ConversationItem,
|
||||||
tabId: string,
|
tabId: string,
|
||||||
|
|
@ -527,7 +493,7 @@ export function ChatSidebar({
|
||||||
|
|
||||||
if (isTurnUsageMessage(item)) {
|
if (isTurnUsageMessage(item)) {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="-mt-6 flex items-center justify-start gap-1 px-1" data-message-id={item.id}>
|
<div key={item.id} className="-mt-6 -ml-1 flex items-center justify-start gap-1" data-message-id={item.id}>
|
||||||
<TokenUsageMenu
|
<TokenUsageMenu
|
||||||
usage={item.usage}
|
usage={item.usage}
|
||||||
scope="turn"
|
scope="turn"
|
||||||
|
|
@ -640,34 +606,6 @@ export function ChatSidebar({
|
||||||
onOpenChatHistory={onOpenChatHistory}
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
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={!activeRunId}
|
|
||||||
onSelect={() => {
|
|
||||||
void handleDownloadChatLog()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Bug className="size-4" />
|
|
||||||
Download chat log
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{onOpenFullScreen && (
|
{onOpenFullScreen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export function CompactConversation({ items }: { items: ConversationItem[] }) {
|
||||||
}
|
}
|
||||||
if (isTurnUsageMessage(item)) {
|
if (isTurnUsageMessage(item)) {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-center justify-start gap-1 px-1">
|
<div key={item.id} className="-ml-1 flex items-center justify-start gap-1">
|
||||||
<TokenUsageMenu
|
<TokenUsageMenu
|
||||||
usage={item.usage}
|
usage={item.usage}
|
||||||
scope="turn"
|
scope="turn"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
} from '@/components/ui/command'
|
} from '@/components/ui/command'
|
||||||
|
|
@ -134,6 +133,10 @@ export function CommandPalette({
|
||||||
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||||
const chatResults = results.filter(r => r.type === 'chat')
|
const chatResults = results.filter(r => r.type === 'chat')
|
||||||
|
|
||||||
|
const scope: SearchType = activeTypes.has('knowledge') ? 'knowledge' : 'chat'
|
||||||
|
const otherScope: SearchType = scope === 'knowledge' ? 'chat' : 'knowledge'
|
||||||
|
const scopeLabel = scope === 'knowledge' ? 'knowledge' : 'chats'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -145,30 +148,54 @@ export function CommandPalette({
|
||||||
>
|
>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
placeholder="Search..."
|
placeholder={scope === 'knowledge' ? 'Search notes and files…' : 'Search chats…'}
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleType(otherScope)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
<div className="flex items-center px-3 py-2">
|
||||||
<FilterToggle
|
<div className="inline-flex items-center rounded-lg bg-muted/60 p-0.5">
|
||||||
active={activeTypes.has('knowledge')}
|
<FilterToggle
|
||||||
onClick={() => toggleType('knowledge')}
|
active={scope === 'knowledge'}
|
||||||
icon={<FileTextIcon className="size-3" />}
|
onClick={() => toggleType('knowledge')}
|
||||||
label="Knowledge"
|
icon={<FileTextIcon className="size-3" />}
|
||||||
/>
|
label="Knowledge"
|
||||||
<FilterToggle
|
/>
|
||||||
active={activeTypes.has('chat')}
|
<FilterToggle
|
||||||
onClick={() => toggleType('chat')}
|
active={scope === 'chat'}
|
||||||
icon={<MessageSquareIcon className="size-3" />}
|
onClick={() => toggleType('chat')}
|
||||||
label="Chats"
|
icon={<MessageSquareIcon className="size-3" />}
|
||||||
/>
|
label="Chats"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{!query.trim() && (
|
{!query.trim() && (
|
||||||
<CommandEmpty>Type to search...</CommandEmpty>
|
<div className="px-6 py-10 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{scope === 'knowledge' ? 'Search your notes and files' : 'Search your chat history'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{query.trim() && isSearching && results.length === 0 && (
|
||||||
|
<div className="px-6 py-10 text-center text-sm text-muted-foreground">Searching…</div>
|
||||||
)}
|
)}
|
||||||
{query.trim() && !isSearching && results.length === 0 && (
|
{query.trim() && !isSearching && results.length === 0 && (
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<div className="px-6 py-10 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No matches in {scopeLabel}.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleType(otherScope)}
|
||||||
|
className="mt-1.5 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Search {otherScope === 'knowledge' ? 'knowledge' : 'chats'} instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{knowledgeResults.length > 0 && (
|
{knowledgeResults.length > 0 && (
|
||||||
<CommandGroup heading="Knowledge">
|
<CommandGroup heading="Knowledge">
|
||||||
|
|
@ -205,10 +232,24 @@ export function CommandPalette({
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
|
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Kbd>↑↓</Kbd> Navigate</span>
|
||||||
|
<span className="flex items-center gap-1"><Kbd>↵</Kbd> Open</span>
|
||||||
|
<span className="flex items-center gap-1"><Kbd>Tab</Kbd> Switch scope</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1"><Kbd>esc</Kbd> Close</span>
|
||||||
|
</div>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Kbd({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<kbd className="rounded border border-border bg-muted px-1 py-px font-mono text-[10px] text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FilterToggle({
|
function FilterToggle({
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -224,10 +265,10 @@ function FilterToggle({
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-background text-foreground shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,10 @@ export function SidebarContentPanel({
|
||||||
})
|
})
|
||||||
const toggleChatPin = useCallback((chatId: string) => {
|
const toggleChatPin = useCallback((chatId: string) => {
|
||||||
const isPinned = pinnedChatIds.includes(chatId)
|
const isPinned = pinnedChatIds.includes(chatId)
|
||||||
if (!isPinned && pinnedChatIds.length >= MAX_PINNED_CHATS) {
|
// Count only pins that still resolve to a chat — deleted chats leave
|
||||||
|
// stale ids in localStorage and must not eat pin slots.
|
||||||
|
const activePinCount = pinnedChatIds.filter((id) => recentRuns.some((r) => r.id === id)).length
|
||||||
|
if (!isPinned && activePinCount >= MAX_PINNED_CHATS) {
|
||||||
toast(`You can pin up to ${MAX_PINNED_CHATS} chats`, 'error')
|
toast(`You can pin up to ${MAX_PINNED_CHATS} chats`, 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -587,7 +590,7 @@ export function SidebarContentPanel({
|
||||||
window.localStorage.setItem(PINNED_CHATS_STORAGE_KEY, JSON.stringify(next))
|
window.localStorage.setItem(PINNED_CHATS_STORAGE_KEY, JSON.stringify(next))
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
setPinnedChatIds(next)
|
setPinnedChatIds(next)
|
||||||
}, [pinnedChatIds])
|
}, [pinnedChatIds, recentRuns])
|
||||||
|
|
||||||
// Chats: pinned first, then the most recently modified, 10 rows total.
|
// Chats: pinned first, then the most recently modified, 10 rows total.
|
||||||
const recentChats = React.useMemo(() => {
|
const recentChats = React.useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { TokenUsage } from '@/lib/chat-conversation'
|
import type { TokenUsage } from '@/lib/chat-conversation'
|
||||||
import { formatTokenCount, totalTokensOf } from '@/lib/token-usage'
|
import { formatTokenCount, totalTokensOf } from '@/lib/token-usage'
|
||||||
|
|
@ -40,26 +41,47 @@ export function TokenUsageMenu({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
{scope === 'session' ? (
|
||||||
<DropdownMenuTrigger asChild>
|
// Header placement: a ghost icon button matching its siblings — hover
|
||||||
<button
|
// explains it, click opens the stats dialog directly.
|
||||||
type="button"
|
<Tooltip>
|
||||||
className={cn(
|
<TooltipTrigger asChild>
|
||||||
'inline-flex size-6 items-center justify-center rounded-md border border-border/60 bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
<button
|
||||||
className,
|
type="button"
|
||||||
)}
|
onClick={() => setDialogOpen(true)}
|
||||||
aria-label={`${title} options`}
|
className={cn(
|
||||||
>
|
'inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||||
<MoreHorizontal className="size-3.5" strokeWidth={1.8} />
|
className,
|
||||||
</button>
|
)}
|
||||||
</DropdownMenuTrigger>
|
aria-label="View token usage"
|
||||||
<DropdownMenuContent align={align} className="w-44">
|
>
|
||||||
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
|
<BarChart3 className="size-4" strokeWidth={1.8} />
|
||||||
<BarChart3 className="size-4" />
|
</button>
|
||||||
View token usage
|
</TooltipTrigger>
|
||||||
</DropdownMenuItem>
|
<TooltipContent side="bottom">View token usage</TooltipContent>
|
||||||
</DropdownMenuContent>
|
</Tooltip>
|
||||||
</DropdownMenu>
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`${title} options`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5" strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="w-44">
|
||||||
|
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
|
||||||
|
<BarChart3 className="size-4" />
|
||||||
|
View token usage
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
|
|
@ -366,36 +366,36 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
<div className="flex h-full flex-col overflow-hidden bg-[#f8f8f9] dark:bg-[#0b0b0d]">
|
||||||
<div className="mx-auto flex w-full max-w-[1120px] shrink-0 items-center justify-between gap-3 pl-[22px] pr-[30px] pt-[30px] pb-4">
|
<div className="mx-auto flex w-full max-w-[1120px] shrink-0 items-center justify-between gap-3 pl-[22px] pr-[30px] pt-[30px] pb-4">
|
||||||
<div className="flex min-w-0 items-center gap-1 text-sm">
|
<div className="flex min-w-0 items-end gap-1 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNavigate(WORKSPACE_ROOT)}
|
onClick={() => onNavigate(WORKSPACE_ROOT)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center rounded-md px-2 py-1 transition-colors',
|
'inline-flex rounded-md px-2 py-1 transition-colors',
|
||||||
isRoot ? 'text-[#0d0e11] dark:text-[#f4f5f7]' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
isRoot ? 'text-[#0d0e11] dark:text-[#f4f5f7]' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-[24px] font-[650] tracking-[-0.02em]">Workspace</span>
|
<span className="text-[24px] leading-none font-[650] tracking-[-0.02em]">Workspace</span>
|
||||||
</button>
|
</button>
|
||||||
{breadcrumbs.map((crumb, idx) => {
|
{breadcrumbs.map((crumb, idx) => {
|
||||||
const isLast = idx === breadcrumbs.length - 1
|
const isLast = idx === breadcrumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<span key={crumb.path} className="flex items-center gap-1">
|
<Fragment key={crumb.path}>
|
||||||
<ChevronRight className="size-4 text-muted-foreground/60" />
|
<ChevronRight className="mb-[5px] size-4 shrink-0 text-muted-foreground/60" />
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className="rounded-md px-2 py-1 font-medium text-foreground truncate">
|
<span className="mb-[2px] rounded-md px-2 py-1 leading-none font-medium text-foreground truncate">
|
||||||
{crumb.name}
|
{crumb.name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNavigate(crumb.path)}
|
onClick={() => onNavigate(crumb.path)}
|
||||||
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
|
className="mb-[2px] rounded-md px-2 py-1 leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
|
||||||
>
|
>
|
||||||
{crumb.name}
|
{crumb.name}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,31 @@ interface SearchResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
const RUNS_DIR = path.join(WorkDir, 'runs');
|
// Chats live in the turn-runtime session store: session event logs carry the
|
||||||
|
// titles, turn logs carry the message content. (The legacy `runs/` dir holds
|
||||||
|
// pre-migration chats the app can no longer open, so search skips it.)
|
||||||
|
const TURNS_DIR = path.join(WorkDir, 'storage', 'turns');
|
||||||
|
|
||||||
type SearchType = 'knowledge' | 'chat';
|
type SearchType = 'knowledge' | 'chat';
|
||||||
|
|
||||||
|
/** Minimal session metadata the caller passes in (from the sessions index). */
|
||||||
|
export type ChatSessionMeta = {
|
||||||
|
sessionId: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search across knowledge files and chat history.
|
* Search across knowledge files and chat history.
|
||||||
* @param types - optional filter to search only specific types (default: both)
|
* @param types - optional filter to search only specific types (default: both)
|
||||||
|
* @param chatSessions - session index entries used for chat title search and
|
||||||
|
* for mapping content matches back to a titled, openable session.
|
||||||
*/
|
*/
|
||||||
export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
|
export async function search(
|
||||||
|
query: string,
|
||||||
|
limit = 20,
|
||||||
|
types?: SearchType[],
|
||||||
|
chatSessions: ChatSessionMeta[] = [],
|
||||||
|
): Promise<{ results: SearchResult[] }> {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return { results: [] };
|
return { results: [] };
|
||||||
|
|
@ -32,7 +48,7 @@ export async function search(query: string, limit = 20, types?: SearchType[]): P
|
||||||
|
|
||||||
const [knowledgeResults, chatResults] = await Promise.all([
|
const [knowledgeResults, chatResults] = await Promise.all([
|
||||||
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
|
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
|
||||||
searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
|
searchChatsEnabled ? searchChats(trimmed, limit, chatSessions) : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
|
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
|
||||||
|
|
@ -100,86 +116,51 @@ async function searchKnowledge(query: string, limit: number): Promise<SearchResu
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search chat history by title and message content.
|
* Search chat history: titles come from the sessions index the caller passes
|
||||||
|
* in; message content is grepped from the turn logs, with each matching turn
|
||||||
|
* file mapped back to its session via the turn_created event's sessionId.
|
||||||
*/
|
*/
|
||||||
async function searchChats(query: string, limit: number): Promise<SearchResult[]> {
|
async function searchChats(query: string, limit: number, sessions: ChatSessionMeta[]): Promise<SearchResult[]> {
|
||||||
if (!fs.existsSync(RUNS_DIR)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: SearchResult[] = [];
|
const results: SearchResult[] = [];
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const titleBySession = new Map(sessions.map((s) => [s.sessionId, s.title]));
|
||||||
|
|
||||||
// Content search via grep on JSONL files
|
// Title search — the index is already newest-first.
|
||||||
try {
|
for (const session of sessions) {
|
||||||
const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
|
if (results.length >= limit) break;
|
||||||
for (const match of grepMatches) {
|
if (!session.title || !session.title.toLowerCase().includes(lowerQuery)) continue;
|
||||||
if (results.length >= limit) break;
|
seenIds.add(session.sessionId);
|
||||||
const runId = path.basename(match.file, '.jsonl');
|
results.push({
|
||||||
if (seenIds.has(runId)) continue;
|
type: 'chat',
|
||||||
|
title: session.title,
|
||||||
const meta = await readRunMetadata(match.file);
|
preview: session.title,
|
||||||
if (meta.agentName !== 'copilot') {
|
path: session.sessionId,
|
||||||
seenIds.add(runId);
|
});
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seenIds.add(runId);
|
|
||||||
|
|
||||||
// Extract a content preview from the matching line
|
|
||||||
let preview = '';
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(match.line);
|
|
||||||
if (parsed.message?.content && typeof parsed.message.content === 'string') {
|
|
||||||
preview = parsed.message.content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
preview = match.line.substring(0, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
type: 'chat',
|
|
||||||
title: meta.title || runId,
|
|
||||||
preview,
|
|
||||||
path: runId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// grep failed — continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title search — scan run files for matching titles
|
// Content search via grep on the turn logs.
|
||||||
try {
|
if (fs.existsSync(TURNS_DIR)) {
|
||||||
const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
|
try {
|
||||||
const jsonlFiles = entries
|
const grepMatches = await grepFiles(query, TURNS_DIR, '*.jsonl');
|
||||||
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
|
for (const match of grepMatches) {
|
||||||
.map(e => e.name)
|
if (results.length >= limit) break;
|
||||||
.sort()
|
const sessionId = await readTurnSessionId(match.file);
|
||||||
.reverse(); // newest first
|
// Sessionless turns (background tasks etc.) aren't openable chats.
|
||||||
|
if (!sessionId || seenIds.has(sessionId)) continue;
|
||||||
for (const name of jsonlFiles) {
|
// Only surface sessions the app can actually open.
|
||||||
if (results.length >= limit) break;
|
if (!titleBySession.has(sessionId)) continue;
|
||||||
const runId = path.basename(name, '.jsonl');
|
seenIds.add(sessionId);
|
||||||
if (seenIds.has(runId)) continue;
|
|
||||||
|
|
||||||
const filePath = path.join(RUNS_DIR, name);
|
|
||||||
const meta = await readRunMetadata(filePath);
|
|
||||||
if (meta.agentName !== 'copilot') {
|
|
||||||
seenIds.add(runId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
|
|
||||||
seenIds.add(runId);
|
|
||||||
results.push({
|
results.push({
|
||||||
type: 'chat',
|
type: 'chat',
|
||||||
title: meta.title,
|
title: titleBySession.get(sessionId) || sessionId,
|
||||||
preview: meta.title,
|
preview: extractPreview(match.line, lowerQuery),
|
||||||
path: runId,
|
path: sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// grep failed — continue with title matches only
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -245,18 +226,14 @@ function getFirstMatchingLine(filePath: string, query: string): Promise<string>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunMetadata {
|
|
||||||
title: string | undefined;
|
|
||||||
agentName: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read metadata from a run JSONL file (agent name from start event, title from first user message).
|
* Read the sessionId from a turn log's first line (the turn_created event).
|
||||||
|
* Returns null for sessionless turns (background tasks, live notes, etc.).
|
||||||
*/
|
*/
|
||||||
function readRunMetadata(filePath: string): Promise<RunMetadata> {
|
function readTurnSessionId(filePath: string): Promise<string | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const done = (value: RunMetadata) => {
|
const done = (value: string | null) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
resolve(value);
|
resolve(value);
|
||||||
|
|
@ -264,64 +241,50 @@ function readRunMetadata(filePath: string): Promise<RunMetadata> {
|
||||||
|
|
||||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
let lineIndex = 0;
|
|
||||||
let agentName: string | undefined;
|
|
||||||
|
|
||||||
rl.on('line', (line) => {
|
rl.on('line', (line) => {
|
||||||
if (resolved) return;
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (lineIndex === 0) {
|
const parsed = JSON.parse(line);
|
||||||
// Start event — extract agentName
|
done(typeof parsed.sessionId === 'string' && parsed.sessionId ? parsed.sessionId : null);
|
||||||
const start = JSON.parse(trimmed);
|
|
||||||
agentName = start.agentName;
|
|
||||||
lineIndex++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = JSON.parse(trimmed);
|
|
||||||
if (event.type === 'message') {
|
|
||||||
const msg = event.message;
|
|
||||||
if (msg?.role === 'user') {
|
|
||||||
const content = msg.content;
|
|
||||||
if (typeof content === 'string' && content.trim()) {
|
|
||||||
let cleaned = content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '');
|
|
||||||
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
||||||
if (cleaned) {
|
|
||||||
done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
|
|
||||||
rl.close();
|
|
||||||
stream.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done({ title: undefined, agentName });
|
|
||||||
rl.close();
|
|
||||||
stream.destroy();
|
|
||||||
return;
|
|
||||||
} else if (msg?.role === 'assistant') {
|
|
||||||
done({ title: undefined, agentName });
|
|
||||||
rl.close();
|
|
||||||
stream.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lineIndex++;
|
|
||||||
} catch {
|
} catch {
|
||||||
lineIndex++;
|
done(null);
|
||||||
}
|
}
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
rl.on('close', () => done({ title: undefined, agentName }));
|
rl.on('close', () => done(null));
|
||||||
rl.on('error', () => done({ title: undefined, agentName: undefined }));
|
stream.on('error', () => done(null));
|
||||||
stream.on('error', () => {
|
|
||||||
rl.close();
|
|
||||||
done({ title: undefined, agentName: undefined });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a human-readable preview out of a matched JSONL line: the first string
|
||||||
|
* value anywhere in the event that contains the query. Falls back to the raw
|
||||||
|
* line so a match is never silently dropped.
|
||||||
|
*/
|
||||||
|
function extractPreview(line: string, lowerQuery: string): string {
|
||||||
|
const clean = (s: string) =>
|
||||||
|
s.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').replace(/\s+/g, ' ').trim().substring(0, 150);
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(line);
|
||||||
|
let found = '';
|
||||||
|
const visit = (value: unknown): void => {
|
||||||
|
if (found) return;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value.toLowerCase().includes(lowerQuery)) found = value;
|
||||||
|
} else if (value && typeof value === 'object') {
|
||||||
|
for (const v of Object.values(value)) visit(v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(parsed);
|
||||||
|
if (found) return clean(found);
|
||||||
|
} catch {
|
||||||
|
// fall through to the raw line
|
||||||
|
}
|
||||||
|
return clean(line);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively list all .md files in a directory.
|
* Recursively list all .md files in a directory.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue