diff --git a/src/App.css b/src/App.css index 63f4c59..b667650 100644 --- a/src/App.css +++ b/src/App.css @@ -1132,10 +1132,24 @@ label.entry-label { cursor: pointer; } } .worktree-session-option:has(input:disabled) { cursor: default; opacity: 0.65; } .row-actions { display: flex; justify-content: flex-end; } +.session-row.clickable { cursor: pointer; } +.session-row.clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.session-row.selected { background: color-mix(in srgb, var(--accent) 7%, transparent); } +.session-row.active { box-shadow: inset 3px 0 0 var(--accent); } +/* The checkbox cell is padded out so selecting does not demand a 15px-precise click. */ +.row-check { display: flex; align-items: center; align-self: stretch; margin: -10px -8px -10px -12px; padding: 0 8px 0 12px; cursor: default; } +.transcript-actions { align-items: center; } +.transcript-select { display: flex; align-items: center; gap: 7px; margin-right: auto; font-size: 12.5px; cursor: pointer; } +.transcript-select.selected { color: var(--accent); font-weight: 600; } +.transcript-nav { display: flex; align-items: center; gap: 2px; } +.transcript-position { min-width: 52px; text-align: center; color: var(--text-tertiary); font-size: 11.5px; font-variant-numeric: tabular-nums; } /* --- conversation preview --- */ .transcript-dialog { width: min(720px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); display: flex; flex-direction: column; overflow: hidden; } +.transcript-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 12px; } .transcript-header h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.transcript-header .dialog-lead { grid-column: 1 / -1; } +.transcript-close { margin: -6px -8px 0 0; } .transcript-body { flex: 1; min-height: 0; margin-top: 14px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-sunken); } .transcript-list { list-style: none; margin: 0; padding: 6px 14px; } .transcript-message { padding: 10px 0; border-top: 1px solid var(--line-soft); } diff --git a/src/icons.tsx b/src/icons.tsx index fc7f51d..379bba1 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -14,6 +14,12 @@ export function PreviewIcon() { } +export function CloseIcon() { + return + + +} + export function BackIcon() { return diff --git a/src/views/SessionsView.tsx b/src/views/SessionsView.tsx index 7e7fb7b..ccb04b4 100644 --- a/src/views/SessionsView.tsx +++ b/src/views/SessionsView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from 'react' import { type ScanSnapshot, type SessionItem, @@ -15,7 +15,7 @@ import { formatBytes } from '../../shared/types' import { message } from '../../shared/messages' -import { FolderIcon, PreviewIcon } from '../icons' +import { CloseIcon, FolderIcon, PreviewIcon } from '../icons' import { formatShortDate } from '../format' import { usePreferences } from '../preferences' import { CleanupSelectionBar, DetailSummary, FunnelFilter, SelectAllCheckbox, SortHeader, useListSelection, useSortState, type SortDir } from '../components/list-controls' @@ -178,7 +178,8 @@ export default function SessionsView({ snapshot, cleaning, actionsDisabled, clea - {visible.map((session) => selection.toggle(session)} onPreview={() => setPreviewing(session)} locale={locale} />)} + {visible.map((session) => selection.toggle(session)} onPreview={() => setPreviewing(session)} locale={locale} />)} {!visible.length && {listable.length ? t('没有符合筛选条件的会话', 'No sessions match these filters') @@ -191,14 +192,37 @@ export default function SessionsView({ snapshot, cleaning, actionsDisabled, clea cleaning={cleaning} actionsDisabled={actionsDisabled} progress={cleanProgress} onDelete={() => onCleanup({ kind: 'sessions-delete', ids: selectedSessions.map(sessionID) })} /> - {previewing && setPreviewing(null)} />} + {previewing && setPreviewing(null)} + checked={selection.isSelected(previewing)} onToggle={() => selection.toggle(previewing)} + position={visible.indexOf(previewing)} count={visible.length} + onNavigate={(step) => { + const next = visible[visible.indexOf(previewing) + step] + if (next) setPreviewing(next) + }} />} > } -function SessionRow({ session, checked, locale, onToggle, onPreview }: { session: SessionItem; checked: boolean; locale: string; onToggle: () => void; onPreview: () => void }) { +/** Clicks and keys that land on the row's own controls must not also open the preview. */ +const fromControl = (event: ReactMouseEvent | ReactKeyboardEvent): boolean => + event.target instanceof Element && event.target.closest('input, button, label') !== null + +function SessionRow({ session, checked, active, locale, onToggle, onPreview }: { + session: SessionItem; checked: boolean; active: boolean; locale: string; onToggle: () => void; onPreview: () => void +}) { const { t, m } = usePreferences() - return - + // Clicking a row opens its conversation; only the checkbox selects, so browsing a + // deletion list can never quietly add a conversation to what gets deleted. + return { if (!fromControl(event)) onPreview() }} + onKeyDown={(event) => { + if (fromControl(event) || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + onPreview() + }}> + event.stopPropagation()}> + + {sessionDisplayName(session)} {(session.isPinned || session.tags.length > 0) && @@ -212,14 +236,19 @@ function SessionRow({ session, checked, locale, onToggle, onPreview }: { session {formatBytes(session.fileBytes)} {formatBytes(sessionTotalBytes(session))} - + window.cleanmycodex.revealPath(session.fileURL)}> } /** Read-only look at a conversation's messages, so it can be recognised before it is deleted. */ -function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; locale: string; onClose: () => void }) { +function TranscriptDialog({ session, locale, onClose, checked, onToggle, position, count, onNavigate }: { + session: SessionItem; locale: string; onClose: () => void + checked: boolean; onToggle: () => void + /** Index within the filtered list, or -1 once filters hide the conversation. */ + position: number; count: number; onNavigate: (step: -1 | 1) => void +}) { const { t, e } = usePreferences() const [transcript, setTranscript] = useState(null) const [error, setError] = useState(null) @@ -227,6 +256,8 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; useEffect(() => { let cancelled = false + setTranscript(null) + setError(null) window.cleanmycodex.sessionTranscript(session.id) .then((result) => { if (!cancelled) setTranscript(result) }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) }) @@ -235,13 +266,20 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; useEffect(() => { const onKey = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') return - if (zoomed) setZoomed(null) - else onClose() + if (event.key === 'Escape') { + // Captured first and stopped, so the app's own Escape (back to the overview) waits for the next press. + event.stopImmediatePropagation() + if (zoomed) setZoomed(null) + else onClose() + } else if (!zoomed && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + if (event.target instanceof HTMLInputElement && event.target.type !== 'checkbox') return + event.preventDefault() + onNavigate(event.key === 'ArrowLeft' ? -1 : 1) + } } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [onClose, zoomed]) + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [onClose, onNavigate, zoomed]) const notes: string[] = [] if (transcript?.toolCalls) notes.push(t(`另有 ${transcript.toolCalls} 次工具调用未显示`, `${transcript.toolCalls} tool calls not shown`)) @@ -253,7 +291,8 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; {sessionDisplayName(session)} - {[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale)].filter(Boolean).join(' · ')} + + {[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale), formatBytes(sessionTotalBytes(session))].filter(Boolean).join(' · ')} {error && {e(error)}} @@ -283,9 +322,16 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; } {notes.length > 0 && {notes.join(' · ')}} - - void window.cleanmycodex.revealPath(session.fileURL)}>{t('在文件管理器中显示', 'Show in File Manager')} - {t('关闭', 'Close')} + + + + {t('选中此会话', 'Select this conversation')} + + + onNavigate(-1)}>{t('上一个', 'Previous')} + {position >= 0 && {position + 1} / {count}} + = count - 1} title="→" onClick={() => onNavigate(1)}>{t('下一个', 'Next')} + {zoomed && setZoomed(null)}>}
{listable.length ? t('没有符合筛选条件的会话', 'No sessions match these filters') @@ -191,14 +192,37 @@ export default function SessionsView({ snapshot, cleaning, actionsDisabled, clea cleaning={cleaning} actionsDisabled={actionsDisabled} progress={cleanProgress} onDelete={() => onCleanup({ kind: 'sessions-delete', ids: selectedSessions.map(sessionID) })} /> - {previewing && setPreviewing(null)} />} + {previewing && setPreviewing(null)} + checked={selection.isSelected(previewing)} onToggle={() => selection.toggle(previewing)} + position={visible.indexOf(previewing)} count={visible.length} + onNavigate={(step) => { + const next = visible[visible.indexOf(previewing) + step] + if (next) setPreviewing(next) + }} />} > } -function SessionRow({ session, checked, locale, onToggle, onPreview }: { session: SessionItem; checked: boolean; locale: string; onToggle: () => void; onPreview: () => void }) { +/** Clicks and keys that land on the row's own controls must not also open the preview. */ +const fromControl = (event: ReactMouseEvent | ReactKeyboardEvent): boolean => + event.target instanceof Element && event.target.closest('input, button, label') !== null + +function SessionRow({ session, checked, active, locale, onToggle, onPreview }: { + session: SessionItem; checked: boolean; active: boolean; locale: string; onToggle: () => void; onPreview: () => void +}) { const { t, m } = usePreferences() - return - + // Clicking a row opens its conversation; only the checkbox selects, so browsing a + // deletion list can never quietly add a conversation to what gets deleted. + return { if (!fromControl(event)) onPreview() }} + onKeyDown={(event) => { + if (fromControl(event) || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + onPreview() + }}> + event.stopPropagation()}> + + {sessionDisplayName(session)} {(session.isPinned || session.tags.length > 0) && @@ -212,14 +236,19 @@ function SessionRow({ session, checked, locale, onToggle, onPreview }: { session {formatBytes(session.fileBytes)} {formatBytes(sessionTotalBytes(session))} - + window.cleanmycodex.revealPath(session.fileURL)}> } /** Read-only look at a conversation's messages, so it can be recognised before it is deleted. */ -function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; locale: string; onClose: () => void }) { +function TranscriptDialog({ session, locale, onClose, checked, onToggle, position, count, onNavigate }: { + session: SessionItem; locale: string; onClose: () => void + checked: boolean; onToggle: () => void + /** Index within the filtered list, or -1 once filters hide the conversation. */ + position: number; count: number; onNavigate: (step: -1 | 1) => void +}) { const { t, e } = usePreferences() const [transcript, setTranscript] = useState(null) const [error, setError] = useState(null) @@ -227,6 +256,8 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; useEffect(() => { let cancelled = false + setTranscript(null) + setError(null) window.cleanmycodex.sessionTranscript(session.id) .then((result) => { if (!cancelled) setTranscript(result) }) .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) }) @@ -235,13 +266,20 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; useEffect(() => { const onKey = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') return - if (zoomed) setZoomed(null) - else onClose() + if (event.key === 'Escape') { + // Captured first and stopped, so the app's own Escape (back to the overview) waits for the next press. + event.stopImmediatePropagation() + if (zoomed) setZoomed(null) + else onClose() + } else if (!zoomed && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + if (event.target instanceof HTMLInputElement && event.target.type !== 'checkbox') return + event.preventDefault() + onNavigate(event.key === 'ArrowLeft' ? -1 : 1) + } } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [onClose, zoomed]) + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [onClose, onNavigate, zoomed]) const notes: string[] = [] if (transcript?.toolCalls) notes.push(t(`另有 ${transcript.toolCalls} 次工具调用未显示`, `${transcript.toolCalls} tool calls not shown`)) @@ -253,7 +291,8 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; {sessionDisplayName(session)} - {[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale)].filter(Boolean).join(' · ')} + + {[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale), formatBytes(sessionTotalBytes(session))].filter(Boolean).join(' · ')} {error && {e(error)}} @@ -283,9 +322,16 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem; } {notes.length > 0 && {notes.join(' · ')}} - - void window.cleanmycodex.revealPath(session.fileURL)}>{t('在文件管理器中显示', 'Show in File Manager')} - {t('关闭', 'Close')} + + + + {t('选中此会话', 'Select this conversation')} + + + onNavigate(-1)}>{t('上一个', 'Previous')} + {position >= 0 && {position + 1} / {count}} + = count - 1} title="→" onClick={() => onNavigate(1)}>{t('下一个', 'Next')} + {zoomed && setZoomed(null)}>}
{[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale)].filter(Boolean).join(' · ')}
{[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale), formatBytes(sessionTotalBytes(session))].filter(Boolean).join(' · ')}
{e(error)}
{notes.join(' · ')}