321 lines
12 KiB
TypeScript
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="複製"
|
|
>
|
|
⎘
|
|
</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>
|
|
);
|
|
}
|