Make agent session history easier to scan (#5438)

This commit is contained in:
Brennan Benson
2026-06-15 20:15:17 -07:00
committed by GitHub
parent 8e9f0fb0da
commit 2aa4ae6d21
12 changed files with 560 additions and 457 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ export function __resetPersistedWindowsPathCacheForTests(): void {
}
export function mergePersistedWindowsPath(
env: Record<string, string>,
env: NodeJS.ProcessEnv,
options: ReadWindowsPathOptions = {}
): void {
const platform = options.platform ?? process.platform
@@ -220,7 +220,7 @@ export default function AiVaultPanel(): React.JSX.Element {
}, [])
return (
<div className="flex h-full min-h-0 flex-col bg-sidebar">
<div className="@container/ai-vault flex h-full min-h-0 flex-col bg-sidebar">
<AiVaultPanelHeader
query={query}
loading={loading}
@@ -36,7 +36,7 @@ import { translate } from '@/i18n/i18n'
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
const VAULT_SCOPE_TOGGLE_ITEM_CLASS =
'h-6 min-h-6 min-w-0 border border-transparent bg-transparent px-1.5 text-[10px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground'
'h-6 min-h-6 min-w-0 border border-transparent bg-transparent px-1.5 text-[10px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1'
export function VaultGroupHeader({
group,
@@ -56,28 +56,47 @@ export function AiVaultPanelHeader({
}: AiVaultPanelHeaderProps): React.JSX.Element {
return (
<div className="shrink-0 border-b border-sidebar-border px-2.5 py-2">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 @max-[300px]/ai-vault:items-start">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHistory',
'Agent Session History'
)}
{/* Why: below 300px the header competes with fixed controls, so compact copy prevents overlap. */}
<span className="@max-[300px]/ai-vault:hidden">
{translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHistory',
'Agent Session History'
)}
</span>
<span className="hidden @max-[300px]/ai-vault:inline">
{translate('auto.components.right.sidebar.AiVaultPanel.agents', 'Agents')}
</span>
</div>
<div className="truncate text-[11px] text-muted-foreground">
{hasScanResult
? translate(
'auto.components.right.sidebar.AiVaultPanel.shownRecent',
'{{value0}} shown · {{value1}} recent',
{ value0: shownCount, value1: sessionCount }
)
: translate(
'auto.components.right.sidebar.AiVaultPanel.resumePastSessions',
'Resume past sessions'
)}
{hasScanResult ? (
<>
<span className="@max-[300px]/ai-vault:hidden">
{translate(
'auto.components.right.sidebar.AiVaultPanel.shownRecent',
'{{value0}} shown · {{value1}} recent',
{ value0: shownCount, value1: sessionCount }
)}
</span>
<span className="hidden @max-[300px]/ai-vault:inline">
{translate(
'auto.components.right.sidebar.AiVaultPanel.sessionsShownCompact',
'{{value0}} shown',
{ value0: shownCount }
)}
</span>
</>
) : (
translate(
'auto.components.right.sidebar.AiVaultPanel.resumePastSessions',
'Resume past sessions'
)
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
<VaultScopeSwitch
scope={scope}
workspaceAvailable={Boolean(activeWorktreePath)}
@@ -1,308 +1,225 @@
import type React from 'react'
import { useCallback } from 'react'
import { Copy, FileJson, FolderOpen, MoreHorizontal, Play } from 'lucide-react'
import { Copy, Play } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
export function SessionDetailsHoverCard({
session,
resumeCommand
}: {
session: AiVaultSession
resumeCommand: string
}): React.JSX.Element {
const updatedAt = session.updatedAt ?? session.modifiedAt
return (
<HoverCardContent
side="left"
align="start"
sideOffset={8}
className="scrollbar-sleek max-h-[min(28rem,calc(100vh-2rem))] w-80 overflow-y-auto p-3"
>
<div className="min-w-0">
<div className="flex items-start gap-2">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={16} />
</span>
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-[13px] font-medium leading-5 text-popover-foreground">
{session.title}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{agentLabel(session.agent)}
</div>
</div>
</div>
<TooltipProvider delayDuration={300}>
<div className="mt-3 space-y-1.5 text-[11px] leading-4">
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.updated',
'Updated'
)}
value={formatDateTime(updatedAt)}
/>
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.created',
'Created'
)}
value={formatDateTime(session.createdAt)}
/>
{session.branch ? (
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.branch',
'Branch'
)}
value={session.branch}
/>
) : null}
{session.model ? (
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.model',
'Model'
)}
value={session.model}
/>
) : null}
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.usage',
'Usage'
)}
value={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.usageValue',
'{{value0}} msgs{{value1}}',
{
value0: session.messageCount,
value1:
session.totalTokens > 0
? translate(
'auto.components.right.sidebar.AiVaultSessionDetails.tokenSuffix',
' · {{value0}} tok',
{ value0: formatTokenCount(session.totalTokens) }
)
: ''
}
)}
/>
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.session',
'Session'
)}
value={session.sessionId}
mono
/>
</div>
</TooltipProvider>
<div className="mt-3 border-t border-border/60 pt-2">
<div className="mb-1.5 text-[11px] font-semibold text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.latestLog',
'Latest log'
)}
</div>
{session.previewMessages.length > 0 ? (
<div className="space-y-2">
{session.previewMessages.map((message, index) => (
<div key={`${message.role}:${index}`} className="min-w-0">
<div className="mb-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
{previewRoleLabel(message.role)}
</div>
<div className="line-clamp-3 whitespace-pre-wrap text-[11px] leading-4 text-popover-foreground/90">
{message.text}
</div>
</div>
))}
</div>
) : (
<div className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noReadablePreview',
'No readable message preview in this transcript.'
)}
</div>
)}
</div>
<div className="mt-3 border-t border-border/60 pt-2">
<div className="mb-1 text-[11px] font-semibold text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeCommand',
'Resume command'
)}
</div>
<div className="line-clamp-3 break-all font-mono text-[10.5px] leading-4 text-popover-foreground/85">
{resumeCommand}
</div>
</div>
</div>
</HoverCardContent>
)
}
function DetailLine({
label,
value,
mono = false
}: {
label: string
value: string
mono?: boolean
}): React.JSX.Element {
const handleCopy = useCallback(() => {
void window.api.ui.writeClipboardText(value).then(() => {
toast.success(
translate('auto.components.right.sidebar.AiVaultPanel.valueCopied', '{{value0}} copied', {
value0: label
})
)
})
}, [label, value])
return (
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
<span className="text-muted-foreground">{label}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopy}
className={cn(
'min-w-0 truncate text-left text-popover-foreground/90 transition-colors hover:text-popover-foreground',
mono && 'font-mono'
)}
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyDetailValue',
'Copy {{value0}}',
{ value0: label }
)}
>
{value}
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={4}
className="pointer-events-none max-w-[min(20rem,calc(100vw-2rem))] break-all"
>
{value}
</TooltipContent>
</Tooltip>
</div>
)
}
export function MetaDot(): React.JSX.Element {
return <span className="shrink-0 text-muted-foreground/45">·</span>
}
export function SessionActionsMenu({
export function SessionInlineDetails({
id,
session,
onResume,
onCopyResume,
onCopyId,
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd,
resumeDisabled
}: {
id: string
session: AiVaultSession
onResume: () => void
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
onOpenLog: () => void
onRevealLog: () => void
onOpenCwd?: () => void
resumeDisabled: boolean
}): React.JSX.Element {
const updatedAt = session.updatedAt ?? session.modifiedAt
const usage = translate(
'auto.components.right.sidebar.AiVaultSessionDetails.usageValue',
'{{value0}} msgs{{value1}}',
{
value0: session.messageCount,
value1:
session.totalTokens > 0
? translate(
'auto.components.right.sidebar.AiVaultSessionDetails.tokenSuffix',
' · {{value0}} tok',
{ value0: formatTokenCount(session.totalTokens) }
)
: ''
}
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
id={id}
className="mt-2 rounded-md border border-sidebar-border bg-sidebar-accent/25 p-2"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={16} />
</span>
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-[12px] font-medium leading-4 text-foreground">
{session.title}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{agentLabel(session.agent)}
</div>
</div>
</div>
<div className="mt-2 grid gap-1 text-[11px] leading-4">
<SessionDetailCopyRow
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.updated',
'Updated'
)}
value={formatDateTime(updatedAt)}
/>
<SessionDetailCopyRow
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.created',
'Created'
)}
value={formatDateTime(session.createdAt)}
/>
{session.model ? (
<SessionDetailCopyRow
label={translate('auto.components.right.sidebar.AiVaultSessionDetails.model', 'Model')}
value={session.model}
/>
) : null}
{session.branch ? (
<SessionDetailCopyRow
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.branch',
'Branch'
)}
value={session.branch}
/>
) : null}
<SessionDetailCopyRow
label={translate('auto.components.right.sidebar.AiVaultSessionDetails.usage', 'Usage')}
value={usage}
/>
<SessionDetailCopyRow
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.session',
'Session'
)}
copyLabel={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.sessionId',
'Session ID'
)}
value={session.sessionId}
mono
/>
</div>
<div className="mt-2 grid gap-1">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.sessionActions',
'{{value0}} session actions',
{ value0: agentLabel(session.agent) }
)}
onClick={(event) => event.stopPropagation()}
variant="secondary"
size="xs"
disabled={resumeDisabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResume()
}}
className="h-7 justify-start px-2 text-[11px]"
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInNewTab',
'Resume in New Tab'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyResume}>
</Button>
<Button
type="button"
variant="ghost"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onCopyResume()
}}
className="h-7 justify-start px-2 text-[11px]"
>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyResumeCommand',
'Copy Resume Command'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.openLog', 'Open Log')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.revealLog', 'Reveal Log')}
</DropdownMenuItem>
{onOpenCwd ? (
<DropdownMenuItem onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.openWorkingDirectory',
'Open Working Directory'
</Button>
</div>
</div>
)
}
function SessionDetailCopyRow({
label,
copyLabel = label,
value,
mono = false
}: {
label: string
copyLabel?: string
value: string
mono?: boolean
}): React.JSX.Element {
const handleCopy = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation()
void window.api.ui
.writeClipboardText(value)
.then(() => {
toast.success(
translate('auto.components.right.sidebar.AiVaultPanel.valueCopied', '{{value0}} copied', {
value0: copyLabel
})
)
})
.catch(() => {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.valueCopyFailed',
'Unable to copy {{value0}}',
{ value0: copyLabel }
)
)
})
}
return (
<div className="grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)_1.5rem] items-center gap-2 rounded-sm px-1 py-0.5">
<span className="text-muted-foreground">{label}</span>
<span className={cn('min-w-0 truncate text-foreground/90', mono && 'font-mono')}>
{value}
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
draggable={false}
onClick={handleCopy}
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyDetailValue',
'Copy {{value0}}',
{ value0: copyLabel }
)}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCopyId}>
className="size-5 text-muted-foreground hover:text-foreground"
>
<Copy className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copySessionId',
'Copy Session ID'
'auto.components.right.sidebar.AiVaultSessionDetails.copyDetailValue',
'Copy {{value0}}',
{ value0: copyLabel }
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyPath}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyLogPath',
'Copy Log Path'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipContent>
</Tooltip>
</div>
)
}
@@ -344,22 +261,6 @@ function formatDateTime(value: string | null): string {
return new Date(timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
function previewRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string {
if (role === 'user') {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.user', 'User')
}
if (role === 'assistant') {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.assistant', 'Assistant')
}
if (role === 'tool') {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.tool', 'Tool')
}
if (role === 'system') {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.system', 'System')
}
return translate('auto.components.right.sidebar.AiVaultSessionDetails.log', 'Log')
}
function formatTimeAgo(timestamp: number): string {
const diffMs = Date.now() - timestamp
if (diffMs < 60_000) {
@@ -1,7 +1,14 @@
import type React from 'react'
import { Copy, FileJson, FolderOpen, Play } from 'lucide-react'
import { ChevronDown, Copy, FileJson, FolderOpen, MoreHorizontal, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardTrigger } from '@/components/ui/hover-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
ContextMenu,
ContextMenuContent,
@@ -19,18 +26,14 @@ import {
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import {
formatTokenCount,
MetaDot,
SessionActionsMenu,
SessionDetailsHoverCard,
SessionTime
} from './AiVaultSessionDetails'
import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails'
export function VaultSessionRow({
session,
resumeCommand,
detailsExpanded,
resumeDisabled,
onToggleDetails,
onResume,
onCopyResume,
onCopyId,
@@ -41,7 +44,9 @@ export function VaultSessionRow({
}: {
session: AiVaultSession
resumeCommand: string
detailsExpanded: boolean
resumeDisabled: boolean
onToggleDetails: () => void
onResume: () => void
onCopyResume: () => void
onCopyId: () => void
@@ -51,51 +56,62 @@ export function VaultSessionRow({
onOpenCwd?: () => void
}): React.JSX.Element {
const updatedAt = session.updatedAt ?? session.modifiedAt
const detailsId = getSessionDetailsId(session.id)
const detailsTooltip = detailsExpanded
? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details')
: translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details')
return (
<HoverCard openDelay={350} closeDelay={80}>
<ContextMenu>
<HoverCardTrigger asChild>
<ContextMenuTrigger asChild>
<div
draggable={!resumeDisabled}
className={cn(
'group relative flex min-h-[64px] w-full items-start border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
!resumeDisabled && 'cursor-grab active:cursor-grabbing'
)}
onDragStart={(event) => {
if (resumeDisabled) {
event.preventDefault()
return
}
writeAiVaultSessionDragData(event.dataTransfer, {
agent: session.agent,
sessionId: session.sessionId,
title: session.title,
command: resumeCommand
})
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT))
}}
onDragEnd={() => {
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}}
onDoubleClick={() => {
if (!resumeDisabled) {
onResume()
}
}}
>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-[13px] font-medium leading-5 text-foreground">
{session.title}
</div>
<SessionMetadata session={session} />
<ContextMenu>
<ContextMenuTrigger asChild>
<div
draggable={!resumeDisabled}
className={cn(
'group relative flex min-h-[64px] w-full flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
!resumeDisabled && 'cursor-grab active:cursor-grabbing'
)}
onDragStart={(event) => {
if (resumeDisabled) {
event.preventDefault()
return
}
writeAiVaultSessionDragData(event.dataTransfer, {
agent: session.agent,
sessionId: session.sessionId,
title: session.title,
command: resumeCommand
})
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT))
}}
onDragEnd={() => {
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}}
onDoubleClick={() => {
if (!resumeDisabled) {
onResume()
}
}}
>
<div className="min-w-0 flex-1 pr-24">
<div className="flex min-w-0 items-start gap-1.5">
<div className="min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-foreground">
{session.title}
</div>
<SessionTime
value={updatedAt}
className="absolute right-3 top-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
/>
<div className="pointer-events-none absolute right-2 top-1.5 flex items-center gap-1 rounded-md bg-sidebar/95 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<SessionTime value={updatedAt} className="mt-0.5 @max-[300px]/ai-vault:hidden" />
</div>
<SessionMetadata session={session} />
</div>
<div
className="pointer-events-none absolute right-2 top-1.5 flex items-center gap-1 rounded-md bg-sidebar/95"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
@@ -106,15 +122,82 @@ export function VaultSessionRow({
{ value0: agentLabel(session.agent) }
)}
disabled={resumeDisabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResume()
}}
className="opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
>
<Play className="size-3.5" />
</Button>
<SessionActionsMenu
session={session}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.toggleSessionDetails',
'{{value0}} session details',
{ value0: agentLabel(session.agent) }
)}
aria-expanded={detailsExpanded}
aria-controls={detailsId}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onToggleDetails()
}}
className="pointer-events-auto"
>
<ChevronDown
className={cn('size-3.5 transition-transform', detailsExpanded && 'rotate-180')}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{detailsTooltip}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.moreSessionActions',
'More Session Actions'
)}
draggable={false}
className="pointer-events-auto"
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.moreActions',
'More Actions'
)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<SessionActionMenuItems
resumeDisabled={resumeDisabled}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -122,103 +205,128 @@ export function VaultSessionRow({
onOpenLog={onOpenLog}
onRevealLog={onRevealLog}
onOpenCwd={onOpenCwd}
resumeDisabled={resumeDisabled}
/>
</div>
</div>
</ContextMenuTrigger>
</HoverCardTrigger>
<ContextMenuContent>
<ContextMenuItem disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
</ContextMenuItem>
<ContextMenuItem onSelect={onCopyResume}>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
'Copy Resume Command'
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
</ContextMenuItem>
<ContextMenuItem onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
</ContextMenuItem>
{onOpenCwd ? (
<ContextMenuItem onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
'Open Working Directory'
)}
</ContextMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{detailsExpanded ? (
<SessionInlineDetails
id={detailsId}
session={session}
resumeDisabled={resumeDisabled}
onResume={onResume}
onCopyResume={onCopyResume}
/>
) : null}
<ContextMenuSeparator />
<ContextMenuItem onSelect={onCopyId}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
'Copy Session ID'
)}
</ContextMenuItem>
<ContextMenuItem onSelect={onCopyPath}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyLogPath',
'Copy Log Path'
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SessionDetailsHoverCard session={session} resumeCommand={resumeCommand} />
</HoverCard>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<SessionActionMenuItems
menuKind="context"
resumeDisabled={resumeDisabled}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
onOpenLog={onOpenLog}
onRevealLog={onRevealLog}
onOpenCwd={onOpenCwd}
/>
</ContextMenuContent>
</ContextMenu>
)
}
function SessionActionMenuItems({
menuKind = 'dropdown',
resumeDisabled,
onResume,
onCopyResume,
onCopyId,
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd
}: {
menuKind?: 'dropdown' | 'context'
resumeDisabled: boolean
onResume: () => void
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
onOpenLog: () => void
onRevealLog: () => void
onOpenCwd?: () => void
}): React.JSX.Element {
const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem
const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
return (
<>
<Item disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
</Item>
<Item onSelect={onCopyResume}>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
'Copy Resume Command'
)}
</Item>
<Separator />
<Item onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
</Item>
<Item onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
</Item>
{onOpenCwd ? (
<Item onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
'Open Working Directory'
)}
</Item>
) : null}
<Separator />
<Item onSelect={onCopyId}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
'Copy Session ID'
)}
</Item>
<Item onSelect={onCopyPath}>
{translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
</Item>
</>
)
}
function getSessionDetailsId(sessionId: string): string {
return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}`
}
function SessionMetadata({ session }: { session: AiVaultSession }): React.JSX.Element {
return (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
{session.model ? (
<>
<MetaDot />
<span className="max-w-[92px] truncate">{session.model}</span>
</>
) : null}
{session.branch ? (
<>
<MetaDot />
<span className="min-w-0 truncate text-muted-foreground/85">{session.branch}</span>
</>
) : null}
<MetaDot />
<span className="shrink-0">
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
{session.totalTokens > 0 ? (
<>
<MetaDot />
<span className="shrink-0">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.tokenCount',
'{{value0}} tok',
{ value0: formatTokenCount(session.totalTokens) }
)}
</span>
</>
) : null}
</div>
)
}
@@ -1,5 +1,5 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -15,6 +15,7 @@ import {
} from './ai-vault-virtual-rows'
const VAULT_ROW_OVERSCAN = 8
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 360
type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
@@ -58,6 +59,7 @@ export function AiVaultSessionVirtualList({
const listScrollRef = useRef<HTMLDivElement>(null)
const stickyRangeStartIndexRef = useRef(0)
const activeStickyHeaderIndexRef = useRef<number | null>(null)
const [expandedSessionIds, setExpandedSessionIds] = useState<Set<string>>(() => new Set())
const vaultRows = useMemo(() => {
const rows: AiVaultListRow[] = []
@@ -77,8 +79,16 @@ export function AiVaultSessionVirtualList({
const virtualizer = useVirtualizer({
count: vaultRows.length,
getScrollElement: () => listScrollRef.current,
estimateSize: (index) =>
vaultRows[index]?.type === 'group' ? VAULT_GROUP_HEADER_ROW_HEIGHT : VAULT_SESSION_ROW_HEIGHT,
estimateSize: (index) => {
const row = vaultRows[index]
if (row?.type === 'group') {
return VAULT_GROUP_HEADER_ROW_HEIGHT
}
if (row?.type === 'session' && expandedSessionIds.has(row.session.id)) {
return VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT
}
return VAULT_SESSION_ROW_HEIGHT
},
overscan: VAULT_ROW_OVERSCAN,
// Why: keep the active group header mounted so CSS sticky can pin it while
// its sessions scroll underneath in the virtual list.
@@ -98,6 +108,18 @@ export function AiVaultSessionVirtualList({
}
})
const toggleSessionDetails = useCallback((sessionId: string) => {
setExpandedSessionIds((current) => {
const next = new Set(current)
if (next.has(sessionId)) {
next.delete(sessionId)
} else {
next.add(sessionId)
}
return next
})
}, [])
const virtualItems = virtualizer.getVirtualItems()
activeStickyHeaderIndexRef.current = getActiveStickyHeaderIndexForScroll({
rangeStartIndex: stickyRangeStartIndexRef.current,
@@ -139,9 +161,11 @@ export function AiVaultSessionVirtualList({
activeStickyHeaderIndex={activeStickyHeaderIndexRef.current}
measureElement={virtualizer.measureElement}
collapsedGroups={collapsedGroups}
expandedSessionIds={expandedSessionIds}
resumeDisabled={resumeDisabled}
buildResumeCommand={buildResumeCommand}
onToggleGroup={onToggleGroup}
onToggleSessionDetails={toggleSessionDetails}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -164,9 +188,11 @@ function AiVaultVirtualRow({
activeStickyHeaderIndex,
measureElement,
collapsedGroups,
expandedSessionIds,
resumeDisabled,
buildResumeCommand,
onToggleGroup,
onToggleSessionDetails,
onResume,
onCopyResume,
onCopyId,
@@ -181,9 +207,11 @@ function AiVaultVirtualRow({
activeStickyHeaderIndex: number | null
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
resumeDisabled: boolean
buildResumeCommand: (session: AiVaultSession) => string
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onResume: (session: AiVaultSession) => void
onCopyResume: (session: AiVaultSession) => void
onCopyId: (session: AiVaultSession) => void
@@ -218,7 +246,9 @@ function AiVaultVirtualRow({
<VaultSessionRow
session={row.session}
resumeCommand={buildResumeCommand(row.session)}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeDisabled}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onResume={() => onResume(row.session)}
onCopyResume={() => onCopyResume(row.session)}
onCopyId={() => onCopyId(row.session)}
+10 -1
View File
@@ -8703,11 +8703,14 @@
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"valueCopyFailed": "Unable to copy {{value0}}",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"agents": "Agents",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"sessionsShownCompact": "{{value0}} shown",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
@@ -8756,6 +8759,7 @@
"usageValue": "{{value0}} msgs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"sessionId": "Session ID",
"copyDetailValue": "Copy {{value0}}",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
@@ -8785,6 +8789,7 @@
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"toggleSessionDetails": "{{value0}} session details",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
@@ -8792,7 +8797,11 @@
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
"tokenCount": "{{value0}} tok",
"hideDetails": "Hide Details",
"showDetails": "Show Details",
"moreSessionActions": "More Session Actions",
"moreActions": "More Actions"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "Find files",
+13 -4
View File
@@ -8703,6 +8703,7 @@
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"valueCopyFailed": "No se pudo copiar {{value0}}",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
@@ -8716,8 +8717,10 @@
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
"sessionId": "ID de sesión",
"logPath": "Ruta del registro",
"agents": "Agentes",
"sessionsShownCompact": "{{value0}} mostradas"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -8780,7 +8783,8 @@
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
"yearsAgo": "{{value0}}y ago",
"sessionId": "ID de sesión"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
@@ -8792,7 +8796,12 @@
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
"tokenCount": "{{value0}} tok",
"toggleSessionDetails": "Detalles de sesión de {{value0}}",
"hideDetails": "Ocultar detalles",
"showDetails": "Mostrar detalles",
"moreSessionActions": "Más acciones de sesión",
"moreActions": "Más acciones"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "Buscar archivos",
+13 -4
View File
@@ -8703,6 +8703,7 @@
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"valueCopyFailed": "{{value0}} をコピーできません",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
@@ -8716,8 +8717,10 @@
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
"sessionId": "セッション ID",
"logPath": "ログパス",
"agents": "エージェント",
"sessionsShownCompact": "{{value0}} 件表示"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -8780,7 +8783,8 @@
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
"yearsAgo": "{{value0}}y ago",
"sessionId": "セッション ID"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
@@ -8792,7 +8796,12 @@
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
"tokenCount": "{{value0}} tok",
"toggleSessionDetails": "{{value0}} のセッション詳細",
"hideDetails": "詳細を非表示",
"showDetails": "詳細を表示",
"moreSessionActions": "セッションのその他の操作",
"moreActions": "その他の操作"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "ファイルを検索",
+13 -4
View File
@@ -8703,6 +8703,7 @@
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"valueCopyFailed": "{{value0}}을(를) 복사할 수 없습니다",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
@@ -8716,8 +8717,10 @@
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
"sessionId": "세션 ID",
"logPath": "로그 경로",
"agents": "에이전트",
"sessionsShownCompact": "{{value0}} 표시됨"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -8780,7 +8783,8 @@
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
"yearsAgo": "{{value0}}y ago",
"sessionId": "세션 ID"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
@@ -8792,7 +8796,12 @@
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
"tokenCount": "{{value0}} tok",
"toggleSessionDetails": "{{value0}} 세션 세부정보",
"hideDetails": "세부정보 숨기기",
"showDetails": "세부정보 표시",
"moreSessionActions": "세션 추가 작업",
"moreActions": "추가 작업"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "파일 찾기",
+13 -4
View File
@@ -8703,6 +8703,7 @@
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"valueCopyFailed": "无法复制 {{value0}}",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
@@ -8716,8 +8717,10 @@
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
"sessionId": "会话 ID",
"logPath": "日志路径",
"agents": "智能体",
"sessionsShownCompact": "已显示 {{value0}} 个"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -8780,7 +8783,8 @@
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
"yearsAgo": "{{value0}}y ago",
"sessionId": "会话 ID"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
@@ -8792,7 +8796,12 @@
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
"tokenCount": "{{value0}} tok",
"toggleSessionDetails": "{{value0}} 会话详情",
"hideDetails": "隐藏详情",
"showDetails": "显示详情",
"moreSessionActions": "更多会话操作",
"moreActions": "更多操作"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "查找文件",