import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { useUrlState } from '../hooks/useUrlState'; import { usePieceList } from '../hooks/usePieces'; import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api'; import { PieceEditor } from '../components/settings/PieceEditor'; import { splitPieces } from '../lib/splitPieces'; import { useBackdropClose } from '../lib/useBackdropClose'; type PieceSource = 'builtin' | 'user-custom' | 'global-custom'; /** Composite selection key so same-named builtin and custom rows are independently selectable. */ type SelectionKey = string; // `${name}::${source}` function makeKey(name: string, source: PieceSource): SelectionKey { return `${name}::${source}`; } function shortSha(sha: string | null): string { return sha ? sha.slice(0, 7) : '???????'; } function DriftBadge({ drift }: { drift: DriftStatus }) { const { t } = useTranslation('pieces'); const [open, setOpen] = useState(false); const ref = useRef(null); // Close popover when clicking outside. useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open]); return (
{open && (
{t('drift.title')}
{t('drift.forkedAt')} {shortSha(drift.forkedFromCommit)}
{t('drift.currentBuiltin')} {shortSha(drift.latestCommit)}

{t('drift.reviewHint')}

)}
); } type ShowToast = (message: string, variant?: 'success' | 'error') => void; interface PieceRowProps { p: PieceSummary; activeKey?: SelectionKey; isBuiltin: boolean; isAdmin: boolean; onSelectPiece: (name: string, source: PieceSource) => void; startDuplicate: (name: string, source: PieceSource) => void; } function PieceRow({ p, activeKey, isBuiltin, isAdmin, onSelectPiece, startDuplicate }: PieceRowProps) { const { t } = useTranslation('pieces'); // For Default pieces: // - admins: show Duplicate (hover) // - non-admins: show Duplicate always (it's their only action) // For Custom pieces: existing behavior (Duplicate on hover) const duplicateAlwaysVisible = isBuiltin && !isAdmin; const src = (p.source ?? (isBuiltin ? 'builtin' : 'user-custom')) as PieceSource; const thisKey = makeKey(p.name, src); return (
{p.drift?.drifted && }
); } function PiecesSidebar({ activeKey, onSelectPiece, isAdmin, showToast, }: { activeKey?: SelectionKey; onSelectPiece: (name: string, source: PieceSource) => void; isAdmin: boolean; showToast?: ShowToast; }) { const { t } = useTranslation('pieces'); const { data: pieces } = usePieceList(); const queryClient = useQueryClient(); const [isCreating, setIsCreating] = useState(false); const [newName, setNewName] = useState(''); const [creating, setCreating] = useState(false); // Inline duplicate dialog state — replaces window.prompt. // Track both the name and source of the piece being duplicated. const [duplicateTarget, setDuplicateTarget] = useState<{ name: string; source: PieceSource } | null>(null); const [duplicateName, setDuplicateName] = useState(''); const [duplicateError, setDuplicateError] = useState(null); const [duplicating, setDuplicating] = useState(false); const duplicateBackdrop = useBackdropClose(() => cancelDuplicate()); const notifyError = (label: string, err: unknown) => { const msg = `${label}: ${err instanceof Error ? err.message : String(err)}`; if (showToast) showToast(msg, 'error'); else console.error(msg); }; const handleCreate = async () => { const name = newName.trim(); if (!name || creating) return; const defaultPiece: PieceDef = { name, description: '', max_movements: 25, initial_movement: 'execute', movements: [{ name: 'execute', persona: 'worker', instruction: '', // Terminal moves go through the `complete` tool, not rules[].next // (rules[].next only accepts other movement names / WAIT_SUBTASKS — // see docs/movement-control-flow-tools.md). default_next is the // engine-internal fallback and is the only place COMPLETE is valid. default_next: 'COMPLETE', rules: [], }], }; try { setCreating(true); const { source } = await createPiece(defaultPiece); await queryClient.invalidateQueries({ queryKey: ['pieces'] }); setIsCreating(false); setNewName(''); onSelectPiece(name, source); } catch (e) { notifyError(t('create.failed'), e); } finally { setCreating(false); } }; const startDuplicate = (name: string, source: PieceSource) => { setDuplicateTarget({ name, source }); setDuplicateName(`${name}-copy`); setDuplicateError(null); }; const cancelDuplicate = () => { setDuplicateTarget(null); setDuplicateName(''); setDuplicateError(null); }; const submitDuplicate = async () => { if (!duplicateTarget || duplicating) return; const name = duplicateName.trim(); if (!name) { setDuplicateError(t('duplicate.nameRequired')); return; } if (!/^[a-z0-9-]+$/.test(name)) { setDuplicateError(t('duplicate.nameInvalid')); return; } try { setDuplicating(true); setDuplicateError(null); // Pass the source so we fetch from the SPECIFIC source (Fix 2). const { piece: pieceData } = await fetchPiece(duplicateTarget.name, duplicateTarget.source); const { source } = await createPiece({ ...pieceData, name }); await queryClient.invalidateQueries({ queryKey: ['pieces'] }); cancelDuplicate(); onSelectPiece(name, source); } catch (e) { const msg = e instanceof Error ? e.message : 'Failed to duplicate piece'; setDuplicateError(msg); } finally { setDuplicating(false); } }; const { defaults, customs } = splitPieces(pieces ?? []); return (
{/* Default Pieces section */}
Default Pieces
{defaults.length === 0 && (
(none)
)} {defaults.map(p => ( ))} {/* Custom Pieces section */}
Custom Pieces
{isCreating && (
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) void handleCreate(); if (e.key === 'Escape') { setIsCreating(false); setNewName(''); } }} disabled={creating} placeholder="piece-name" className="w-full h-7 px-2 text-xs border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent transition-shadow" />
)} {customs.length === 0 && !isCreating && (
(none)
)} {customs.map(p => ( ))} {duplicateTarget && (
e.stopPropagation()} >
{t('duplicate.title', { name: duplicateTarget.name })}
{ setDuplicateName(e.target.value); setDuplicateError(null); }} onKeyDown={e => { if (e.key === 'Enter') void submitDuplicate(); if (e.key === 'Escape') cancelDuplicate(); }} disabled={duplicating} className="w-full h-8 px-2 text-xs border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent" /> {duplicateError && (
{duplicateError}
)}
)}
); } interface PiecesPageProps { showToast?: ShowToast; isAdmin?: boolean; } export function PiecesPage({ showToast, isAdmin = true }: PiecesPageProps = {}) { const { t } = useTranslation('pieces'); const { urlState, setUrlState } = useUrlState(); const piece = urlState.piece; // pieceSource is persisted in the URL so reload correctly restores both name+source. const selectedSource: PieceSource | undefined = urlState.pieceSource; // モバイルでは list / detail のどちらかを全幅で表示。 // URL に piece が指定されていれば detail から、そうでなければ list から。 const [mobileView, setMobileView] = useState<'list' | 'detail'>(piece ? 'detail' : 'list'); // 直接 URL から piece が変わった時 (例: 別タブからシェアされた link) は detail に追従。 useEffect(() => { if (piece) setMobileView('detail'); }, [piece]); const handleSelectPiece = (name: string, source: PieceSource) => { setUrlState(prev => ({ ...prev, piece: name, pieceSource: source })); setMobileView('detail'); }; // Composite key for sidebar highlight. const activeKey: SelectionKey | undefined = piece && selectedSource ? makeKey(piece, selectedSource) : undefined; return (
{piece && ( )}
{piece ? ( ) : (
{t('page.selectPrompt')}
)}
); }