maestro/ui/src/pages/PiecesPage.tsx
2026-06-03 05:08:00 +00:00

321 lines
12 KiB
TypeScript

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<HTMLDivElement>(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 (
<div ref={ref} className="relative flex-shrink-0">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setOpen(p => !p); }}
title="組み込み Piece がフォーク後に更新されました"
className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-amber-100 text-amber-800 hover:bg-amber-200 transition-colors leading-none border border-amber-300"
>
updated
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-52 rounded-md border border-hairline bg-white shadow-lg p-2.5 text-2xs text-slate-700">
<div className="font-semibold text-slate-800 mb-1.5"> Piece </div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-slate-500"></span>
<code className="font-mono text-[10px] bg-slate-100 px-1 rounded">
{shortSha(drift.forkedFromCommit)}
</code>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-slate-500"></span>
<code className="font-mono text-[10px] bg-amber-50 px-1 rounded text-amber-800">
{shortSha(drift.latestCommit)}
</code>
</div>
</div>
<p className="mt-2 text-[10px] text-slate-400 leading-snug">
Piece
</p>
</div>
)}
</div>
);
}
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<string | null>(null);
const [duplicateName, setDuplicateName] = useState('');
const [duplicateError, setDuplicateError] = useState<string | null>(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 (
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
<div className="flex items-center justify-between mb-2 px-2">
<span className="section-label">Pieces</span>
<button
onClick={() => setIsCreating(true)}
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
title="新しい Piece"
>
+
</button>
</div>
{isCreating && (
<div className="mb-1 px-2">
<input
autoFocus
value={newName}
onChange={e => 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"
/>
</div>
)}
{(pieces ?? []).map(p => (
<div key={p.name} className="group flex items-center mb-0.5 gap-1 pr-1">
<button onClick={() => onSelectPiece(p.name)}
className={`flex-1 text-left px-2 py-1 rounded text-xs transition-colors min-w-0 truncate ${
activePiece === p.name
? 'bg-accent-soft text-accent font-semibold'
: 'text-slate-700 hover:bg-surface'
}`}>
{p.name}
</button>
{p.drift?.drifted && <DriftBadge drift={p.drift} />}
<button
onClick={(e) => { e.stopPropagation(); startDuplicate(p.name); }}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 text-xs px-1.5 transition-opacity flex-shrink-0"
title="複製"
>
&#x2398;
</button>
</div>
))}
{duplicateSource && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dup-piece-label"
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 px-4"
onClick={cancelDuplicate}
>
<div
className="w-full max-w-sm rounded-lg border border-hairline bg-white p-4 shadow-xl"
onClick={e => e.stopPropagation()}
>
<div id="dup-piece-label" className="text-[13px] font-semibold text-slate-800 mb-2">
"{duplicateSource}"
</div>
<label className="block text-2xs font-medium text-slate-500 mb-1"></label>
<input
autoFocus
value={duplicateName}
onChange={e => { 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 && (
<div role="alert" className="mt-2 text-2xs text-red-700">{duplicateError}</div>
)}
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={cancelDuplicate}
disabled={duplicating}
className="px-3 py-1 text-xs rounded border border-hairline text-slate-700 hover:bg-surface-2 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void submitDuplicate()}
disabled={duplicating || !duplicateName.trim()}
className="px-3 py-1 text-xs rounded bg-accent text-white hover:bg-accent-hover disabled:opacity-50"
>
{duplicating ? '複製中…' : '複製'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
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 (
<div className="flex h-full">
<div
className={`${mobileView === 'list' ? 'block' : 'hidden'} md:block w-full md:w-52 flex-shrink-0`}
>
<PiecesSidebar activePiece={piece} onSelectPiece={handleSelectPiece} showToast={showToast} />
</div>
<div
className={`${mobileView === 'detail' ? 'flex' : 'hidden'} md:flex flex-1 flex-col overflow-y-auto`}
>
{piece && (
<button
type="button"
onClick={() => setMobileView('list')}
className="md:hidden flex items-center gap-1 px-4 py-2 text-xs text-slate-600 hover:text-slate-900 border-b border-hairline"
>
<span aria-hidden></span>
<span>Piece </span>
</button>
)}
<div className="flex-1 p-6">
{piece ? (
<PieceEditor name={piece} />
) : (
<div className="text-sm text-slate-400"> Piece </div>
)}
</div>
</div>
</div>
);
}