This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* SpaceSettings.tsx — スペース詳細の「設定」タブ
|
||||
*
|
||||
* ユーザーフォルダ相当の設定を、そのスペースのフォルダ
|
||||
* (`data/spaces/{id}/…`) に対して扱う。左に sub-nav、右に対応パネル。
|
||||
*
|
||||
* - AGENTS.md / メモリ / Pieces / スキル: ファイルベース。spaceId を
|
||||
* 各 API に渡してスペースフォルダを操作する(個人スペースは leafId=
|
||||
* ユーザーIDなので User Folder と同一実体になる)。
|
||||
* - MCP / SSH: DB ベースだが per-space 化済み(spec §11)。spaceId を
|
||||
* 渡して、そのスペース専用のサーバー/接続として一覧・登録する。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AgentsMdPanel } from '../userfolder/AgentsMdPanel';
|
||||
import { MemoryPanel } from '../userfolder/MemoryPanel';
|
||||
import { SkillsPanel } from '../userfolder/SkillsPanel';
|
||||
import { McpPanel } from '../userfolder/McpPanel';
|
||||
import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
|
||||
import { SpaceMembersPanel } from './SpaceMembersPanel';
|
||||
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
|
||||
import { PieceEditor } from '../settings/PieceEditor';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { splitPieces } from '../../lib/splitPieces';
|
||||
import { createPiece, type PieceDef, type PieceSummary } from '../../api';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members';
|
||||
|
||||
const SECTIONS: { id: SettingsSection; label: string; testid: string }[] = [
|
||||
{ id: 'agents', label: 'AGENTS.md', testid: 'space-settings-nav-agents' },
|
||||
{ id: 'memory', label: 'メモリ', testid: 'space-settings-nav-memory' },
|
||||
{ id: 'pieces', label: 'Pieces', testid: 'space-settings-nav-pieces' },
|
||||
{ id: 'skills', label: 'スキル', testid: 'space-settings-nav-skills' },
|
||||
{ id: 'mcp', label: 'MCP', testid: 'space-settings-nav-mcp' },
|
||||
{ id: 'ssh', label: 'SSH', testid: 'space-settings-nav-ssh' },
|
||||
{ id: 'browser', label: 'ブラウザ', testid: 'space-settings-nav-browser' },
|
||||
{ id: 'members', label: 'メンバー', testid: 'space-settings-nav-members' },
|
||||
];
|
||||
|
||||
export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const [section, setSection] = useState<SettingsSection>('agents');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col md:flex-row md:gap-3">
|
||||
{/* Sub-nav: モバイルは横スクロールのセグメント、md+ は左の縦リスト。 */}
|
||||
<nav
|
||||
aria-label="ワークスペース設定"
|
||||
className="flex shrink-0 gap-1 overflow-x-auto border-b border-hairline pb-2 md:w-44 md:flex-col md:overflow-x-visible md:border-b-0 md:border-r md:pb-0 md:pr-3"
|
||||
>
|
||||
{SECTIONS.map(s => {
|
||||
const active = section === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
data-testid={s.testid}
|
||||
onClick={() => setSection(s.id)}
|
||||
className={`shrink-0 whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm font-medium transition-colors md:w-full ${
|
||||
active
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-600 hover:bg-surface hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 右ペイン */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-3 md:pt-0">
|
||||
{section === 'agents' && <AgentsMdPanel spaceId={spaceId} />}
|
||||
{section === 'memory' && <MemoryPanel spaceId={spaceId} />}
|
||||
{section === 'pieces' && <SpacePiecesPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'skills' && <SkillsPanel spaceId={spaceId} />}
|
||||
{section === 'mcp' && <McpPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* スペースフォルダの Pieces 一覧 + 編集。`PiecesPage` は URL state 結合が深い
|
||||
* ため、ここでは軽量な一覧(splitPieces で Default/Custom 分け)+ 重い
|
||||
* `PieceEditor` の再利用で構成する。選択はローカル state。
|
||||
*/
|
||||
function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const auth = useAuthState();
|
||||
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
|
||||
const qc = useQueryClient();
|
||||
const { data: pieces } = usePieceList(spaceId);
|
||||
const [selected, setSelected] = useState<{ name: string; source: 'builtin' | 'user-custom' | 'global-custom' } | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const { defaults, customs } = splitPieces(pieces ?? []);
|
||||
|
||||
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, spaceId);
|
||||
await qc.invalidateQueries({ queryKey: ['pieces', spaceId] });
|
||||
setIsCreating(false);
|
||||
setNewName('');
|
||||
setSelected({ name, source });
|
||||
} catch (e) {
|
||||
const msg = `Piece の作成に失敗しました: ${e instanceof Error ? e.message : String(e)}`;
|
||||
if (showToast) showToast(msg, 'error');
|
||||
else console.error(msg);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (p: PieceSummary, isBuiltin: boolean) => {
|
||||
const src = (p.source ?? (isBuiltin ? 'builtin' : 'user-custom')) as 'builtin' | 'user-custom' | 'global-custom';
|
||||
const active = selected?.name === p.name && selected?.source === src;
|
||||
return (
|
||||
<button
|
||||
key={`${src}-${p.name}`}
|
||||
type="button"
|
||||
onClick={() => setSelected({ name: p.name, source: src })}
|
||||
className={`w-full truncate rounded px-2 py-1 text-left text-xs transition-colors ${
|
||||
active ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* 左: 一覧 */}
|
||||
<div className="w-48 shrink-0 overflow-y-auto border-r border-hairline p-2">
|
||||
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">Default</div>
|
||||
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">(なし)</div>}
|
||||
{defaults.map(p => renderRow(p, true))}
|
||||
|
||||
<div className="mb-1 mt-3 flex items-center justify-between px-2">
|
||||
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">Custom</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreating(true)}
|
||||
title="新しい Piece"
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
|
||||
>
|
||||
+
|
||||
</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="h-7 w-full rounded-md border border-hairline px-2 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">(なし)</div>}
|
||||
{customs.map(p => renderRow(p, false))}
|
||||
</div>
|
||||
|
||||
{/* 右: エディタ */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{selected ? (
|
||||
<PieceEditor
|
||||
name={selected.name}
|
||||
source={selected.source}
|
||||
isAdmin={isAdmin}
|
||||
spaceId={spaceId}
|
||||
onDeleted={() => setSelected(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400">左から Piece を選んでください。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user