sync: update from private repo (6fcb0d0)

This commit is contained in:
oss-sync
2026-06-04 03:03:12 +00:00
parent 21be01b699
commit 57685d995c
36 changed files with 2467 additions and 391 deletions
+1 -1
View File
@@ -399,7 +399,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
{page === 'pieces' && isAdmin && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} /></div>}
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
+18 -9
View File
@@ -358,8 +358,10 @@ export async function reloadConfig(): Promise<void> {
// --- Pieces ---
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; drift?: DriftStatus; requiredMcp?: string[] }
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] }
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }
export async function fetchPieces(): Promise<PieceSummary[]> {
const res = await fetch(`${BASE}/pieces`);
@@ -368,15 +370,17 @@ export async function fetchPieces(): Promise<PieceSummary[]> {
return data.pieces;
}
export async function fetchPiece(name: string): Promise<PieceDef> {
const res = await fetch(`${BASE}/pieces/${name}`);
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<PieceFetchResult> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
return data.piece;
return { piece: data.piece, source: data.source, ownerId: data.ownerId };
}
export async function updatePiece(name: string, piece: PieceDef): Promise<void> {
const res = await fetch(`${BASE}/pieces/${name}`, {
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(piece),
@@ -384,17 +388,22 @@ export async function updatePiece(name: string, piece: PieceDef): Promise<void>
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
}
export async function createPiece(piece: PieceDef): Promise<void> {
export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }
export async function createPiece(piece: PieceDef): Promise<PieceCreateResult> {
const res = await fetch(`${BASE}/pieces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
const d = await res.json();
return { source: d.source ?? 'user-custom' };
}
export async function deletePiece(name: string): Promise<void> {
const res = await fetch(`${BASE}/pieces/${name}`, { method: 'DELETE' });
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
}
@@ -5,6 +5,7 @@ import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfil
import { AttachmentDropzone } from './AttachmentDropzone';
import { ScheduleFields } from './ScheduleFields';
import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces';
import { useAuthState } from '../../App';
interface CreateTaskDialogProps {
@@ -80,7 +81,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
scheduledAt: '',
});
const selectedPiece = (pieces ?? []).find(p => p.name === form.piece);
const resolvedPieces = resolvePieceOptions(pieces ?? []);
const selectedPiece = resolvedPieces.find(p => p.name === form.piece);
const missingMcp = selectedPiece?.requiredMcp
? selectedPiece.requiredMcp.filter(
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
@@ -241,7 +243,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value="auto"></option>
{(pieces ?? []).map(p => (
{resolvedPieces.map(p => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces';
import { MarkdownText } from '../../lib/markdown-text';
interface PrevJobInfo {
@@ -123,7 +124,7 @@ export function ContinueWithPieceDialog({
disabled={piecesQuery.isLoading}
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
>
{(piecesQuery.data ?? []).map(p => (
{resolvePieceOptions(piecesQuery.data ?? []).map(p => (
<option key={p.name} value={p.name}>
{p.name}
{p.name === prevJob.pieceName ? ' (現在)' : ''}
+1 -1
View File
@@ -18,7 +18,7 @@ interface TopBarProps {
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
{ id: 'pieces', label: 'Pieces', adminOnly: false, requiresAuth: false },
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
@@ -7,9 +7,10 @@ export interface MovementAccordionProps {
onAdd: () => void;
onRemove: (index: number) => void;
onMove: (index: number, direction: 'up' | 'down') => void;
disabled?: boolean;
}
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove }: MovementAccordionProps) {
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove, disabled = false }: MovementAccordionProps) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const movementNames = movements.map((m) => m.name ?? '');
@@ -49,40 +50,42 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
{/* Spacer */}
<div className="flex-1" />
{/* Controls */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => onMove(i, 'up')}
disabled={i === 0}
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
title="Move up"
>
&#9650;
</button>
<button
type="button"
onClick={() => onMove(i, 'down')}
disabled={i === movements.length - 1}
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
title="Move down"
>
&#9660;
</button>
<button
type="button"
onClick={() => {
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
onRemove(i);
if (expandedIndex === i) setExpandedIndex(null);
}
}}
className="text-slate-400 hover:text-red-500 text-sm px-1"
title="Delete"
>
&times;
</button>
</div>
{/* Controls — hidden in read-only mode */}
{!disabled && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => onMove(i, 'up')}
disabled={i === 0}
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
title="Move up"
>
&#9650;
</button>
<button
type="button"
onClick={() => onMove(i, 'down')}
disabled={i === movements.length - 1}
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
title="Move down"
>
&#9660;
</button>
<button
type="button"
onClick={() => {
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
onRemove(i);
if (expandedIndex === i) setExpandedIndex(null);
}
}}
className="text-slate-400 hover:text-red-500 text-sm px-1"
title="Delete"
>
&times;
</button>
</div>
)}
</div>
{/* Expanded form */}
@@ -92,6 +95,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
movement={movement}
movementNames={movementNames}
onChange={(field, value) => onChange(i, field, value)}
disabled={disabled}
/>
</div>
)}
@@ -100,13 +104,15 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
})}
</div>
<button
type="button"
onClick={onAdd}
className="mt-3 text-sm text-blue-600 hover:text-blue-700"
>
+ Add Movement
</button>
{!disabled && (
<button
type="button"
onClick={onAdd}
className="mt-3 text-sm text-blue-600 hover:text-blue-700"
>
+ Add Movement
</button>
)}
</div>
);
}
+15 -6
View File
@@ -8,10 +8,12 @@ export interface MovementFormProps {
movement: any;
movementNames: string[];
onChange: (field: string, value: any) => void;
disabled?: boolean;
}
export function MovementForm({ movement, movementNames, onChange }: MovementFormProps) {
export function MovementForm({ movement, movementNames, onChange, disabled = false }: MovementFormProps) {
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
return (
<div className="space-y-4">
@@ -22,7 +24,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
type="text"
value={movement.name ?? ''}
onChange={(e) => onChange('name', e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
</div>
@@ -33,7 +36,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
type="text"
value={movement.persona ?? ''}
onChange={(e) => onChange('persona', e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
</div>
@@ -43,7 +47,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
<select
value={movement.default_next ?? 'COMPLETE'}
onChange={(e) => onChange('default_next', e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
>
{nextOptions.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
@@ -58,7 +63,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
id={`edit-${movement.name}`}
checked={movement.edit ?? false}
onChange={(e) => onChange('edit', e.target.checked)}
className="rounded border-slate-300"
disabled={disabled}
className="rounded border-slate-300 disabled:cursor-not-allowed"
/>
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
<HelpText> Write / Edit LLM </HelpText>
@@ -71,7 +77,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
value={movement.instruction ?? ''}
onChange={(e) => onChange('instruction', e.target.value)}
rows={6}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono ${disabledClass}`}
/>
<HelpText>LLM Markdown 使</HelpText>
</div>
@@ -80,6 +87,7 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
<ToolTagInput
value={movement.allowed_tools ?? []}
onChange={(tools) => onChange('allowed_tools', tools)}
disabled={disabled}
/>
{/* rules */}
@@ -87,6 +95,7 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
rules={movement.rules ?? []}
movementNames={movementNames.filter((n) => n !== movement.name)}
onChange={(rules) => onChange('rules', rules)}
disabled={disabled}
/>
</div>
);
+66 -34
View File
@@ -9,10 +9,22 @@ import { MovementAccordion } from './MovementAccordion';
export interface PieceEditorProps {
name: string;
/** Whether the current user has admin role. Controls edit access for built-in/global-custom pieces. */
isAdmin?: boolean;
/**
* Source hint from URL / list selection — used for the targeted GET request.
* The read-only gate uses the SOURCE the API actually resolved (authoritative),
* so a deep link without pieceSource still renders correctly for non-admins.
*/
source?: 'builtin' | 'user-custom' | 'global-custom';
}
export function PieceEditor({ name }: PieceEditorProps) {
const { data: piece, isLoading, error } = usePiece(name);
export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps) {
const { data: fetchResult, isLoading, error } = usePiece(name, source);
// Use the server-resolved source as the authoritative value; fall back to the
// prop only while the fetch hasn't completed yet (avoids flicker on known paths).
const piece = fetchResult?.piece ?? null;
const effectiveSource = fetchResult?.source ?? source;
const queryClient = useQueryClient();
const { setUrlState } = useUrlState();
@@ -33,7 +45,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
setEditMode('visual');
setYamlError(null);
}
}, [piece]);
}, [piece]); // piece is derived from fetchResult above
const showToast = (msg: string, duration = 2000) => {
setToast(msg);
@@ -159,7 +171,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
setSaving(true);
try {
await updatePiece(name, saveData);
await updatePiece(name, saveData, effectiveSource);
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
setIsDirty(false);
@@ -177,7 +189,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
const handleDelete = async () => {
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
try {
await deletePiece(name);
await deletePiece(name, effectiveSource);
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
} catch (e: any) {
@@ -189,6 +201,12 @@ export function PieceEditor({ name }: PieceEditorProps) {
if (error) return <div className="text-sm text-red-500">Piece </div>;
if (!draft) return null;
// Non-admins cannot edit built-in or global-custom pieces — read-only view.
// user-custom stays editable for its owner. Admins can edit all sources.
// Use the SERVER-RESOLVED source (effectiveSource) as the authoritative value so
// that deep links without a ?pieceSource param still render read-only for non-admins.
const readonly = !isAdmin && (effectiveSource === 'builtin' || effectiveSource === 'global-custom');
const movementNames = (draft.movements ?? []).map((m: any) => m.name ?? '');
return (
@@ -201,13 +219,21 @@ export function PieceEditor({ name }: PieceEditorProps) {
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2">{String(draft.description).split('\n')[0]}</p>
)}
</div>
<button
type="button"
onClick={handleDelete}
className="px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded-lg border border-red-200"
>
Delete
</button>
{/* Delete is hidden for ALL built-in pieces (non-deletable by everyone, including admins). */}
{!readonly && effectiveSource !== 'builtin' && (
<button
type="button"
onClick={handleDelete}
className="px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded-lg border border-red-200"
>
Delete
</button>
)}
{readonly && (
<span className="px-2 py-1 text-xs text-slate-400 bg-slate-100 rounded border border-slate-200">
</span>
)}
</div>
{/* Mode toggle */}
@@ -242,6 +268,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
piece={draft}
onChange={handleMetaChange}
movementNames={movementNames}
disabled={readonly}
/>
</div>
@@ -253,6 +280,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
onAdd={handleAddMovement}
onRemove={handleRemoveMovement}
onMove={handleMoveMovement}
disabled={readonly}
/>
</div>
</>
@@ -268,7 +296,9 @@ export function PieceEditor({ name }: PieceEditorProps) {
value={yamlText}
onChange={(e) => handleYamlChange(e.target.value)}
spellCheck={false}
className="w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono bg-slate-50 leading-relaxed resize-y"
readOnly={readonly}
disabled={readonly}
className={`w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono leading-relaxed resize-y ${readonly ? 'bg-slate-100 text-slate-500 cursor-not-allowed' : 'bg-slate-50'}`}
style={{ minHeight: '500px', tabSize: 2 }}
/>
<p className="text-xs text-slate-400 mt-1">
@@ -278,27 +308,29 @@ export function PieceEditor({ name }: PieceEditorProps) {
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
{toast && (
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
{toast}
</span>
)}
<button
onClick={handleDiscard}
disabled={!isDirty}
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg disabled:opacity-50"
>
Discard Changes
</button>
<button
onClick={handleSave}
disabled={!isDirty || saving}
className="px-4 py-2 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
{!readonly && (
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
{toast && (
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
{toast}
</span>
)}
<button
onClick={handleDiscard}
disabled={!isDirty}
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg disabled:opacity-50"
>
Discard Changes
</button>
<button
onClick={handleSave}
disabled={!isDirty || saving}
className="px-4 py-2 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
)}
</div>
);
}
+11 -5
View File
@@ -4,10 +4,12 @@ export interface PieceMetaFormProps {
piece: any;
onChange: (field: string, value: any) => void;
movementNames: string[];
disabled?: boolean;
}
export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormProps) {
export function PieceMetaForm({ piece, onChange, movementNames, disabled = false }: PieceMetaFormProps) {
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
return (
<div className="space-y-4">
@@ -30,7 +32,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
type="text"
value={piece.description ?? ''}
onChange={(e) => onChange('description', e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
</div>
@@ -42,7 +45,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
value={piece.max_movements ?? 10}
onChange={(e) => onChange('max_movements', parseInt(e.target.value, 10) || 0)}
min={1}
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
disabled={disabled}
className={`w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
<HelpText>1 movement </HelpText>
</div>
@@ -53,7 +57,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
<select
value={piece.initial_movement ?? ''}
onChange={(e) => onChange('initial_movement', e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
>
{movementNames.length === 0 && <option value="">--</option>}
{movementNames.map((name) => (
@@ -77,7 +82,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
onChange('triggers', { ...piece.triggers, keywords });
}}
placeholder="keyword1, keyword2, ..."
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
<HelpText> piece </HelpText>
</div>
+28 -20
View File
@@ -6,10 +6,12 @@ export interface RulesTableProps {
rules: Array<{ condition: string; next: string }>;
movementNames: string[];
onChange: (rules: Array<{ condition: string; next: string }>) => void;
disabled?: boolean;
}
export function RulesTable({ rules, movementNames, onChange }: RulesTableProps) {
export function RulesTable({ rules, movementNames, onChange, disabled = false }: RulesTableProps) {
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
const updateRule = (index: number, field: 'condition' | 'next', value: string) => {
const updated = rules.map((r, i) => (i === index ? { ...r, [field]: value } : r));
@@ -33,7 +35,7 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
<tr className="text-xs text-slate-500">
<th className="text-left font-medium pb-1 pr-2">condition</th>
<th className="text-left font-medium pb-1 pr-2 w-44">next</th>
<th className="w-8" />
{!disabled && <th className="w-8" />}
</tr>
</thead>
<tbody>
@@ -44,7 +46,8 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
type="text"
value={rule.condition}
onChange={(e) => updateRule(i, 'condition', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
disabled={disabled}
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
placeholder="条件..."
/>
</td>
@@ -52,34 +55,39 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
<select
value={rule.next}
onChange={(e) => updateRule(i, 'next', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
disabled={disabled}
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
>
{nextOptions.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</td>
<td className="pb-1">
<button
type="button"
onClick={() => removeRule(i)}
className="text-slate-400 hover:text-red-500 text-sm px-1"
>
&times;
</button>
</td>
{!disabled && (
<td className="pb-1">
<button
type="button"
onClick={() => removeRule(i)}
className="text-slate-400 hover:text-red-500 text-sm px-1"
>
&times;
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
<button
type="button"
onClick={addRule}
className="text-xs text-blue-600 hover:text-blue-700"
>
+ Add Rule
</button>
{!disabled && (
<button
type="button"
onClick={addRule}
className="text-xs text-blue-600 hover:text-blue-700"
>
+ Add Rule
</button>
)}
<HelpText>LLM transition </HelpText>
</div>
);
+31 -23
View File
@@ -6,6 +6,7 @@ import { HelpText } from './HelpText';
export interface ToolTagInputProps {
value: string[];
onChange: (tools: string[]) => void;
disabled?: boolean;
}
/**
@@ -24,7 +25,7 @@ export interface ToolTagInputProps {
* - Selecting an unavailable catalog tool is still allowed (the user might be
* preparing for a server that's about to come back online).
*/
export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInputProps) {
const { data: catalog } = useToolList();
const [input, setInput] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
@@ -127,24 +128,27 @@ export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
name={tool}
entry={entry}
onRemove={() => removeTool(tool)}
readOnly={disabled}
/>
);
})}
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setShowDropdown(true);
}}
onFocus={() => setShowDropdown(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
/>
{!disabled && (
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setShowDropdown(true);
}}
onFocus={() => setShowDropdown(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
/>
)}
</div>
{showDropdown && groupedSuggestions.length > 0 && (
{!disabled && showDropdown && groupedSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-slate-200 rounded-lg shadow-lg">
{groupedSuggestions.map((g) => (
<div key={g.key}>
@@ -202,10 +206,12 @@ function SelectedToolChip({
name,
entry,
onRemove,
readOnly = false,
}: {
name: string;
entry: ToolCatalogEntry | undefined;
onRemove: () => void;
readOnly?: boolean;
}) {
const isUnknown = !entry;
const isUnavailable = entry ? !entry.available : false;
@@ -231,14 +237,16 @@ function SelectedToolChip({
{entry && <ScopeBadge scope={entry.scope} dim />}
{isUnknown && <Badge color="amber">unknown</Badge>}
{!isUnknown && isUnavailable && <Badge color="amber">{entry?.reason ?? 'offline'}</Badge>}
<button
type="button"
onClick={onRemove}
className="text-current opacity-60 hover:opacity-100"
aria-label={`remove ${name}`}
>
&times;
</button>
{!readOnly && (
<button
type="button"
onClick={onRemove}
className="text-current opacity-60 hover:opacity-100"
aria-label={`remove ${name}`}
>
&times;
</button>
)}
</span>
);
}
+23 -5
View File
@@ -49,19 +49,37 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
> このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。
## カスタム Piece を作る(admin / パワーユーザー)
## Default Pieces と Custom Pieces
Pieces ページでは「**Default Pieces**」と「**Custom Pieces**」の 2 つのセクションが表示されます。
**Default Pieces(組み込み)**
- システムに同梱され、git で管理されている Piece です
- **削除は非 admin ユーザーには禁止**されています(削除は admin のみ)
- **編集は admin のみ** 可能です(非 admin ユーザーは読み取り専用)
- 非 admin ユーザーは `⎘` ボタンで **「Custom に複製」** でき、別名の Custom Piece として自分用にカスタマイズできます
**Custom Pieces(ユーザー作成)**
- ユーザーが作成した Piece で、自分のユーザーフォルダ配下に保存されます
- 自分で作った Custom Piece は編集・削除できます
- Custom Piece は **Default Piece と同名にできません**(別名を付けてください)
- タスク実行時、Custom Piece はそのオーナーのユーザーフォルダから読み込まれ、正しく実行されます
## カスタム Piece を作る
### 方法 1: piece-builder に依頼する
タスクを作成し、「○○用の Piece を作って」と書きます。`piece-builder` が選ばれ、要件をヒアリングしながら movement 構成・ツール選定・遷移ルールを設計して Piece を保存します。既存 Piece の改良で済む場合はそれを優先します。
### 方法 2: Pieces ページで手動編集する
### 方法 2: Pieces ページで手動作成する
admin は TopBar → Pieces から Piece の一覧・閲覧・新規作成・編集ができます。YAML を直接編集して保存すると `pieces/{name}.yaml` に反映されます
TopBar → Pieces → Custom Pieces セクションの `+` から新規作成できます。YAML を直接編集して保存できます。**Default Piece と同名の名前は使えません**(別名を付けてください)
### 方法 3: 自分専用に fork する
### 方法 3: Default Piece を複製してカスタマイズする
組み込み Piece を少しだけ変えたい場合、ユーザーフォルダ配下に同名の Piece を置くと、自分のタスクではそちらが優先されます。組み込み定義はそのまま残ります。
Default Piece の行にある `⎘` ボタンをクリックすると「複製名」の入力ダイアログが開きます。別名を入力して複製すると、Custom Pieces セクションに追加されます。元の Default Piece はそのまま残ります。複製後の Custom Piece は自分で自由に編集できます。
## Piece を編集するときの勘所
+8 -3
View File
@@ -6,10 +6,15 @@ export function usePieceList() {
return useQuery({ queryKey: ['pieces'], queryFn: fetchPieces, staleTime: STALE_TIME.SEMI_STATIC });
}
export function usePiece(name: string | undefined) {
/**
* Fetches a single piece by name (and optional source).
* Returns the full PieceFetchResult so callers can use the server-resolved source
* for authorization decisions (e.g. read-only gate in PieceEditor).
*/
export function usePiece(name: string | undefined, source?: 'builtin' | 'user-custom' | 'global-custom') {
return useQuery({
queryKey: ['piece', name],
queryFn: () => fetchPiece(name!),
queryKey: ['piece', name, source],
queryFn: () => fetchPiece(name!, source),
enabled: !!name,
staleTime: STALE_TIME.SEMI_STATIC,
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { splitPieces, resolvePieceOptions } from './splitPieces';
import type { PieceSummary } from '../api';
function makePiece(name: string, source?: PieceSummary['source']): PieceSummary {
return { name, description: 'test', source };
}
describe('splitPieces', () => {
it('puts builtin pieces in defaults', () => {
const pieces = [makePiece('chat', 'builtin'), makePiece('general', 'builtin')];
const { defaults, customs } = splitPieces(pieces);
expect(defaults).toHaveLength(2);
expect(customs).toHaveLength(0);
});
it('puts user-custom pieces in customs', () => {
const pieces = [makePiece('my-piece', 'user-custom')];
const { defaults, customs } = splitPieces(pieces);
expect(defaults).toHaveLength(0);
expect(customs).toHaveLength(1);
expect(customs[0].name).toBe('my-piece');
});
it('puts global-custom pieces in customs', () => {
const pieces = [makePiece('global-piece', 'global-custom')];
const { defaults, customs } = splitPieces(pieces);
expect(defaults).toHaveLength(0);
expect(customs).toHaveLength(1);
});
it('splits mixed list correctly', () => {
const pieces = [
makePiece('chat', 'builtin'),
makePiece('general', 'builtin'),
makePiece('my-chat', 'user-custom'),
makePiece('chat', 'user-custom'), // same name as builtin, but still custom
];
const { defaults, customs } = splitPieces(pieces);
expect(defaults).toHaveLength(2);
expect(customs).toHaveLength(2);
expect(defaults.map(p => p.name)).toEqual(['chat', 'general']);
expect(customs.map(p => p.name)).toEqual(['my-chat', 'chat']);
});
it('handles pieces with undefined source as custom', () => {
const pieces = [makePiece('legacy')]; // no source
const { defaults, customs } = splitPieces(pieces);
expect(defaults).toHaveLength(0);
expect(customs).toHaveLength(1);
});
it('returns empty arrays for empty input', () => {
const { defaults, customs } = splitPieces([]);
expect(defaults).toHaveLength(0);
expect(customs).toHaveLength(0);
});
});
describe('resolvePieceOptions', () => {
it('returns unique names with no duplicates when all sources differ', () => {
const pieces = [makePiece('chat', 'builtin'), makePiece('my-tool', 'user-custom')];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(2);
expect(result.map(p => p.name).sort()).toEqual(['chat', 'my-tool']);
});
it('user-custom wins over builtin for the same name', () => {
const pieces = [makePiece('chat', 'builtin'), makePiece('chat', 'user-custom')];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(1);
expect(result[0].source).toBe('user-custom');
});
it('user-custom wins over global-custom for the same name', () => {
const pieces = [makePiece('chat', 'global-custom'), makePiece('chat', 'user-custom')];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(1);
expect(result[0].source).toBe('user-custom');
});
it('global-custom wins over builtin for the same name', () => {
const pieces = [makePiece('chat', 'builtin'), makePiece('chat', 'global-custom')];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(1);
expect(result[0].source).toBe('global-custom');
});
it('user-custom wins over both global-custom and builtin', () => {
const pieces = [
makePiece('chat', 'builtin'),
makePiece('chat', 'global-custom'),
makePiece('chat', 'user-custom'),
];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(1);
expect(result[0].source).toBe('user-custom');
});
it('de-duplicates only same-name entries, keeps different names', () => {
const pieces = [
makePiece('chat', 'builtin'),
makePiece('general', 'builtin'),
makePiece('chat', 'user-custom'),
makePiece('my-tool', 'user-custom'),
];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(3);
const byName = Object.fromEntries(result.map(p => [p.name, p]));
expect(byName['chat'].source).toBe('user-custom');
expect(byName['general'].source).toBe('builtin');
expect(byName['my-tool'].source).toBe('user-custom');
});
it('returns empty array for empty input', () => {
expect(resolvePieceOptions([])).toHaveLength(0);
});
it('undefined source is treated as builtin priority (lowest)', () => {
const pieces = [makePiece('chat', 'global-custom'), makePiece('chat', undefined)];
const result = resolvePieceOptions(pieces);
expect(result).toHaveLength(1);
expect(result[0].source).toBe('global-custom');
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { PieceSummary } from '../api';
export interface SplitPieces {
defaults: PieceSummary[];
customs: PieceSummary[];
}
/**
* Split a flat list of PieceSummary into Default (built-in) and Custom sections.
* Pieces with source === 'builtin' go to defaults; all others go to customs.
*/
export function splitPieces(pieces: PieceSummary[]): SplitPieces {
const defaults: PieceSummary[] = [];
const customs: PieceSummary[] = [];
for (const p of pieces) {
if (p.source === 'builtin') {
defaults.push(p);
} else {
customs.push(p);
}
}
return { defaults, customs };
}
/**
* De-duplicate a flat list of PieceSummary by name, keeping the highest-priority
* source for each name — mirroring the executor's resolution order:
* user-custom > global-custom > builtin
*
* Use this wherever a piece is SELECTED TO RUN (task creation, scheduled tasks,
* continue-with-piece dialogs). The result matches what the executor will actually
* run, so the user sees exactly one "chat" entry rather than two.
*
* Do NOT use this in the management/PiecesPage view — that view intentionally
* shows both the builtin and any same-named custom side-by-side.
*/
export function resolvePieceOptions(pieces: PieceSummary[]): PieceSummary[] {
const PRIORITY: Record<string, number> = {
'user-custom': 0,
'global-custom': 1,
builtin: 2,
};
const seen = new Map<string, PieceSummary>();
for (const p of pieces) {
const existing = seen.get(p.name);
const pPrio = PRIORITY[p.source ?? 'builtin'] ?? 2;
if (!existing) {
seen.set(p.name, p);
} else {
const existingPrio = PRIORITY[existing.source ?? 'builtin'] ?? 2;
if (pPrio < existingPrio) {
seen.set(p.name, p);
}
}
}
return Array.from(seen.values());
}
+9
View File
@@ -78,6 +78,8 @@ export interface UiUrlState {
taskId: number | null;
section?: SettingsSection;
piece?: string;
/** Source of the currently-selected piece ('builtin' | 'user-custom' | 'global-custom'). */
pieceSource?: 'builtin' | 'user-custom' | 'global-custom';
/** Active SideInfoPanel widget slug. Default: 'worker-status'. */
dashboardWidget?: string;
/** Selected help section id. Only meaningful when page === 'help'. */
@@ -107,6 +109,11 @@ export function readUiUrlState(): UiUrlState {
const taskId = Number(params.get('task') ?? '');
const section = params.get('section');
const piece = params.get('piece');
const pieceSourceRaw = params.get('pieceSource');
const PIECE_SOURCES = ['builtin', 'user-custom', 'global-custom'] as const;
const pieceSource = PIECE_SOURCES.includes(pieceSourceRaw as any)
? (pieceSourceRaw as UiUrlState['pieceSource'])
: undefined;
const dashboardWidget = params.get('dashboardWidget');
const help = params.get('help');
@@ -127,6 +134,7 @@ export function readUiUrlState(): UiUrlState {
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
section: section && SETTINGS_SECTIONS.includes(section as SettingsSection) ? section as SettingsSection : undefined,
piece: piece || undefined,
...(pieceSource ? { pieceSource } : {}),
...(dashboardWidget ? { dashboardWidget } : {}),
...(help ? { help } : {}),
};
@@ -144,6 +152,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
if (state.taskId) params.set('task', String(state.taskId));
if (state.section) params.set('section', state.section);
if (state.piece) params.set('piece', state.piece);
if (state.pieceSource) params.set('pieceSource', state.pieceSource);
if (state.help) params.set('help', state.help);
if (state.dashboardWidget && state.dashboardWidget !== 'worker-status') {
params.set('dashboardWidget', state.dashboardWidget);
+125 -43
View File
@@ -2,8 +2,15 @@ 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 { 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) : '???????';
@@ -64,13 +71,57 @@ function DriftBadge({ drift }: { drift: DriftStatus }) {
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({
activePiece,
activeKey,
onSelectPiece,
isAdmin,
showToast,
}: {
activePiece?: string;
onSelectPiece: (name: string) => void;
activeKey?: SelectionKey;
onSelectPiece: (name: string, source: PieceSource) => void;
isAdmin: boolean;
showToast?: ShowToast;
}) {
const { data: pieces } = usePieceList();
@@ -79,8 +130,9 @@ function PiecesSidebar({
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
// Inline duplicate dialog state — replaces window.prompt
const [duplicateSource, setDuplicateSource] = useState<string | null>(null);
// 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);
@@ -111,11 +163,11 @@ function PiecesSidebar({
};
try {
setCreating(true);
await createPiece(defaultPiece);
const { source } = await createPiece(defaultPiece);
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
setIsCreating(false);
setNewName('');
onSelectPiece(name);
onSelectPiece(name, source);
} catch (e) {
notifyError('Piece の作成に失敗', e);
} finally {
@@ -123,20 +175,20 @@ function PiecesSidebar({
}
};
const startDuplicate = (sourceName: string) => {
setDuplicateSource(sourceName);
setDuplicateName(`${sourceName}-copy`);
const startDuplicate = (name: string, source: PieceSource) => {
setDuplicateTarget({ name, source });
setDuplicateName(`${name}-copy`);
setDuplicateError(null);
};
const cancelDuplicate = () => {
setDuplicateSource(null);
setDuplicateTarget(null);
setDuplicateName('');
setDuplicateError(null);
};
const submitDuplicate = async () => {
if (!duplicateSource || duplicating) return;
if (!duplicateTarget || duplicating) return;
const name = duplicateName.trim();
if (!name) {
setDuplicateError('複製名を入力してください');
@@ -149,11 +201,12 @@ function PiecesSidebar({
try {
setDuplicating(true);
setDuplicateError(null);
const source = await fetchPiece(duplicateSource);
await createPiece({ ...source, name });
// 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);
onSelectPiece(name, source);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to duplicate piece';
setDuplicateError(msg);
@@ -162,10 +215,32 @@ function PiecesSidebar({
}
};
const { defaults, customs } = splitPieces(pieces ?? []);
return (
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
{/* Default Pieces section */}
<div className="flex items-center justify-between mb-2 px-2">
<span className="section-label">Pieces</span>
<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"
@@ -190,27 +265,22 @@ function PiecesSidebar({
/>
</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>
{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}
/>
))}
{duplicateSource && (
{duplicateTarget && (
<div
role="dialog"
aria-modal="true"
@@ -223,7 +293,7 @@ function PiecesSidebar({
onClick={e => e.stopPropagation()}
>
<div id="dup-piece-label" className="text-[13px] font-semibold text-slate-800 mb-2">
"{duplicateSource}"
"{duplicateTarget.name}"
</div>
<label className="block text-2xs font-medium text-slate-500 mb-1"></label>
<input
@@ -267,11 +337,14 @@ function PiecesSidebar({
interface PiecesPageProps {
showToast?: ShowToast;
isAdmin?: boolean;
}
export function PiecesPage({ showToast }: PiecesPageProps = {}) {
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 から。
@@ -282,17 +355,26 @@ export function PiecesPage({ showToast }: PiecesPageProps = {}) {
if (piece) setMobileView('detail');
}, [piece]);
const handleSelectPiece = (name: string) => {
setUrlState(prev => ({ ...prev, piece: name }));
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 activePiece={piece} onSelectPiece={handleSelectPiece} showToast={showToast} />
<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`}
@@ -309,7 +391,7 @@ export function PiecesPage({ showToast }: PiecesPageProps = {}) {
)}
<div className="flex-1 p-6">
{piece ? (
<PieceEditor name={piece} />
<PieceEditor name={piece} isAdmin={isAdmin} source={selectedSource} />
) : (
<div className="text-sm text-slate-400"> Piece </div>
)}
+2 -1
View File
@@ -4,6 +4,7 @@ import { POLLING } from '../lib/constants.js';
import { EmptyState } from '../components/shared/EmptyState';
import { StatChip } from '../components/shared/StatChip';
import { usePieceList } from '../hooks/usePieces';
import { resolvePieceOptions } from '../lib/splitPieces';
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
import { useAuthState } from '../App';
@@ -846,7 +847,7 @@ function ScheduleEditor({ mode, initialTask, onCancel, onSaved }: ScheduleEditor
const pieceOptions = useMemo(() => {
const opts = [{ value: 'auto', label: 'auto', description: 'LLM が自動選択' }];
for (const p of pieces) {
for (const p of resolvePieceOptions(pieces)) {
if (p.name === 'auto') continue;
opts.push({ value: p.name, label: p.name, description: p.description ?? '' });
}