Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
6 changes: 6 additions & 0 deletions src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export function PreviewIcon() {
</svg>
}

export function CloseIcon() {
return <svg className="navigation-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18"/>
</svg>
}

export function BackIcon() {
return <svg className="navigation-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 5-7 7 7 7"/>
Expand Down
84 changes: 65 additions & 19 deletions src/views/SessionsView.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -178,7 +178,8 @@ export default function SessionsView({ snapshot, cleaning, actionsDisabled, clea
<span />
</div>
<ul className="session-list">
{visible.map((session) => <SessionRow key={session.id} session={session} checked={selection.isSelected(session)} onToggle={() => selection.toggle(session)} onPreview={() => setPreviewing(session)} locale={locale} />)}
{visible.map((session) => <SessionRow key={session.id} session={session} checked={selection.isSelected(session)} active={previewing?.id === session.id}
onToggle={() => selection.toggle(session)} onPreview={() => setPreviewing(session)} locale={locale} />)}
</ul>
{!visible.length && <p className="empty-inline">{listable.length
? t('没有符合筛选条件的会话', 'No sessions match these filters')
Expand All @@ -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 && <TranscriptDialog session={previewing} locale={locale} onClose={() => setPreviewing(null)} />}
{previewing && <TranscriptDialog session={previewing} locale={locale} onClose={() => 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 <li className={`session-row ${session.isUnstable ? 'unstable' : ''}`}>
<input type="checkbox" aria-label={sessionDisplayName(session)} checked={checked} onChange={onToggle} />
// 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 <li className={`session-row clickable ${session.isUnstable ? 'unstable' : ''} ${checked ? 'selected' : ''} ${active ? 'active' : ''}`}
tabIndex={0} title={t('点击查看聊天内容', 'Click to view the conversation')}
onClick={(event) => { if (!fromControl(event)) onPreview() }}
onKeyDown={(event) => {
if (fromControl(event) || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
onPreview()
}}>
<label className="row-check" onClick={(event) => event.stopPropagation()}>
<input type="checkbox" aria-label={sessionDisplayName(session)} checked={checked} onChange={onToggle} />
</label>
<div className="session-title">
<span className="session-name">{sessionDisplayName(session)}</span>
{(session.isPinned || session.tags.length > 0) && <span className="session-tags">
Expand All @@ -212,21 +236,28 @@ function SessionRow({ session, checked, locale, onToggle, onPreview }: { session
<span className="col-num">{formatBytes(session.fileBytes)}</span>
<span className="col-num">{formatBytes(sessionTotalBytes(session))}</span>
<span className="row-actions">
<button className="icon-button" title={t('预览聊天内容', 'Preview conversation')} aria-label={t('预览聊天内容', 'Preview conversation')} onClick={onPreview}><PreviewIcon /></button>
<button className="icon-button" title={t('查看聊天内容', 'View conversation')} aria-label={t('查看聊天内容', 'View conversation')} onClick={onPreview}><PreviewIcon /></button>
<button className="icon-button" title={t('在文件管理器中显示', 'Show in file manager')} aria-label={t('在文件管理器中显示', 'Show in file manager')} onClick={() => window.cleanmycodex.revealPath(session.fileURL)}><FolderIcon /></button>
</span>
</li>
}

/** 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<SessionTranscript | null>(null)
const [error, setError] = useState<string | null>(null)
const [zoomed, setZoomed] = useState<string | null>(null)

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)) })
Expand All @@ -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`))
Expand All @@ -253,7 +291,8 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem;
<section className="cleanup-dialog transcript-dialog" role="dialog" aria-modal="true" aria-labelledby="transcript-title">
<header className="transcript-header">
<h2 id="transcript-title">{sessionDisplayName(session)}</h2>
<p className="dialog-lead">{[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale)].filter(Boolean).join(' · ')}</p>
<button className="icon-button transcript-close" title={t('关闭', 'Close')} aria-label={t('关闭', 'Close')} onClick={onClose}><CloseIcon /></button>
<p className="dialog-lead">{[sessionProjectName(session), new Date(session.modifiedAt).toLocaleString(locale), formatBytes(sessionTotalBytes(session))].filter(Boolean).join(' · ')}</p>
</header>
<div className="transcript-body">
{error && <p className="error">{e(error)}</p>}
Expand Down Expand Up @@ -283,9 +322,16 @@ function TranscriptDialog({ session, locale, onClose }: { session: SessionItem;
</section>}
</div>
{notes.length > 0 && <p className="transcript-notes">{notes.join(' · ')}</p>}
<div className="dialog-actions">
<button className="btn btn-quiet" onClick={() => void window.cleanmycodex.revealPath(session.fileURL)}>{t('在文件管理器中显示', 'Show in File Manager')}</button>
<button className="btn" onClick={onClose}>{t('关闭', 'Close')}</button>
<div className="dialog-actions transcript-actions">
<label className={`transcript-select ${checked ? 'selected' : ''}`}>
<input type="checkbox" checked={checked} onChange={onToggle} />
{t('选中此会话', 'Select this conversation')}
</label>
<span className="transcript-nav">
<button className="btn btn-quiet" disabled={position <= 0} title="←" onClick={() => onNavigate(-1)}>{t('上一个', 'Previous')}</button>
{position >= 0 && <span className="transcript-position">{position + 1} / {count}</span>}
<button className="btn btn-quiet" disabled={position < 0 || position >= count - 1} title="→" onClick={() => onNavigate(1)}>{t('下一个', 'Next')}</button>
</span>
</div>
</section>
{zoomed && <div className="image-zoom" role="presentation" onClick={() => setZoomed(null)}><img src={zoomed} alt="" /></div>}
Expand Down
Loading