From 3fe8f58675a113e79d6f4d16a33c5da3c50ef735 Mon Sep 17 00:00:00 2001 From: Sun KeyContacts Date: Fri, 22 May 2026 11:16:12 +0800 Subject: [PATCH 1/3] feat: sidebar project search, right-click open in finder, detail page polish - Add search box to sidebar project filter section - Add right-click context menu on projects to open in Finder - Add message order toggle (newest/oldest first) in session detail - Add scroll-to-bottom button for long conversations - Change active status to dynamic (24h recently updated) - Add keyboard navigation: arrow right to enter detail, left to exit - Make session list search bar area draggable - Align detail header layout (back button, tool icon, title, metadata) Co-Authored-By: Claude Opus 4.7 --- src/main/database.ts | 8 +-- src/main/ipc-handlers.ts | 5 ++ src/renderer/components/SessionDetail.tsx | 63 ++++++++++++++++------ src/renderer/components/SessionList.tsx | 21 +++++--- src/renderer/components/Sidebar.tsx | 66 +++++++++++++++++++++-- src/renderer/lib/i18n.ts | 4 ++ src/renderer/stores/useStore.ts | 1 + 7 files changed, 138 insertions(+), 30 deletions(-) diff --git a/src/main/database.ts b/src/main/database.ts index 3720e9c..3f76d82 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -189,7 +189,8 @@ export class DatabaseManager { params.push(filter.projectName) } if (filter.status === 'active') { - sql += ' AND is_active = 1 AND archived = 0' + sql += ' AND updated_at > ? AND archived = 0' + params.push(Math.floor(Date.now() / 1000) - 86400) } else if (filter.status === 'starred') { sql += ' AND starred = 1 AND archived = 0' } else if (filter.status === 'pinned') { @@ -264,14 +265,15 @@ export class DatabaseManager { } getStatusCounts(): { active: number; starred: number; pinned: number; archived: number } { + const cutoff = Math.floor(Date.now() / 1000) - 86400 const r = this.db.prepare(` SELECT - SUM(CASE WHEN is_active = 1 AND archived = 0 THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN updated_at > ? AND archived = 0 THEN 1 ELSE 0 END) as active, SUM(CASE WHEN starred = 1 AND archived = 0 THEN 1 ELSE 0 END) as starred, SUM(CASE WHEN pinned = 1 AND archived = 0 THEN 1 ELSE 0 END) as pinned, SUM(CASE WHEN archived = 1 THEN 1 ELSE 0 END) as archived FROM unified_session - `).get() as { active: number; starred: number; pinned: number; archived: number } + `).get(cutoff) as { active: number; starred: number; pinned: number; archived: number } return { active: r.active || 0, starred: r.starred || 0, pinned: r.pinned || 0, archived: r.archived || 0 } } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index c210ed2..3c8bb12 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -33,6 +33,11 @@ export function registerIpcHandlers( return { ...db.getCounts(), ...db.getStatusCounts() } }) + ipcMain.handle('open-in-finder', async (_e, projectName: string) => { + const path = db.getProjectPath(projectName) + if (path) shell.showItemInFolder(path) + }) + ipcMain.handle('list-project-files', async (_e, projectName: string) => { const path = db.getProjectPath(projectName) if (!path) return [] diff --git a/src/renderer/components/SessionDetail.tsx b/src/renderer/components/SessionDetail.tsx index 2ee542e..e36957c 100644 --- a/src/renderer/components/SessionDetail.tsx +++ b/src/renderer/components/SessionDetail.tsx @@ -1,5 +1,5 @@ -import { memo, useCallback, useMemo, useState } from 'react' -import { ArrowLeft, Star, Pin, Archive, Trash2, Play, ChevronDown, ChevronUp } from 'lucide-react' +import { memo, useCallback, useMemo, useState, useRef, useEffect } from 'react' +import { ArrowLeft, Star, Pin, Archive, Trash2, Play, ChevronDown, ChevronUp, ArrowUpDown, ArrowDownToLine } from 'lucide-react' import { Virtuoso } from 'react-virtuoso' import { useStore, ChatMessage } from '../stores/useStore' import { ToolIcon } from './Sidebar' @@ -117,6 +117,23 @@ export function SessionDetail() { const terminalApp = useStore(s => s.terminalApp) const lang = useStore(s => s.language) + const [reverseOrder, setReverseOrder] = useState(false) + const [showScrollBottom, setShowScrollBottom] = useState(false) + const virtuosoRef = useRef(null) + + const displayMessages = useMemo(() => { + if (!reverseOrder) return detailMessages + return [...detailMessages].reverse() + }, [detailMessages, reverseOrder]) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.key === 'ArrowLeft') closeDetail() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [closeDetail]) + const textCount = useMemo(() => detailMessages.filter(m => m.type === 'text').length, [detailMessages]) const toolCount = useMemo(() => detailMessages.filter(m => m.type === 'tool').length, [detailMessages]) @@ -149,19 +166,17 @@ export function SessionDetail() { return (
{/* Header */} -
-
+
+ {/* Top row: back + title + actions */} +
-
- -
+
@@ -169,7 +184,7 @@ export function SessionDetail() {
-
+
- {/* Metadata pills */} -
+ {/* Metadata pills — aligned with title */} +
{s.projectName && ( {s.projectName} )} @@ -242,26 +257,44 @@ export function SessionDetail() { {s.cost > 0 && ( {formatCost(s.cost)} )} +
{/* Messages */} -
+
{detailLoading ? (
{translate('detail.loading', lang as any)}
) : detailMessages.length === 0 ? (
{translate('detail.noMessages', lang as any)}
) : ( } components={{ Header: () =>
, Footer: () =>
, }} + followOutput="smooth" + atBottomStateChange={atBottom => setShowScrollBottom(!atBottom)} /> )} + {showScrollBottom && !detailLoading && detailMessages.length > 0 && ( + + )}
) diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 2fe1c83..f2d6431 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -2,7 +2,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' import { GroupedVirtuoso } from 'react-virtuoso' -import { Pin, Star, Archive, Trash2, Play, Search, Flame } from 'lucide-react' +import { Pin, Star, Archive, Trash2, Play, Search, Flame, Eye } from 'lucide-react' import { useStore } from '../stores/useStore' import { ToolIcon } from './Sidebar' import { cn } from '../lib/utils' @@ -57,13 +57,14 @@ function formatCost(n: number): string { type CtxMenuState = { x: number; y: number; sessionId: string; session: any } | null -const ContextMenu = memo(function ContextMenu({ ctx, onClose, togglePin, toggleStar, toggleArchive, deleteSession }: { +const ContextMenu = memo(function ContextMenu({ ctx, onClose, togglePin, toggleStar, toggleArchive, deleteSession, openDetail }: { ctx: CtxMenuState onClose: () => void togglePin: (id: string) => void toggleStar: (id: string) => void toggleArchive: (id: string) => void deleteSession: (id: string) => void + openDetail: (s: any) => void }) { const lang = useStore(s => s.language) const ref = useRef(null) @@ -115,6 +116,7 @@ const ContextMenu = memo(function ContextMenu({ ctx, onClose, togglePin, toggleS const s = ctx.session const items = [ + { icon: Eye, label: translate('ctx.viewDetail', lang), action: () => { onClose(); openDetail(s) } }, { icon: Pin, label: s.pinned ? translate('ctx.unpin', lang) : translate('ctx.pin', lang), action: () => togglePin(ctx.sessionId) }, { icon: Star, label: s.starred ? translate('ctx.unstar', lang) : translate('ctx.star', lang), action: () => toggleStar(ctx.sessionId) }, { icon: Archive, label: translate('ctx.archive', lang), action: () => { onClose(); useStore.getState().confirm(translate('confirm.archive', lang)).then(ok => { if (ok) toggleArchive(ctx.sessionId) }) } }, @@ -237,7 +239,7 @@ export function SessionList() { } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex(i => Math.max(i - 1, 0)) - } else if (e.key === 'Enter' && itemData[selectedIndex]) { + } else if ((e.key === 'Enter' || e.key === 'ArrowRight') && itemData[selectedIndex]) { e.preventDefault() openDetail(itemData[selectedIndex].session) } @@ -248,9 +250,12 @@ export function SessionList() { return (
- {/* Search + sort bar */} -
-
+ {/* Search + sort bar — top portion is draggable */} +
+
-
+
{SORT_KEYS.map(key => (
- +
) } diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index faa7f57..65a0587 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,5 +1,7 @@ -import { memo, useState, useRef, useCallback, useMemo } from 'react' -import { Sun, Moon, RefreshCw, FolderOpen, Folder, FileText, Star, Pin, Archive, CircleDot, Layers, Settings, ChevronRight } from 'lucide-react' +import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { Sun, Moon, RefreshCw, FolderOpen, Folder, FileText, Star, Pin, Archive, CircleDot, Layers, Settings, ChevronRight, Search, FolderSearch } from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' import { useStore } from '../stores/useStore' import { cn } from '../lib/utils' import { translate } from '../lib/i18n' @@ -76,6 +78,7 @@ export function Sidebar() { const [collapsedTool, setCollapsedTool] = useState(false) const [collapsedProject, setCollapsedProject] = useState(false) const [collapsedStatus, setCollapsedStatus] = useState(false) + const [projectSearch, setProjectSearch] = useState('') const [statusHeight, setStatusHeight] = useState(220) const resizeRef = useRef<{ startY: number; startH: number } | null>(null) @@ -111,6 +114,28 @@ export function Sidebar() { return [...tools].sort((a, b) => (counts.byTool[b] || 0) - (counts.byTool[a] || 0)) }, [counts.byTool]) + const filteredProjects = useMemo(() => { + if (!projectSearch) return projectNames + const q = projectSearch.toLowerCase() + return projectNames.filter(n => n.toLowerCase().includes(q)) + }, [projectNames, projectSearch]) + + const [projCtx, setProjCtx] = useState<{ x: number; y: number; name: string } | null>(null) + const projCtxRef = useRef(null) + + useEffect(() => { + if (!projCtx) return + const close = () => setProjCtx(null) + const onClick = (e: MouseEvent) => { + if (projCtxRef.current && projCtxRef.current.contains(e.target as Node)) return + close() + } + document.addEventListener('click', onClick) + document.addEventListener('contextmenu', onClick) + document.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') close() }) + return () => { document.removeEventListener('click', onClick); document.removeEventListener('contextmenu', onClick) } + }, [projCtx]) + return (
{/* Traffic light spacer + controls */} @@ -171,7 +196,24 @@ export function Sidebar() { {/* Project filter */}
- setCollapsedProject(!collapsedProject)} /> +
+
setCollapsedProject(!collapsedProject)} className="flex cursor-pointer items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-foreground-muted select-none hover:text-foreground-secondary"> + + {translate('sidebar.project', lang)} +
+ {!collapsedProject && ( +
+ + setProjectSearch(e.target.value)} + placeholder="" + className="w-20 rounded border border-border bg-background py-0.5 pr-2 pl-6 text-[10px] text-foreground transition-all focus:w-28 focus:border-primary" + /> +
+ )} +
{!collapsedProject && ( <>
setFilter('selectedProject', 'all')} className={itemCls(selectedProject === 'all')}> @@ -180,7 +222,7 @@ export function Sidebar() { {counts.total}
- {projectNames.map(name => { + {filteredProjects.map(name => { const isExpanded = expandedProject === name const files = projectFiles[name] return ( @@ -188,6 +230,7 @@ export function Sidebar() {
setFilter('selectedProject', name)} onDoubleClick={() => toggleProjectExpand(name)} + onContextMenu={e => { e.preventDefault(); setProjCtx({ x: e.clientX, y: e.clientY, name }) }} title={translate('sidebar.doubleClickExpand', lang)} className={cn('mt-0.5', itemCls(selectedProject === name))} > @@ -252,6 +295,21 @@ export function Sidebar() {
)}
+ {projCtx && ( +
+
{ window.api.openInFinder(projCtx.name); setProjCtx(null) }} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-hover" + > + + {translate('sidebar.openInFinder', lang)} +
+
+ )}
) } diff --git a/src/renderer/lib/i18n.ts b/src/renderer/lib/i18n.ts index 2f10832..23ce102 100644 --- a/src/renderer/lib/i18n.ts +++ b/src/renderer/lib/i18n.ts @@ -91,6 +91,7 @@ const t: Record> = { 'ctx.unstar': row('Unstar', '取消标星', { es:'Quitar destaque', ar:'إلغاء التمييز', hi:'तारा हटाएं', pt:'Desfavoritar', bn:'তারা সরান', ru:'Убрать', ja:'スター解除', ko:'즐겨찾기 해제', fr:'Retirer', de:'Markierung aufheben', tr:'Yıldızı kaldır', it:'Rimuovi', th:'ยกเลิกดาว', vi:'Bỏ sao', pl:'Odznacz', nl:'Ster verwijderen', uk:'Прибрати', id:'Hapus bintang' }), 'ctx.archive': row('Archive', '归档', { es:'Archivar', ar:'أرشفة', hi:'संग्रहित', pt:'Arquivar', bn:'সংরক্ষণ', ru:'В архив', ja:'アーカイブ', ko:'보관', fr:'Archiver', de:'Archivieren', tr:'Arşivle', it:'Archivia', th:'เก็บถาวร', vi:'Lưu trữ', pl:'Zarchiwizuj', nl:'Archiveren', uk:'В архів', id:'Arsipkan' }), 'ctx.delete': row('Delete', '删除', { es:'Eliminar', ar:'حذف', hi:'हटाएं', pt:'Excluir', bn:'মুছুন', ru:'Удалить', ja:'削除', ko:'삭제', fr:'Supprimer', de:'Löschen', tr:'Sil', it:'Elimina', th:'ลบ', vi:'Xóa', pl:'Usuń', nl:'Verwijderen', uk:'Видалити', id:'Hapus' }), + 'ctx.viewDetail': row('View Detail', '查看详情'), // SessionDetail 'detail.back': row('Back', '返回', { es:'Volver', ar:'رجوع', hi:'वापस', pt:'Voltar', bn:'ফিরে', ru:'Назад', ja:'戻る', ko:'뒤로', fr:'Retour', de:'Zurück', tr:'Geri', it:'Indietro', th:'กลับ', vi:'Quay lại', pl:'Wstecz', nl:'Terug', uk:'Назад', id:'Kembali' }), @@ -115,6 +116,9 @@ const t: Record> = { 'detail.msgs': row('msgs', '条消息', { es:'mensajes', ar:'رسائل', hi:'संदेश', pt:'mensagens', bn:'বার্তা', ru:'сообщ.', ja:'件', ko:'건', fr:'messages', de:'Nachr.', tr:'msj', it:'messaggi', th:'ข้อความ', vi:'tin nhắn', pl:'wiad.', nl:'berichten', uk:'повід.', id:'pesan' }), 'detail.tools': row('tools', '工具', { es:'herramientas', ar:'أدوات', hi:'टूल', pt:'ferramentas', bn:'টুল', ru:'инструменты', ja:'ツール', ko:'도구', fr:'outils', de:'Werkzeuge', tr:'araçlar', it:'strumenti', th:'เครื่องมือ', vi:'công cụ', pl:'narzędzia', nl:'gereedschappen', uk:'інструменти', id:'alat' }), 'detail.tokens': row('tokens', 'tokens'), + 'detail.order.newFirst': row('Newest first', '最新优先'), + 'detail.order.oldFirst': row('Oldest first', '最早优先'), + 'sidebar.openInFinder': row('Open in Finder', '在 Finder 中打开'), // ConfirmModal 'confirm.cancel': row('Cancel', '取消', { es:'Cancelar', ar:'إلغاء', hi:'रद्द करें', pt:'Cancelar', bn:'বাতিল', ru:'Отмена', ja:'キャンセル', ko:'취소', fr:'Annuler', de:'Abbrechen', tr:'İptal', it:'Annulla', th:'ยกเลิก', vi:'Hủy', pl:'Anuluj', nl:'Annuleren', uk:'Скасувати', id:'Batal' }), diff --git a/src/renderer/stores/useStore.ts b/src/renderer/stores/useStore.ts index 8d32765..f94917f 100644 --- a/src/renderer/stores/useStore.ts +++ b/src/renderer/stores/useStore.ts @@ -18,6 +18,7 @@ declare global { getProjectNames: () => Promise getSessionCount: () => Promise getCounts: () => Promise<{ byTool: Record; byProject: Record; total: number; active: number; starred: number; pinned: number; archived: number }> + openInFinder: (projectName: string) => Promise listProjectFiles: (projectName: string) => Promise> toggleStar: (sessionId: string) => Promise toggleArchive: (sessionId: string) => Promise From 8a76835e0ac8b95da979a0e79eef7f497dfde4ed Mon Sep 17 00:00:00 2001 From: Sun KeyContacts Date: Fri, 22 May 2026 13:09:42 +0800 Subject: [PATCH 2/3] fix: security hardening, UX polish, performance optimizations - Fix command injection in open-system-terminal (execFile + escaping) - Add sidebar project right-click menu (pin, finder, copy path, terminal, vscode) - Add toast notifications for sidebar actions - Fix Virtuoso scroll position on reverse order toggle - Fix loadSessions race condition with request versioning - Optimize getCounts() from JS counting to SQL GROUP BY - Add database indexes on archived/pinned columns - Wrap localStorage pinnedProjects in try-catch - Add missing preload API exposures (openInFinder, copyProjectPath, openInVscode) - Fix body overflow clipping toast notifications - Add i18n keys for new sidebar context menu actions Co-Authored-By: Claude Opus 4.7 --- src/main/database.ts | 16 ++-- src/main/ipc-handlers.ts | 33 ++++++-- src/preload/index.ts | 4 + src/renderer/App.tsx | 70 +++++++++-------- src/renderer/components/SessionDetail.tsx | 10 ++- src/renderer/components/Sidebar.tsx | 93 +++++++++++++++++++---- src/renderer/lib/i18n.ts | 9 +++ src/renderer/stores/useStore.ts | 20 ++++- src/renderer/styles/index.css | 2 +- 9 files changed, 194 insertions(+), 63 deletions(-) diff --git a/src/main/database.ts b/src/main/database.ts index 3f76d82..6465f63 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -49,6 +49,8 @@ export class DatabaseManager { CREATE INDEX IF NOT EXISTS idx_session_project ON unified_session(project_name); CREATE INDEX IF NOT EXISTS idx_session_updated ON unified_session(updated_at); CREATE INDEX IF NOT EXISTS idx_session_starred ON unified_session(starred); + CREATE INDEX IF NOT EXISTS idx_session_archived ON unified_session(archived); + CREATE INDEX IF NOT EXISTS idx_session_pinned ON unified_session(pinned); CREATE INDEX IF NOT EXISTS idx_msg_session ON session_message_preview(session_id); CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5( @@ -254,14 +256,12 @@ export class DatabaseManager { getCounts(): { byTool: Record, byProject: Record, total: number } { const byTool: Record = {} const byProject: Record = {} - const rows = this.db.prepare('SELECT tool, project_name FROM unified_session WHERE archived = 0').all() as Array<{ tool: string; project_name: string | null }> - for (const r of rows) { - byTool[r.tool] = (byTool[r.tool] || 0) + 1 - if (r.project_name) { - byProject[r.project_name] = (byProject[r.project_name] || 0) + 1 - } - } - return { byTool, byProject, total: rows.length } + const toolRows = this.db.prepare('SELECT tool, COUNT(*) as cnt FROM unified_session WHERE archived = 0 GROUP BY tool').all() as Array<{ tool: string; cnt: number }> + for (const r of toolRows) byTool[r.tool] = r.cnt + const projRows = this.db.prepare('SELECT project_name, COUNT(*) as cnt FROM unified_session WHERE archived = 0 AND project_name IS NOT NULL GROUP BY project_name').all() as Array<{ project_name: string; cnt: number }> + for (const r of projRows) byProject[r.project_name] = r.cnt + const total = this.db.prepare('SELECT COUNT(*) as cnt FROM unified_session WHERE archived = 0').get() as { cnt: number } + return { byTool, byProject, total: total.cnt } } getStatusCounts(): { active: number; starred: number; pinned: number; archived: number } { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 3c8bb12..b7b9530 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell } from 'electron' +import { ipcMain, BrowserWindow, shell, clipboard } from 'electron' import { DatabaseManager } from './database' import { Indexer } from './indexer' import { PtyManager } from './pty-manager' @@ -35,7 +35,30 @@ export function registerIpcHandlers( ipcMain.handle('open-in-finder', async (_e, projectName: string) => { const path = db.getProjectPath(projectName) - if (path) shell.showItemInFolder(path) + if (!path) return false + shell.showItemInFolder(path) + return true + }) + + ipcMain.handle('copy-project-path', async (_e, projectName: string) => { + const path = db.getProjectPath(projectName) + if (!path) return false + clipboard.writeText(path) + return true + }) + + ipcMain.handle('open-in-vscode', async (_e, projectName: string) => { + const path = db.getProjectPath(projectName) + if (!path) return false + const { execFile } = await import('child_process') + execFile('code', [path], (err) => { + if (err) console.error('[IPC] open-in-vscode failed:', err.message) + }) + return true + }) + + ipcMain.handle('get-project-path', async (_e, projectName: string) => { + return db.getProjectPath(projectName) || null }) ipcMain.handle('list-project-files', async (_e, projectName: string) => { @@ -128,15 +151,15 @@ export function registerIpcHandlers( const fs = await import('fs') const path = await import('path') const os = await import('os') - const { exec } = await import('child_process') + const { execFile } = await import('child_process') const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-resume-')) const scriptPath = path.join(tmpDir, 'resume.command') const workDir = cwd || process.env.HOME || '/' - fs.writeFileSync(scriptPath, `#!/bin/bash\ncd "${workDir}"\n${command}\n`) + fs.writeFileSync(scriptPath, `#!/bin/bash\ncd "${workDir.replace(/"/g, '\\"')}"\n${command.replace(/"/g, '\\"')}\n`) fs.chmodSync(scriptPath, 0o755) if (terminalApp) { - exec(`open -a "${terminalApp}" "${scriptPath}"`) + execFile('open', ['-a', terminalApp, scriptPath]) } else { shell.openPath(scriptPath) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 87d2a48..4468d61 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,10 @@ contextBridge.exposeInMainWorld('api', { getSessionCount: () => ipcRenderer.invoke('get-session-count'), getCounts: () => ipcRenderer.invoke('get-counts'), listProjectFiles: (projectName: string) => ipcRenderer.invoke('list-project-files', projectName), + openInFinder: (projectName: string) => ipcRenderer.invoke('open-in-finder', projectName), + copyProjectPath: (projectName: string) => ipcRenderer.invoke('copy-project-path', projectName), + openInVscode: (projectName: string) => ipcRenderer.invoke('open-in-vscode', projectName), + getProjectPath: (projectName: string) => ipcRenderer.invoke('get-project-path', projectName), toggleStar: (sessionId: string) => ipcRenderer.invoke('toggle-star', sessionId), toggleArchive: (sessionId: string) => ipcRenderer.invoke('toggle-archive', sessionId), togglePin: (sessionId: string) => ipcRenderer.invoke('toggle-pin', sessionId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 10f9e1f..7ce0b9a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,6 @@ import { useEffect } from 'react' +import 'sonner/dist/styles.css' +import { Toaster } from 'sonner' import { useStore } from './stores/useStore' import { Sidebar } from './components/Sidebar' import { SessionList } from './components/SessionList' @@ -24,41 +26,43 @@ export function App() { loadProjectNames() }, []) - if (terminalFullscreen && showTerminal) { - return ( -
- - -
- ) - } - return ( -
-
- -
-
- -
- {detailSession && ( -
- -
- )} + <> + {terminalFullscreen && showTerminal ? ( +
+ + +
-
+ ) : ( +
+
+ +
+
+ +
+ {detailSession && ( +
+ +
+ )} +
+
- {showTerminal && } - - -
+ {showTerminal && } + + +
+ )} + + ) } diff --git a/src/renderer/components/SessionDetail.tsx b/src/renderer/components/SessionDetail.tsx index e36957c..624e6ea 100644 --- a/src/renderer/components/SessionDetail.tsx +++ b/src/renderer/components/SessionDetail.tsx @@ -212,7 +212,12 @@ export function SessionDetail() { @@ -275,9 +280,10 @@ export function SessionDetail() {
{translate('detail.noMessages', lang as any)}
) : ( } components={{ Header: () =>
, diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 65a0587..4f0ffe7 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react' import { createPortal } from 'react-dom' -import { Sun, Moon, RefreshCw, FolderOpen, Folder, FileText, Star, Pin, Archive, CircleDot, Layers, Settings, ChevronRight, Search, FolderSearch } from 'lucide-react' +import { Sun, Moon, RefreshCw, FolderOpen, Folder, FileText, Star, Pin, Archive, CircleDot, Layers, Settings, ChevronRight, Search, FolderSearch, Copy, Terminal, Code } from 'lucide-react' +import { toast } from 'sonner' import { motion, AnimatePresence } from 'framer-motion' import { useStore } from '../stores/useStore' import { cn } from '../lib/utils' @@ -70,6 +71,8 @@ export function Sidebar() { const expandedProject = useStore(s => s.expandedProject) const projectFiles = useStore(s => s.projectFiles) const toggleProjectExpand = useStore(s => s.toggleProjectExpand) + const pinnedProjects = useStore(s => s.pinnedProjects) + const toggleProjectPin = useStore(s => s.toggleProjectPin) const theme = useStore(s => s.theme) const toggleTheme = useStore(s => s.toggleTheme) const lang = useStore(s => s.language) @@ -115,10 +118,17 @@ export function Sidebar() { }, [counts.byTool]) const filteredProjects = useMemo(() => { - if (!projectSearch) return projectNames - const q = projectSearch.toLowerCase() - return projectNames.filter(n => n.toLowerCase().includes(q)) - }, [projectNames, projectSearch]) + let list = projectNames + if (projectSearch) { + const q = projectSearch.toLowerCase() + list = list.filter(n => n.toLowerCase().includes(q)) + } + return [...list].sort((a, b) => { + const aP = pinnedProjects.includes(a) ? 0 : 1 + const bP = pinnedProjects.includes(b) ? 0 : 1 + return aP - bP + }) + }, [projectNames, projectSearch, pinnedProjects]) const [projCtx, setProjCtx] = useState<{ x: number; y: number; name: string } | null>(null) const projCtxRef = useRef(null) @@ -130,10 +140,15 @@ export function Sidebar() { if (projCtxRef.current && projCtxRef.current.contains(e.target as Node)) return close() } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() } document.addEventListener('click', onClick) document.addEventListener('contextmenu', onClick) - document.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') close() }) - return () => { document.removeEventListener('click', onClick); document.removeEventListener('contextmenu', onClick) } + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('click', onClick) + document.removeEventListener('contextmenu', onClick) + document.removeEventListener('keydown', onKey) + } }, [projCtx]) return ( @@ -230,7 +245,7 @@ export function Sidebar() {
setFilter('selectedProject', name)} onDoubleClick={() => toggleProjectExpand(name)} - onContextMenu={e => { e.preventDefault(); setProjCtx({ x: e.clientX, y: e.clientY, name }) }} + onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setProjCtx({ x: e.clientX, y: e.clientY, name }) }} title={translate('sidebar.doubleClickExpand', lang)} className={cn('mt-0.5', itemCls(selectedProject === name))} > @@ -239,6 +254,7 @@ export function Sidebar() { : } {name} + {pinnedProjects.includes(name) && } {counts.byProject[name] || 0}
{isExpanded && files && ( @@ -278,7 +294,7 @@ export function Sidebar() { : counts.archived return (
setFilter('selectedStatus', status)} className={itemCls(selectedStatus === status)}> - + {translate(`sidebar.status.${status}` as any, lang)} {count}
@@ -295,20 +311,71 @@ export function Sidebar() {
)}
- {projCtx && ( + {projCtx && createPortal(
{ window.api.openInFinder(projCtx.name); setProjCtx(null) }} + onClick={() => { + toggleProjectPin(projCtx.name) + toast.success(pinnedProjects.includes(projCtx.name) ? translate('sidebar.unpinned', lang) : translate('sidebar.pinned', lang)) + setProjCtx(null) + }} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-hover" + > + + {pinnedProjects.includes(projCtx.name) ? translate('sidebar.unpinProject', lang) : translate('sidebar.pinProject', lang)} +
+
+
{ + toast.success(translate('sidebar.opened', lang)) + window.api.openInFinder(projCtx.name) + setProjCtx(null) + }} className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-hover" > {translate('sidebar.openInFinder', lang)}
-
+
{ + toast.success(translate('sidebar.copied', lang)) + window.api.copyProjectPath(projCtx.name) + setProjCtx(null) + }} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-hover" + > + + {translate('sidebar.copyPath', lang)} +
+
{ + toast.success(translate('sidebar.opened', lang)) + const path = await window.api.getProjectPath(projCtx.name) + if (path) window.api.openSystemTerminal('cd "' + path + '"', path, useStore.getState().terminalApp || undefined) + setProjCtx(null) + }} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-hover" + > + + {translate('sidebar.openInTerminal', lang)} +
+
{ + toast.success(translate('sidebar.opened', lang)) + window.api.openInVscode(projCtx.name) + setProjCtx(null) + }} + className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-hover" + > + + {translate('sidebar.openInVscode', lang)} +
+
, + document.body )}
) diff --git a/src/renderer/lib/i18n.ts b/src/renderer/lib/i18n.ts index 23ce102..56edd7b 100644 --- a/src/renderer/lib/i18n.ts +++ b/src/renderer/lib/i18n.ts @@ -119,6 +119,15 @@ const t: Record> = { 'detail.order.newFirst': row('Newest first', '最新优先'), 'detail.order.oldFirst': row('Oldest first', '最早优先'), 'sidebar.openInFinder': row('Open in Finder', '在 Finder 中打开'), + 'sidebar.copyPath': row('Copy Path', '复制路径'), + 'sidebar.openInTerminal': row('Open in Terminal', '在终端中打开'), + 'sidebar.openInVscode': row('Open in VS Code', '在 VS Code 中打开'), + 'sidebar.opened': row('Done', '已打开'), + 'sidebar.copied': row('Copied', '已复制'), + 'sidebar.pinProject': row('Pin Project', '置顶项目'), + 'sidebar.unpinProject': row('Unpin Project', '取消置顶'), + 'sidebar.pinned': row('Pinned', '已置顶'), + 'sidebar.unpinned': row('Unpinned', '已取消置顶'), // ConfirmModal 'confirm.cancel': row('Cancel', '取消', { es:'Cancelar', ar:'إلغاء', hi:'रद्द करें', pt:'Cancelar', bn:'বাতিল', ru:'Отмена', ja:'キャンセル', ko:'취소', fr:'Annuler', de:'Abbrechen', tr:'İptal', it:'Annulla', th:'ยกเลิก', vi:'Hủy', pl:'Anuluj', nl:'Annuleren', uk:'Скасувати', id:'Batal' }), diff --git a/src/renderer/stores/useStore.ts b/src/renderer/stores/useStore.ts index f94917f..fc8c130 100644 --- a/src/renderer/stores/useStore.ts +++ b/src/renderer/stores/useStore.ts @@ -19,6 +19,8 @@ declare global { getSessionCount: () => Promise getCounts: () => Promise<{ byTool: Record; byProject: Record; total: number; active: number; starred: number; pinned: number; archived: number }> openInFinder: (projectName: string) => Promise + copyProjectPath: (projectName: string) => Promise + openInVscode: (projectName: string) => Promise listProjectFiles: (projectName: string) => Promise> toggleStar: (sessionId: string) => Promise toggleArchive: (sessionId: string) => Promise @@ -51,12 +53,14 @@ interface AppState { detailLoading: boolean counts: { byTool: Record; byProject: Record; total: number; active: number; starred: number; pinned: number; archived: number } loading: boolean + _sessionReqId: number sortBy: string terminalTabs: TerminalTab[] activeTabId: string | null showTerminal: boolean expandedProject: string | null projectFiles: Record> + pinnedProjects: string[] terminalFullscreen: boolean theme: 'dark' | 'light' resumeAction: 'system' | 'builtin' @@ -81,6 +85,7 @@ interface AppState { closeTab: (tabId: string) => Promise setActiveTab: (tabId: string) => void toggleProjectExpand: (projectName: string) => void + toggleProjectPin: (projectName: string) => void setTerminalFullscreen: (v: boolean) => void toggleTheme: () => void setResumeAction: (v: 'system' | 'builtin') => void @@ -103,12 +108,14 @@ export const useStore = create((set, get) => ({ detailLoading: false, counts: { byTool: {}, byProject: {}, total: 0, active: 0, starred: 0, pinned: 0, archived: 0 }, loading: false, + _sessionReqId: 0, sortBy: 'updatedAt', terminalTabs: [], activeTabId: null, showTerminal: false, expandedProject: null, projectFiles: {}, + pinnedProjects: (() => { try { return JSON.parse(localStorage.getItem('pinnedProjects') || '[]') } catch { return [] } })(), terminalFullscreen: false, theme: (localStorage.getItem('theme') as 'dark' | 'light') || 'dark', resumeAction: (localStorage.getItem('resumeAction') as 'system' | 'builtin') || 'system', @@ -125,7 +132,8 @@ export const useStore = create((set, get) => ({ }, loadSessions: async () => { - set({ loading: true }) + const reqId = get()._sessionReqId + 1 + set({ loading: true, _sessionReqId: reqId }) const { selectedTool, selectedProject, selectedStatus, searchQuery } = get() const sessions = await window.api.getSessions({ tool: selectedTool, @@ -133,6 +141,7 @@ export const useStore = create((set, get) => ({ status: selectedStatus, search: searchQuery || undefined }) + if (get()._sessionReqId !== reqId) return set({ sessions, loading: false }) }, @@ -278,6 +287,15 @@ export const useStore = create((set, get) => ({ set({ expandedProject: projectName }) }, + toggleProjectPin: (projectName: string) => { + const pinned = get().pinnedProjects + const next = pinned.includes(projectName) + ? pinned.filter(n => n !== projectName) + : [...pinned, projectName] + set({ pinnedProjects: next }) + localStorage.setItem('pinnedProjects', JSON.stringify(next)) + }, + setTerminalFullscreen: (v: boolean) => { set({ terminalFullscreen: v }) }, diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index 0cb0375..c34c682 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -104,13 +104,13 @@ color: var(--color-foreground); font-family: var(--font-sans); font-size: 13px; - overflow: hidden; height: 100vh; -webkit-font-smoothing: antialiased; } #root { height: 100vh; + overflow: hidden; display: flex; flex-direction: column; } From 65534faae7bca22d353f8cef86f65f8d6bcc7dfe Mon Sep 17 00:00:00 2001 From: Sun KeyContacts Date: Fri, 22 May 2026 14:18:40 +0800 Subject: [PATCH 3/3] chore: bump version to 0.2.0 Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 725b4ac..3c4ff40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "session-manager", - "version": "0.1.0", + "version": "0.2.0", "description": "Unified AI Agent Session Manager", "main": "./out/main/index.js", "scripts": {