/** * 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 { useTranslation } from 'react-i18next'; 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 { SpaceToolSettings } from './SpaceToolSettings'; 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' | 'tools'; const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [ { id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' }, { id: 'memory', labelKey: 'settings.nav.memory', testid: 'space-settings-nav-memory' }, { id: 'pieces', labelKey: 'settings.nav.pieces', testid: 'space-settings-nav-pieces' }, { id: 'skills', labelKey: 'settings.nav.skills', testid: 'space-settings-nav-skills' }, { id: 'mcp', labelKey: 'settings.nav.mcp', testid: 'space-settings-nav-mcp' }, { id: 'ssh', labelKey: 'settings.nav.ssh', testid: 'space-settings-nav-ssh' }, { id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' }, { id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' }, { id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' }, ]; export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) { const { t } = useTranslation('spaces'); const [section, setSection] = useState('agents'); return (
{/* Sub-nav: モバイルは横スクロールのセグメント、md+ は左の縦リスト。 */} {/* 右ペイン */}
{section === 'agents' && } {section === 'memory' && } {section === 'pieces' && } {section === 'skills' && } {section === 'mcp' && } {section === 'ssh' && } {section === 'browser' && } {section === 'tools' && } {section === 'members' && }
); } /** * スペースフォルダの Pieces 一覧 + 編集。`PiecesPage` は URL state 結合が深い * ため、ここでは軽量な一覧(splitPieces で Default/Custom 分け)+ 重い * `PieceEditor` の再利用で構成する。選択はローカル state。 */ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) { const { t } = useTranslation('spaces'); 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 = t('settings.pieces.createFailed', { msg: 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 ( ); }; return (
{/* 左: 一覧 */}
{t('settings.pieces.default')}
{defaults.length === 0 &&
{t('settings.pieces.none')}
} {defaults.map(p => renderRow(p, true))}
{t('settings.pieces.custom')}
{isCreating && (
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" />
)} {customs.length === 0 && !isCreating &&
{t('settings.pieces.none')}
} {customs.map(p => renderRow(p, false))}
{/* 右: エディタ */}
{selected ? ( setSelected(null)} /> ) : (
{t('settings.pieces.selectHint')}
)}
); }