import { useEffect, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useUrlState } from '../hooks/useUrlState'; import { usePieceList } from '../hooks/usePieces'; import { createPiece, fetchPiece, PieceDef, DriftStatus } from '../api'; import { PieceEditor } from '../components/settings/PieceEditor'; function shortSha(sha: string | null): string { return sha ? sha.slice(0, 7) : '???????'; } function DriftBadge({ drift }: { drift: DriftStatus }) { 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 && (
組み込み Piece が更新されました
フォーク時点 {shortSha(drift.forkedFromCommit)}
現在の組み込み {shortSha(drift.latestCommit)}

組み込み Piece の改善点を確認してマージを検討してください。

)}
); } type ShowToast = (message: string, variant?: 'success' | 'error') => void; function PiecesSidebar({ activePiece, onSelectPiece, showToast, }: { activePiece?: string; onSelectPiece: (name: string) => void; showToast?: ShowToast; }) { 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 const [duplicateSource, setDuplicateSource] = useState(null); const [duplicateName, setDuplicateName] = useState(''); const [duplicateError, setDuplicateError] = useState(null); const [duplicating, setDuplicating] = useState(false); 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', edit: true, persona: 'worker', instruction: '', allowed_tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], default_next: 'COMPLETE', rules: [{ condition: '完了', next: 'COMPLETE' }], }], }; try { setCreating(true); await createPiece(defaultPiece); await queryClient.invalidateQueries({ queryKey: ['pieces'] }); setIsCreating(false); setNewName(''); onSelectPiece(name); } catch (e) { notifyError('Piece の作成に失敗', e); } finally { setCreating(false); } }; const startDuplicate = (sourceName: string) => { setDuplicateSource(sourceName); setDuplicateName(`${sourceName}-copy`); setDuplicateError(null); }; const cancelDuplicate = () => { setDuplicateSource(null); setDuplicateName(''); setDuplicateError(null); }; const submitDuplicate = async () => { if (!duplicateSource || duplicating) return; const name = duplicateName.trim(); if (!name) { setDuplicateError('複製名を入力してください'); return; } if (!/^[a-z0-9-]+$/.test(name)) { setDuplicateError('Piece 名は英小文字・数字・ハイフンのみ使用できます'); return; } try { setDuplicating(true); setDuplicateError(null); const source = await fetchPiece(duplicateSource); await createPiece({ ...source, name }); await queryClient.invalidateQueries({ queryKey: ['pieces'] }); cancelDuplicate(); onSelectPiece(name); } catch (e) { const msg = e instanceof Error ? e.message : 'Failed to duplicate piece'; setDuplicateError(msg); } finally { setDuplicating(false); } }; return (
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" />
)} {(pieces ?? []).map(p => (
{p.drift?.drifted && }
))} {duplicateSource && (
e.stopPropagation()} >
"{duplicateSource}" を複製
{ 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; } export function PiecesPage({ showToast }: PiecesPageProps = {}) { const { urlState, setUrlState } = useUrlState(); const piece = urlState.piece; // モバイルでは 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) => { setUrlState(prev => ({ ...prev, piece: name })); setMobileView('detail'); }; return (
{piece && ( )}
{piece ? ( ) : (
左から Piece を選択してください。
)}
); }