219 lines
9.6 KiB
TypeScript
219 lines
9.6 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 { 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<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 === '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',
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|