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