diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 59cb1392..fc8253a0 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1920,7 +1920,10 @@ export function setupIpcHandlers() { }, // Search handler 'search:query': async (_event, args) => { - return search(args.query, args.limit, args.types); + await sessionsIndexReady; + const sessions = container.resolve('sessions').listSessions() + .map((s) => ({ sessionId: s.sessionId, title: s.title })); + return search(args.query, args.limit, args.types, sessions); }, // Inline task schedule classification 'export:note': async (event, args) => { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0ffe61bd..5edb27f5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6040,7 +6040,7 @@ function App() { if (isTurnUsageMessage(item)) { return ( -
+
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) => { try { await window.ipc.invoke('sessions:delete', { sessionId: rid }) diff --git a/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx b/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx index a7e79fdf..bc68b29f 100644 --- a/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx +++ b/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from "react" import { Coffee } from "lucide-react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { cn } from "@/lib/utils" 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 ( @@ -40,22 +43,15 @@ export function CaffeinateIndicator() { toast.error("Failed to turn off Caffeinate") }) }} - disabled={!enabled} - aria-hidden={!enabled} aria-label="Caffeinate is on — click to turn off" - className={cn( - "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", - )} + 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" > - {enabled && ( - - Caffeinate is on — your Mac won't sleep. Click to turn off. - - )} + + Caffeinate is on — your Mac won't sleep. Click to turn off. + ) } diff --git a/apps/x/apps/renderer/src/components/chat-header.tsx b/apps/x/apps/renderer/src/components/chat-header.tsx index 186ca5ef..d100d339 100644 --- a/apps/x/apps/renderer/src/components/chat-header.tsx +++ b/apps/x/apps/renderer/src/components/chat-header.tsx @@ -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 { cn } from '@/lib/utils' @@ -51,6 +53,31 @@ export function ChatHeader({ const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory) 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 ( <> {hasHistory ? ( @@ -123,6 +150,34 @@ export function ChatHeader({ New chat + + + + + + + + Chat options + + + { + void handleDownloadChatLog() + }} + > + + Download chat log + + + ) } diff --git a/apps/x/apps/renderer/src/components/chat-history-view.tsx b/apps/x/apps/renderer/src/components/chat-history-view.tsx index 831a8aa1..b9a299f7 100644 --- a/apps/x/apps/renderer/src/components/chat-history-view.tsx +++ b/apps/x/apps/renderer/src/components/chat-history-view.tsx @@ -1,7 +1,8 @@ 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 { cn } from '@/lib/utils' import { ContextMenu, ContextMenuContent, @@ -9,6 +10,13 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Dialog, DialogContent, @@ -33,6 +41,7 @@ type ChatHistoryViewProps = { processingRunIds?: Set onSelectRun: (runId: string) => void onOpenInNewTab?: (runId: string) => void + onRenameRun?: (runId: string, title: string) => void onDeleteRun: (runId: string) => Promise | void onNewChat?: () => void onOpenSearch?: () => void @@ -44,11 +53,14 @@ export function ChatHistoryView({ processingRunIds, onSelectRun, onOpenInNewTab, + onRenameRun, onDeleteRun, onNewChat, onOpenSearch, }: ChatHistoryViewProps) { const [pendingDeleteId, setPendingDeleteId] = useState(null) + const [renamingId, setRenamingId] = useState(null) + const [renameDraft, setRenameDraft] = useState('') const sortedRuns = useMemo(() => { return [...runs].sort((a, b) => { @@ -65,92 +77,188 @@ export function ChatHistoryView({ await onDeleteRun(id) }, [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 ( -
-
-

Chat history

-
- {onOpenSearch && ( - - )} - {onNewChat && ( - - )} +
+
+
+

Chat history

+
+ {onOpenSearch && ( + + )} + {onNewChat && ( + + )} +
+

+ {sortedRuns.length === 0 + ? 'Every conversation you have shows up here.' + : `${sortedRuns.length} ${sortedRuns.length === 1 ? 'conversation' : 'conversations'}, newest first.`} +

-
-
-
Title
-
Last modified
-
- +
{sortedRuns.length === 0 ? ( -
No chats yet.
+
+ No chats yet. +
) : ( - sortedRuns.map((run) => { - const isActive = currentRunId === run.id - const isProcessing = processingRunIds?.has(run.id) - return ( - - - - - - {onOpenInNewTab && ( - <> - onOpenInNewTab(run.id)}> - - Open in new tab - - - - )} - {!isProcessing && ( - setPendingDeleteId(run.id)} +
+
+
Title
+
Last modified
+
+
+ + {sortedRuns.map((run) => { + const isActive = currentRunId === run.id + const isProcessing = processingRunIds?.has(run.id) + return ( + + +
- - Delete - - )} - - - ) - }) + {renamingId === run.id ? ( +
+ 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" + /> +
+ ) : ( + <> + + + + + + + {onOpenInNewTab && ( + <> + onOpenInNewTab(run.id)}> + + Open in new tab + + + + )} + {onRenameRun && ( + startRename(run)}> + + Rename + + )} + {!isProcessing && ( + setPendingDeleteId(run.id)} + > + + Delete + + )} + + + + )} +
+
+ + {onOpenInNewTab && ( + <> + onOpenInNewTab(run.id)}> + + Open in new tab + + + + )} + {onRenameRun && ( + startRename(run)}> + + Rename + + )} + {!isProcessing && ( + setPendingDeleteId(run.id)} + > + + Delete + + )} + +
+ ) + })} +
)}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 2f013d96..ef46db54 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,18 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react' -import { toast } from 'sonner' +import { ArrowLeft, ArrowRight, Pin } from 'lucide-react' 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, @@ -379,33 +372,6 @@ 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 { - // 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 = ( item: ConversationItem, tabId: string, @@ -527,7 +493,7 @@ export function ChatSidebar({ if (isTurnUsageMessage(item)) { return ( -
+
)} - - - - - - - - Chat options - - - { - void handleDownloadChatLog() - }} - > - - Download chat log - - - {onOpenFullScreen && ( diff --git a/apps/x/apps/renderer/src/components/compact-conversation.tsx b/apps/x/apps/renderer/src/components/compact-conversation.tsx index 75c7a2b5..3224b14a 100644 --- a/apps/x/apps/renderer/src/components/compact-conversation.tsx +++ b/apps/x/apps/renderer/src/components/compact-conversation.tsx @@ -39,7 +39,7 @@ export function CompactConversation({ items }: { items: ConversationItem[] }) { } if (isTurnUsageMessage(item)) { return ( -
+
r.type === 'knowledge') 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 ( { + if (e.key === 'Tab') { + e.preventDefault() + toggleType(otherScope) + } + }} /> -
- toggleType('knowledge')} - icon={} - label="Knowledge" - /> - toggleType('chat')} - icon={} - label="Chats" - /> +
+
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
{!query.trim() && ( - Type to search... +
+

+ {scope === 'knowledge' ? 'Search your notes and files' : 'Search your chat history'} +

+
+ )} + {query.trim() && isSearching && results.length === 0 && ( +
Searching…
)} {query.trim() && !isSearching && results.length === 0 && ( - No results found. +
+

No matches in {scopeLabel}.

+ +
)} {knowledgeResults.length > 0 && ( @@ -205,10 +232,24 @@ export function CommandPalette({ )}
+
+ ↑↓ Navigate + Open + Tab Switch scope + esc Close +
) } +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + function FilterToggle({ active, onClick, @@ -224,10 +265,10 @@ function FilterToggle({ - - - setDialogOpen(true)}> - - View token usage - - - + {scope === 'session' ? ( + // Header placement: a ghost icon button matching its siblings — hover + // explains it, click opens the stats dialog directly. + + + + + View token usage + + ) : ( + + + + + + setDialogOpen(true)}> + + View token usage + + + + )} diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 9d475e13..71b8803c 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, Copy, @@ -366,36 +366,36 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo return (
-
+
{breadcrumbs.map((crumb, idx) => { const isLast = idx === breadcrumbs.length - 1 return ( - - + + {isLast ? ( - + {crumb.name} ) : ( )} - + ) })}
diff --git a/apps/x/packages/core/src/search/search.ts b/apps/x/packages/core/src/search/search.ts index d68449f5..6d52496e 100644 --- a/apps/x/packages/core/src/search/search.ts +++ b/apps/x/packages/core/src/search/search.ts @@ -13,15 +13,31 @@ interface SearchResult { } 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'; +/** 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. * @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(); if (!trimmed) { return { results: [] }; @@ -32,7 +48,7 @@ export async function search(query: string, limit = 20, types?: SearchType[]): P const [knowledgeResults, chatResults] = await Promise.all([ 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); @@ -100,86 +116,51 @@ async function searchKnowledge(query: string, limit: number): Promise { - if (!fs.existsSync(RUNS_DIR)) { - return []; - } - +async function searchChats(query: string, limit: number, sessions: ChatSessionMeta[]): Promise { const results: SearchResult[] = []; const seenIds = new Set(); const lowerQuery = query.toLowerCase(); + const titleBySession = new Map(sessions.map((s) => [s.sessionId, s.title])); - // Content search via grep on JSONL files - try { - const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl'); - for (const match of grepMatches) { - if (results.length >= limit) break; - const runId = path.basename(match.file, '.jsonl'); - if (seenIds.has(runId)) continue; - - const meta = await readRunMetadata(match.file); - if (meta.agentName !== 'copilot') { - 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(/[\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 — the index is already newest-first. + for (const session of sessions) { + if (results.length >= limit) break; + if (!session.title || !session.title.toLowerCase().includes(lowerQuery)) continue; + seenIds.add(session.sessionId); + results.push({ + type: 'chat', + title: session.title, + preview: session.title, + path: session.sessionId, + }); } - // Title search — scan run files for matching titles - try { - const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true }); - const jsonlFiles = entries - .filter(e => e.isFile() && e.name.endsWith('.jsonl')) - .map(e => e.name) - .sort() - .reverse(); // newest first - - for (const name of jsonlFiles) { - if (results.length >= limit) break; - const runId = path.basename(name, '.jsonl'); - 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); + // Content search via grep on the turn logs. + if (fs.existsSync(TURNS_DIR)) { + try { + const grepMatches = await grepFiles(query, TURNS_DIR, '*.jsonl'); + for (const match of grepMatches) { + if (results.length >= limit) break; + const sessionId = await readTurnSessionId(match.file); + // Sessionless turns (background tasks etc.) aren't openable chats. + if (!sessionId || seenIds.has(sessionId)) continue; + // Only surface sessions the app can actually open. + if (!titleBySession.has(sessionId)) continue; + seenIds.add(sessionId); results.push({ type: 'chat', - title: meta.title, - preview: meta.title, - path: runId, + title: titleBySession.get(sessionId) || sessionId, + preview: extractPreview(match.line, lowerQuery), + path: sessionId, }); } + } catch { + // grep failed — continue with title matches only } - } catch { - // ignore errors } return results; @@ -245,18 +226,14 @@ function getFirstMatchingLine(filePath: string, query: string): Promise }); } -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 { +function readTurnSessionId(filePath: string): Promise { return new Promise((resolve) => { let resolved = false; - const done = (value: RunMetadata) => { + const done = (value: string | null) => { if (resolved) return; resolved = true; resolve(value); @@ -264,64 +241,50 @@ function readRunMetadata(filePath: string): Promise { const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - let lineIndex = 0; - let agentName: string | undefined; rl.on('line', (line) => { - if (resolved) return; - const trimmed = line.trim(); - if (!trimmed) return; - try { - if (lineIndex === 0) { - // Start event — extract agentName - 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(/[\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++; + const parsed = JSON.parse(line); + done(typeof parsed.sessionId === 'string' && parsed.sessionId ? parsed.sessionId : null); } catch { - lineIndex++; + done(null); } + rl.close(); + stream.destroy(); }); - rl.on('close', () => done({ title: undefined, agentName })); - rl.on('error', () => done({ title: undefined, agentName: undefined })); - stream.on('error', () => { - rl.close(); - done({ title: undefined, agentName: undefined }); - }); + rl.on('close', () => done(null)); + stream.on('error', () => done(null)); }); } +/** + * 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(/[\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. */