maestro/ui/src/pages/PiecesPage.tsx
oss-sync caa0d03900
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (fd190dc)
2026-06-06 00:39:26 +00:00

403 lines
15 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, PieceSummary } from '../api';
import { PieceEditor } from '../components/settings/PieceEditor';
import { splitPieces } from '../lib/splitPieces';
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 [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 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-500/25 transition-colors leading-none border border-amber-300 dark:border-amber-500/30"
>
updated
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-52 rounded-md border border-hairline bg-canvas 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 dark:bg-amber-500/15 px-1 rounded text-amber-800 dark:text-amber-300">
{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;
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) {
// 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 (
<div key={thisKey} className="group flex items-center mb-0.5 gap-1 pr-1">
<button onClick={() => onSelectPiece(p.name, src)}
className={`flex-1 text-left px-2 py-1 rounded text-xs transition-colors min-w-0 truncate ${
activeKey === thisKey
? '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, src); }}
className={`text-slate-400 hover:text-slate-700 text-xs px-1.5 transition-opacity flex-shrink-0 ${
duplicateAlwaysVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
title="複製"
>
&#x2398;
</button>
</div>
);
}
function PiecesSidebar({
activeKey,
onSelectPiece,
isAdmin,
showToast,
}: {
activeKey?: SelectionKey;
onSelectPiece: (name: string, source: PieceSource) => void;
isAdmin: boolean;
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.
// 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<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);
const { source } = await createPiece(defaultPiece);
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
setIsCreating(false);
setNewName('');
onSelectPiece(name, source);
} catch (e) {
notifyError('Piece の作成に失敗', 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('複製名を入力してください');
return;
}
if (!/^[a-z0-9-]+$/.test(name)) {
setDuplicateError('Piece 名は英小文字・数字・ハイフンのみ使用できます');
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 (
<div className="h-full overflow-y-auto border-r border-hairline bg-canvas p-3">
{/* Default Pieces section */}
<div className="flex items-center justify-between mb-2 px-2">
<span className="section-label">Default Pieces</span>
</div>
{defaults.length === 0 && (
<div className="px-2 text-xs text-slate-400 mb-2">(none)</div>
)}
{defaults.map(p => (
<PieceRow
key={`builtin-${p.name}`}
p={p}
activeKey={activeKey}
isBuiltin={true}
isAdmin={isAdmin}
onSelectPiece={onSelectPiece}
startDuplicate={startDuplicate}
/>
))}
{/* Custom Pieces section */}
<div className="flex items-center justify-between mt-3 mb-2 px-2">
<span className="section-label">Custom 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>
)}
{customs.length === 0 && !isCreating && (
<div className="px-2 text-xs text-slate-400">(none)</div>
)}
{customs.map(p => (
<PieceRow
key={`custom-${p.name}`}
p={p}
activeKey={activeKey}
isBuiltin={false}
isAdmin={isAdmin}
onSelectPiece={onSelectPiece}
startDuplicate={startDuplicate}
/>
))}
{duplicateTarget && (
<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-canvas p-4 shadow-xl"
onClick={e => e.stopPropagation()}
>
<div id="dup-piece-label" className="text-[13px] font-semibold text-slate-800 mb-2">
"{duplicateTarget.name}"
</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 dark:text-red-300">{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;
isAdmin?: boolean;
}
export function PiecesPage({ showToast, isAdmin = true }: PiecesPageProps = {}) {
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 (
<div className="flex h-full">
<div
className={`${mobileView === 'list' ? 'block' : 'hidden'} md:block w-full md:w-52 flex-shrink-0`}
>
<PiecesSidebar
activeKey={activeKey}
onSelectPiece={handleSelectPiece}
isAdmin={isAdmin}
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} isAdmin={isAdmin} source={selectedSource} />
) : (
<div className="text-sm text-slate-400"> Piece </div>
)}
</div>
</div>
</div>
);
}