import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'; import { ChevronDown, ChevronRight, Copy, Edit2, FileJson, LoaderCircle } from 'lucide-react'; import { toast } from 'react-toastify'; import { AppEvents } from '../constants/events'; import { defineMessages, useIntl } from '../i18n'; import { acpExportSession, acpForkSession, acpRenameSession } from '../acp/sessions'; import { getSessionDisplayName } from '../sessions'; import type { Session } from '../types/session'; import { errorMessage } from '../utils/conversionUtils'; import { cn } from '../utils'; import { Button } from './ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu'; const i18n = defineMessages({ actionsLabel: { id: 'sessionActionsHeader.actionsLabel', defaultMessage: 'Session actions', }, renameSession: { id: 'sessionActionsHeader.renameSession', defaultMessage: 'Rename session', }, duplicateSession: { id: 'sessionActionsHeader.duplicateSession', defaultMessage: 'Duplicate session', }, viewJson: { id: 'sessionActionsHeader.viewJson', defaultMessage: 'View session JSON', }, renameTitle: { id: 'sessionActionsHeader.renameTitle', defaultMessage: 'Rename Session', }, renamePlaceholder: { id: 'sessionActionsHeader.renamePlaceholder', defaultMessage: 'Enter session name', }, cancel: { id: 'sessionActionsHeader.cancel', defaultMessage: 'Cancel', }, save: { id: 'sessionActionsHeader.save', defaultMessage: 'Save', }, saving: { id: 'sessionActionsHeader.saving', defaultMessage: 'Saving...', }, renamed: { id: 'sessionActionsHeader.renamed', defaultMessage: 'Session renamed', }, renameFailed: { id: 'sessionActionsHeader.renameFailed', defaultMessage: 'Failed to rename session: {error}', }, duplicated: { id: 'sessionActionsHeader.duplicated', defaultMessage: 'Session duplicated', }, duplicateFailed: { id: 'sessionActionsHeader.duplicateFailed', defaultMessage: 'Failed to duplicate session: {error}', }, jsonTitle: { id: 'sessionActionsHeader.jsonTitle', defaultMessage: 'Session JSON', }, loadingJson: { id: 'sessionActionsHeader.loadingJson', defaultMessage: 'Loading JSON...', }, jsonFailed: { id: 'sessionActionsHeader.jsonFailed', defaultMessage: 'Failed to load session JSON: {error}', }, close: { id: 'sessionActionsHeader.close', defaultMessage: 'Close', }, copyJson: { id: 'sessionActionsHeader.copyJson', defaultMessage: 'Copy JSON', }, copiedJson: { id: 'sessionActionsHeader.copiedJson', defaultMessage: 'Session JSON copied', }, fullTextTitle: { id: 'sessionActionsHeader.fullTextTitle', defaultMessage: 'Text value', }, copyText: { id: 'sessionActionsHeader.copyText', defaultMessage: 'Copy text', }, copiedText: { id: 'sessionActionsHeader.copiedText', defaultMessage: 'Text copied', }, }); const LONG_STRING_THRESHOLD = 180; const STRING_PREVIEW_START = 96; const STRING_PREVIEW_END = 56; interface SessionActionsHeaderProps { session?: Session; onSessionChange: (updater: (session: Session) => Session) => void; className?: string; } interface ParsedSessionJson { value: unknown; pretty: string; } interface FullTextSelection { path: string; value: string; } function parseSessionJson(json: string): ParsedSessionJson { const value = JSON.parse(json) as unknown; return { value, pretty: JSON.stringify(value, null, 2), }; } function isJsonRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function getNodePath(parentPath: string, key: string, isArrayItem: boolean): string { if (isArrayItem) { return `${parentPath}[${key}]`; } return /^[A-Za-z_$][\w$]*$/.test(key) ? `${parentPath}.${key}` : `${parentPath}[${JSON.stringify(key)}]`; } function getStringPreview(value: string): string { if (value.length <= LONG_STRING_THRESHOLD) { return JSON.stringify(value); } return JSON.stringify( `${value.slice(0, STRING_PREVIEW_START)} ... ${value.slice(-STRING_PREVIEW_END)}` ); } function JsonPrimitiveValue({ value, path, onOpenText, }: { value: unknown; path: string; onOpenText: (selection: FullTextSelection) => void; }) { if (typeof value === 'string') { const isLong = value.length > LONG_STRING_THRESHOLD; const preview = getStringPreview(value); if (isLong) { return ( ); } return ( {preview} ); } if (typeof value === 'number') { return {value}; } if (typeof value === 'boolean') { return {String(value)}; } if (value === null) { return null; } return {String(value)}; } function JsonTreeNode({ label, value, depth, path, isArrayItem = false, onOpenText, }: { label?: string; value: unknown; depth: number; path: string; isArrayItem?: boolean; onOpenText: (selection: FullTextSelection) => void; }) { const isArray = Array.isArray(value); const isRecord = isJsonRecord(value); const isContainer = isArray || isRecord; const [isOpen, setIsOpen] = useState(depth < 3); const labelNode = label === undefined ? null : ( {isArrayItem ? label : JSON.stringify(label)}: ); if (!isContainer) { return (
{labelNode}
); } const entries = isArray ? value.map((item, index) => [String(index), item] as const) : Object.entries(value); const openToken = isArray ? '[' : '{'; const closeToken = isArray ? ']' : '}'; const countLabel = isArray ? `${entries.length} ${entries.length === 1 ? 'item' : 'items'}` : `${entries.length} ${entries.length === 1 ? 'key' : 'keys'}`; return (
{isOpen && entries.length > 0 && (
{entries.map(([key, childValue]) => ( ))}
{closeToken}
)}
); } function JsonTree({ value, onOpenText, }: { value: unknown; onOpenText: (selection: FullTextSelection) => void; }) { return (
); } export default function SessionActionsHeader({ session, onSessionChange, className, }: SessionActionsHeaderProps) { const intl = useIntl(); const [isRenameOpen, setIsRenameOpen] = useState(false); const [renameValue, setRenameValue] = useState(''); const [isRenaming, setIsRenaming] = useState(false); const [isJsonOpen, setIsJsonOpen] = useState(false); const [jsonValue, setJsonValue] = useState(null); const [jsonText, setJsonText] = useState(''); const [isJsonLoading, setIsJsonLoading] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false); const [fullTextSelection, setFullTextSelection] = useState(null); const title = useMemo(() => (session ? getSessionDisplayName(session) : ''), [session]); useEffect(() => { if (session && isRenameOpen) { setRenameValue(getSessionDisplayName(session)); } }, [isRenameOpen, session]); const handleRename = useCallback(async () => { if (!session || isRenaming) return; const trimmedName = renameValue.trim(); if (!trimmedName) return; if (trimmedName === session.name) { setIsRenameOpen(false); return; } setIsRenaming(true); try { await acpRenameSession(session.id, trimmedName); onSessionChange((current) => ({ ...current, name: trimmedName, user_set_name: true })); window.dispatchEvent( new CustomEvent(AppEvents.SESSION_RENAMED, { detail: { sessionId: session.id, newName: trimmedName, userInitiated: true }, }) ); setIsRenameOpen(false); toast.success(intl.formatMessage(i18n.renamed)); } catch (error) { toast.error( intl.formatMessage(i18n.renameFailed, { error: errorMessage(error, 'Unknown error'), }) ); } finally { setIsRenaming(false); } }, [intl, isRenaming, onSessionChange, renameValue, session]); const handleDuplicate = useCallback(async () => { if (!session || isDuplicating) return; setIsDuplicating(true); try { await acpForkSession(session.id); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); toast.success(intl.formatMessage(i18n.duplicated)); } catch (error) { toast.error( intl.formatMessage(i18n.duplicateFailed, { error: errorMessage(error, 'Unknown error'), }) ); } finally { setIsDuplicating(false); } }, [intl, isDuplicating, session]); const handleViewJson = useCallback(async () => { if (!session) return; setIsJsonOpen(true); setJsonValue(null); setJsonText(''); setIsJsonLoading(true); try { const json = await acpExportSession(session.id); const parsed = parseSessionJson(json); setJsonValue(parsed.value); setJsonText(parsed.pretty); } catch (error) { setIsJsonOpen(false); toast.error( intl.formatMessage(i18n.jsonFailed, { error: errorMessage(error, 'Unknown error'), }) ); } finally { setIsJsonLoading(false); } }, [intl, session]); const handleCopyJson = useCallback(async () => { if (!jsonText) return; await navigator.clipboard.writeText(jsonText); toast.success(intl.formatMessage(i18n.copiedJson)); }, [intl, jsonText]); const handleCopyFullText = useCallback(async () => { if (!fullTextSelection) return; await navigator.clipboard.writeText(fullTextSelection.value); toast.success(intl.formatMessage(i18n.copiedText)); }, [fullTextSelection, intl]); const handleJsonOpenChange = useCallback((open: boolean) => { setIsJsonOpen(open); if (!open) { setFullTextSelection(null); } }, []); const handleRenameKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Enter') { void handleRename(); } }, [handleRename] ); if (!session) { return null; } return ( <>
setIsRenameOpen(true)}> {intl.formatMessage(i18n.renameSession)} void handleDuplicate()}> {isDuplicating ? ( ) : ( )} {intl.formatMessage(i18n.duplicateSession)} void handleViewJson()}> {isJsonLoading ? ( ) : ( )} {intl.formatMessage(i18n.viewJson)}
{intl.formatMessage(i18n.renameTitle)} setRenameValue(event.target.value)} onKeyDown={handleRenameKeyDown} placeholder={intl.formatMessage(i18n.renamePlaceholder)} className="w-full rounded-lg border border-border-primary bg-background-primary p-3 text-text-primary outline-none focus:ring-2 focus:ring-border-active" disabled={isRenaming} maxLength={200} autoFocus /> {intl.formatMessage(i18n.jsonTitle)}
{isJsonLoading ? (
{intl.formatMessage(i18n.loadingJson)}
) : (
)}
!open && setFullTextSelection(null)} > {intl.formatMessage(i18n.fullTextTitle)} {fullTextSelection && (
{fullTextSelection.path}
                {fullTextSelection.value}
              
)}
); }