Files
maestro/ui/src/components/spaces/SpaceSettings.tsx
T
oss-sync a67c40d33c
CI / build-and-test (push) Successful in 8m58s
sync: update from private repo (a5db2ab0)
2026-07-10 00:51:05 +00:00

223 lines
10 KiB
TypeScript

/**
* 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 { SpaceWebhookSettings } from './SpaceWebhookSettings';
import { PythonPackagesPanel } from './PythonPackagesPanel';
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' | 'python' | 'webhooks';
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: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' },
{ id: 'webhooks', labelKey: 'settings.nav.webhooks', testid: 'space-settings-nav-webhooks' },
{ 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<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={t('settings.navLabel')}
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'
}`}
>
{t(s.labelKey)}
</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 === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
{section === 'webhooks' && <SpaceWebhookSettings 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 { 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',
persona: 'worker',
instruction: '',
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 (
<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">{t('settings.pieces.default')}</div>
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</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">{t('settings.pieces.custom')}</span>
<button
type="button"
onClick={() => setIsCreating(true)}
title={t('settings.pieces.new')}
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">{t('settings.pieces.none')}</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">{t('settings.pieces.selectHint')}</div>
)}
</div>
</div>
);
}