feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
createBrowserSessionProfile, startBrowserSessionLogin,
saveBrowserSession, cancelBrowserSession,
type BrowserSessionProfile,
} from '../../api';
import { usePictureInPicture } from '../../lib/usePictureInPicture.js';
import { PipButton } from '../browser/PipButton.js';
type Phase = 'form' | 'logging-in' | 'saving' | 'done' | 'error';
interface Props {
existingProfile?: BrowserSessionProfile | null;
onClose: () => void;
}
export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('form');
const [label, setLabel] = useState(existingProfile?.label ?? '');
const [startUrl, setStartUrl] = useState(existingProfile?.startUrl ?? '');
const [loggedInSelector, setLoggedInSelector] = useState(existingProfile?.loggedInSelector ?? '');
const [loginUrl, setLoginUrl] = useState(existingProfile?.loginUrlPatterns?.[0] ?? '');
const [profileId, setProfileId] = useState<number | null>(existingProfile?.id ?? null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [novncPath, setNovncPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const pip = usePictureInPicture(novncPath, label ? `noVNC — ログイン: ${label}` : 'noVNC — ログイン');
async function startLogin() {
setError(null);
try {
let pid = profileId;
if (!pid) {
const created = await createBrowserSessionProfile({
label,
startUrl,
matchPatterns: [],
storageOrigins: [new URL(startUrl).origin],
loggedInSelector: loggedInSelector || undefined,
loginUrlPatterns: loginUrl ? [loginUrl] : [],
});
pid = created.id;
setProfileId(pid);
}
const r = await startBrowserSessionLogin(pid);
setSessionId(r.sessionId);
setNovncPath(r.novncPath);
setPhase('logging-in');
} catch (e) {
setError((e as Error).message);
setPhase('error');
}
}
async function saveNow() {
if (!profileId || !sessionId) return;
setPhase('saving');
try {
await saveBrowserSession(profileId, sessionId);
qc.invalidateQueries({ queryKey: ['browser-session-profiles'] });
setPhase('done');
setTimeout(onClose, 800);
} catch (e) {
setError((e as Error).message);
setPhase('error');
}
}
async function cancel() {
if (profileId && sessionId) await cancelBrowserSession(profileId, sessionId).catch(() => {});
onClose();
}
// The remote noVNC content is rendered at the Xvfb native resolution
// (1280x720, see src/engine/browser-session.ts). At the form-phase 640px
// dialog width the iframe scales down to ~50%, which feels cramped while
// the user is actually logging in. Expand the dialog to ~1320x860 once we
// enter the login phase so the iframe can show 1:1.
const inLogin = phase === 'logging-in';
const dialogSize = inLogin
? 'w-[1320px] h-[860px] max-w-[95vw] max-h-[95vh]'
: 'w-[640px] max-w-[95vw] max-h-[90vh]';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className={`bg-white rounded-lg shadow-xl ${dialogSize} overflow-hidden flex flex-col`}>
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">
{existingProfile ? `再ログイン: ${existingProfile.label}` : 'ブラウザセッションを追加'}
</h3>
<button onClick={cancel} className="text-slate-400 hover:text-slate-700 text-lg leading-none">×</button>
</div>
{phase === 'form' && (
<div className="p-4 space-y-3">
<div>
<label className="block text-xs text-slate-700 mb-1"></label>
<input value={label} onChange={e => setLabel(e.target.value)}
disabled={!!existingProfile}
placeholder="My Twitter"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md disabled:bg-slate-50 disabled:text-slate-500" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1"> URL</label>
<input value={startUrl} onChange={e => setStartUrl(e.target.value)}
placeholder="https://twitter.com/home"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1"></label>
<input value={loggedInSelector} onChange={e => setLoggedInSelector(e.target.value)}
placeholder='[data-testid="primaryColumn"]'
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1"> URL </label>
<input value={loginUrl} onChange={e => setLoginUrl(e.target.value)}
placeholder="https://twitter.com/i/flow/login**"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
{error && <div className="text-xs text-rose-600">{error}</div>}
<div className="flex justify-end gap-2 pt-2">
<button onClick={cancel} className="text-xs px-3 py-1.5 rounded-md hover:bg-surface"></button>
<button disabled={!label || !startUrl} onClick={startLogin}
className="text-xs px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:bg-slate-300">
</button>
</div>
</div>
)}
{phase === 'logging-in' && novncPath && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-[420px] bg-black">
{pip.isOpen ? (
<div className="w-full h-full flex items-center justify-center text-xs text-slate-300">
PiP
</div>
) : (
<iframe src={novncPath} title="login" className="w-full h-full" allow="clipboard-read; clipboard-write" />
)}
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-between text-xs">
<span className="text-slate-600"></span>
<div className="flex gap-2 items-center">
<PipButton pip={pip} />
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface"></button>
<button onClick={saveNow} className="px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep"></button>
</div>
</div>
</div>
)}
{phase === 'saving' && <div className="p-6 text-center text-xs text-slate-500"></div>}
{phase === 'done' && <div className="p-6 text-center text-xs text-emerald-600"></div>}
{phase === 'error' && (
<div className="p-6 space-y-3 text-center">
<div className="text-xs text-rose-600">{error}</div>
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface text-xs"></button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MonacoFileEditor } from './MonacoFileEditor';
interface AgentsMdResponse {
exists: boolean;
content: string;
}
async function fetchAgentsMd(): Promise<AgentsMdResponse> {
const res = await fetch('/api/users/me/agents-md', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<AgentsMdResponse>;
}
async function saveAgentsMd(content: string): Promise<void> {
const res = await fetch('/api/users/me/agents-md', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: content,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${text}`);
}
}
async function deleteAgentsMd(): Promise<void> {
const res = await fetch('/api/users/me/agents-md', {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
}
interface AgentsMdPanelProps {
onDirtyChange?: (dirty: boolean) => void;
}
export function AgentsMdPanel({ onDirtyChange }: AgentsMdPanelProps) {
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['agents-md'],
queryFn: fetchAgentsMd,
staleTime: 30_000,
});
const save = useMutation({
mutationFn: saveAgentsMd,
onSuccess: () => qc.invalidateQueries({ queryKey: ['agents-md'] }),
});
const del = useMutation({
mutationFn: deleteAgentsMd,
onSuccess: () => qc.invalidateQueries({ queryKey: ['agents-md'] }),
});
if (isLoading) return <div className="p-6 text-[13px] text-slate-400">Loading</div>;
if (error) return <div className="p-6 text-[13px] text-red-500">: {String(error)}</div>;
const content = data?.content ?? '';
const byteSize = new TextEncoder().encode(content).length;
const handleSave = async (next: string) => {
await save.mutateAsync(next);
};
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/30">
<div className="flex items-center justify-between">
<div>
<h2 className="text-[13px] font-semibold text-slate-900">AGENTS.md</h2>
<p className="text-2xs text-slate-500 mt-0.5">
system prompt 64KB
</p>
</div>
{data?.exists && (
<button
type="button"
className="text-2xs text-red-600 hover:text-red-800 underline"
onClick={() => {
if (window.confirm('AGENTS.md を削除しますか?')) del.mutate();
}}
disabled={del.isPending}
>
</button>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<MonacoFileEditor
subdir="agents-md"
filename="AGENTS.md"
content={content}
mtime=""
size={byteSize}
onSave={handleSave}
onDirtyChange={onDirtyChange ?? (() => {})}
/>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
type BrowserSessionProfile,
} from '../../api';
import { AddBrowserSessionDialog } from './AddBrowserSessionDialog';
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
const map: Record<BrowserSessionProfile['status'], string> = {
pending: 'bg-slate-200 text-slate-700',
active: 'bg-emerald-100 text-emerald-700',
expired: 'bg-amber-100 text-amber-800',
revoked: 'bg-slate-200 text-slate-500',
error: 'bg-rose-100 text-rose-700',
};
const labels: Record<BrowserSessionProfile['status'], string> = {
pending: '保留中',
active: '有効',
expired: '期限切れ',
revoked: '無効化',
error: 'エラー',
};
return <span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${map[status]}`}>{labels[status]}</span>;
}
export function BrowserSessionsPanel() {
const qc = useQueryClient();
const { data: profiles = [], isLoading } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
});
const del = useMutation({
mutationFn: (id: number) => deleteBrowserSessionProfile(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['browser-session-profiles'] }),
});
const test = useMutation({
mutationFn: (id: number) => testBrowserSessionProfile(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['browser-session-profiles'] }),
});
const [adding, setAdding] = useState(false);
const [reLoginProfileId, setReLoginProfileId] = useState<number | null>(null);
return (
<div className="h-full overflow-y-auto p-6">
<div className="max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-800"></h2>
<button onClick={() => { setReLoginProfileId(null); setAdding(true); }}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep">
</button>
</div>
<p className="text-xs text-slate-500">
cookie / storageState {' '}
<code className="font-mono text-2xs bg-slate-100 px-1 py-0.5 rounded">session_profile_id</code>{' '}
</p>
{isLoading && <div className="text-xs text-slate-500"></div>}
<div className="rounded-md border border-hairline divide-y divide-hairline">
{profiles.length === 0 && !isLoading && (
<div className="px-3 py-6 text-center text-xs text-slate-400">
<div></div>
<div className="mt-1 text-slate-400"></div>
</div>
)}
{profiles.map(p => (
<div key={p.id} className="flex items-center justify-between px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-slate-800 truncate">{p.label}</span>
<StatusPill status={p.status} />
<span className="text-[10px] font-mono text-slate-400">id={p.id}</span>
</div>
<div className="text-2xs text-slate-500 truncate">{p.startUrl}</div>
{p.lastError && <div className="text-2xs text-rose-600 truncate">{p.lastError}</div>}
<div className="text-2xs text-slate-400">
{p.lastSavedAt ? `保存: ${new Date(p.lastSavedAt).toLocaleString('ja-JP')}` : '未保存'}
{p.lastUsedAt && ` · 最終使用: ${new Date(p.lastUsedAt).toLocaleString('ja-JP')}`}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => test.mutate(p.id)} disabled={test.isPending}
className="text-xs text-slate-700 hover:text-slate-900 px-2 py-1 rounded hover:bg-surface disabled:opacity-50"></button>
<button onClick={() => { setReLoginProfileId(p.id); setAdding(true); }}
className="text-xs text-slate-700 hover:text-slate-900 px-2 py-1 rounded hover:bg-surface"></button>
<button onClick={() => { if (confirm(`${p.label} を削除しますか?`)) del.mutate(p.id); }}
className="text-xs text-rose-600 hover:text-rose-800 px-2 py-1 rounded hover:bg-rose-50"></button>
</div>
</div>
))}
</div>
{adding && (
<AddBrowserSessionDialog
existingProfile={reLoginProfileId ? profiles.find(p => p.id === reLoginProfileId) ?? null : null}
onClose={() => { setAdding(false); setReLoginProfileId(null); }}
/>
)}
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useState } from 'react';
// 'agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections' are virtual subdirs (not raw file editor directories)
export type SubdirId = 'agents-md' | 'scripts' | 'browser-macros' | 'templates' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections' | 'notes' | 'subscribed-notes';
/** True for subdirs that have actual files on disk */
export const FILE_SUBDIRS: SubdirId[] = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'notes'];
export interface FileEntry {
name: string;
size: number;
mtime: string;
}
export interface SubdirFiles {
subdir: SubdirId;
files: FileEntry[];
loading: boolean;
}
interface FileTreeProps {
subdirData: SubdirFiles[];
selectedSubdir: SubdirId | null;
selectedFile: string | null;
onSelectSubdir: (subdir: SubdirId) => void;
onSelectFile: (subdir: SubdirId, file: string) => void;
onDeleteFile: (subdir: SubdirId, file: string) => void;
}
const SUBDIR_LABELS: Record<SubdirId, string> = {
'agents-md': 'AGENTS.md',
scripts: 'scripts',
'browser-macros': 'browser-macros',
templates: 'templates',
recordings: 'recordings',
trash: 'trash',
memory: 'memory',
'browser-sessions': 'browser-sessions',
mcp: 'MCP',
skills: 'Skills',
pets: 'pets',
'ssh-connections': 'ssh-connections',
notes: 'Notes (共有)',
'subscribed-notes': 'Subscribed Notes',
};
const SUBDIR_ICONS: Record<SubdirId, string> = {
'agents-md': '📖',
scripts: '📜',
'browser-macros': '🤖',
templates: '📄',
recordings: '🎬',
trash: '🗑',
memory: '🧠',
'browser-sessions': '🌐',
mcp: '🔌',
skills: '📚',
pets: '◉',
'ssh-connections': '🔐',
notes: '📝',
'subscribed-notes': '🔔',
};
/** Virtual subdirs that don't show a file list (they render custom panel content instead). */
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
export function FileTree({
subdirData,
selectedSubdir,
selectedFile,
onSelectSubdir,
onSelectFile,
onDeleteFile,
}: FileTreeProps) {
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
return (
<div className="flex flex-col h-full overflow-y-auto">
{subdirData.map(({ subdir, files, loading }) => {
const isOpen = selectedSubdir === subdir;
const isVirtual = VIRTUAL_SUBDIRS.has(subdir);
return (
<div key={subdir}>
{/* Subdir header */}
<button
type="button"
onClick={() => onSelectSubdir(subdir)}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors hover:bg-surface-2 ${
isOpen ? 'bg-surface-2 text-slate-900' : 'text-slate-600'
}`}
>
<span className="text-2xs">{isOpen ? '▾' : '▸'}</span>
<span>{SUBDIR_ICONS[subdir]}</span>
<span className="flex-1 text-left">{SUBDIR_LABELS[subdir]}{subdir !== 'agents-md' ? '/' : ''}</span>
{!isVirtual && (
<span className="text-[10px] font-mono text-slate-400 tabular-nums">
{loading ? '…' : files.length}
</span>
)}
</button>
{/* File list — only for non-virtual subdirs */}
{isOpen && !isVirtual && (
<div className="ml-4 border-l border-hairline pl-2 pb-1">
{loading && (
<div className="text-2xs text-slate-400 px-2 py-1.5">Loading</div>
)}
{!loading && files.length === 0 && (
<div className="text-2xs text-slate-400 px-2 py-1.5">Empty</div>
)}
{!loading && files.map(file => {
const fileKey = `${subdir}/${file.name}`;
const isSelected = selectedSubdir === subdir && selectedFile === file.name;
return (
<div
key={file.name}
className={`group flex items-center gap-1 px-2 py-1 rounded text-2xs cursor-pointer transition-colors ${
isSelected
? 'bg-accent text-accent-fg'
: 'text-slate-700 hover:bg-surface-2'
}`}
onMouseEnter={() => setHoveredFile(fileKey)}
onMouseLeave={() => setHoveredFile(null)}
onClick={() => onSelectFile(subdir, file.name)}
>
<span className="flex-1 truncate font-mono">{file.name}</span>
{(hoveredFile === fileKey || isSelected) && (
<button
type="button"
aria-label={`Delete ${file.name}`}
onClick={e => {
e.stopPropagation();
onDeleteFile(subdir, file.name);
}}
className={`flex-shrink-0 w-4 h-4 flex items-center justify-center rounded hover:bg-red-100 hover:text-red-600 transition-colors ${
isSelected ? 'text-accent-fg/70' : 'text-slate-400'
}`}
>
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,129 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface ConnectionRow {
serverId: string;
serverName: string;
connected: boolean;
authKind: 'oauth' | 'api_key';
ownerId: string | null;
}
interface ConnectionListResponse {
connections: ConnectionRow[];
}
async function fetchConnections(): Promise<ConnectionRow[]> {
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
const data: ConnectionListResponse = await res.json();
return data.connections ?? [];
}
async function disconnectMcp(serverId: string): Promise<void> {
const res = await fetch(`/api/mcp/connections/${encodeURIComponent(serverId)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
}
function OwnerBadge({ ownerId }: { ownerId: string | null }) {
if (ownerId === null) {
return (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">
global
</span>
);
}
return (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">
personal
</span>
);
}
export function McpConnectionsPanel() {
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['mcp-connections'],
queryFn: fetchConnections,
staleTime: 30_000,
});
const disconnect = useMutation({
mutationFn: disconnectMcp,
onSuccess: () => qc.invalidateQueries({ queryKey: ['mcp-connections'] }),
});
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP </h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
MCP OAuth
API key
</p>
</div>
{isLoading && <div className="text-[13px] text-slate-400">Loading</div>}
{error && <div className="text-[13px] text-red-500">: {String(error)}</div>}
{!isLoading && !error && (data?.length ?? 0) === 0 && (
<div className="text-[13px] text-slate-400">
MCP mcp-servers/
</div>
)}
<ul className="divide-y divide-hairline">
{(data ?? []).map((c) => (
<li key={c.serverId} className="py-3 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-medium text-slate-900 truncate">{c.serverName}</span>
<OwnerBadge ownerId={c.ownerId} />
</div>
<div className="text-2xs text-slate-500 font-mono truncate">{c.serverId}</div>
</div>
<div className="shrink-0">
{c.authKind === 'oauth' ? (
c.connected ? (
<div className="flex items-center gap-3">
<span className="text-xs text-emerald-600 font-medium"></span>
<button
type="button"
className="text-xs text-slate-500 hover:text-slate-700 underline"
onClick={() => {
if (window.confirm(`${c.serverName} の連携を解除しますか?`)) {
disconnect.mutate(c.serverId);
}
}}
disabled={disconnect.isPending}
></button>
</div>
) : (
<a
className="px-3 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
href={`/auth/mcp/${encodeURIComponent(c.serverId)}/start`}
>
</a>
)
) : (
/* api_key */
c.connected ? (
<div className="flex items-center gap-1.5">
<span className="text-xs text-emerald-600 font-medium">API key </span>
{c.ownerId !== null && (
<span className="text-2xs text-slate-400"> mcp-servers </span>
)}
</div>
) : (
<span className="text-xs text-amber-600">API key </span>
)
)}
</div>
</li>
))}
</ul>
</div>
</div>
);
}
+461
View File
@@ -0,0 +1,461 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthState } from '../../App';
interface ServerPublic {
id: string;
name: string;
url: string;
authKind: 'oauth' | 'api_key';
ownerId: string | null;
oauthClientId: string | null;
oauthScopes: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
toolCount?: number;
}
interface ConnectionRow {
serverId: string;
serverName: string;
connected: boolean;
authKind: 'oauth' | 'api_key';
ownerId: string | null;
}
interface ServerFormBody {
id: string;
name: string;
url: string;
authKind: 'oauth' | 'api_key';
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string;
staticToken?: string;
enabled?: boolean;
}
// ── API helpers ───────────────────────────────────────────────────────────
async function fetchAdminServers(): Promise<ServerPublic[]> {
const res = await fetch('/api/mcp/servers', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
return ((await res.json()) as { servers: ServerPublic[] }).servers ?? [];
}
async function fetchUserServers(): Promise<ServerPublic[]> {
const res = await fetch('/api/mcp/user-servers', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
return ((await res.json()) as { servers: ServerPublic[] }).servers ?? [];
}
async function fetchConnections(): Promise<ConnectionRow[]> {
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
return ((await res.json()) as { connections: ConnectionRow[] }).connections ?? [];
}
async function upsertServer(body: ServerFormBody, isGlobal: boolean): Promise<void> {
const url = isGlobal ? '/api/mcp/servers' : '/api/mcp/user-servers';
const res = await fetch(url, {
method: 'POST', credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${text}`);
}
}
async function deleteServer(id: string, isGlobal: boolean): Promise<void> {
const url = isGlobal
? `/api/mcp/servers/${encodeURIComponent(id)}`
: `/api/mcp/user-servers/${encodeURIComponent(id)}`;
const res = await fetch(url, { method: 'DELETE', credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
}
async function refreshTools(id: string, isGlobal: boolean): Promise<void> {
const url = isGlobal
? `/api/mcp/servers/${encodeURIComponent(id)}/tools/refresh`
: `/api/mcp/user-servers/${encodeURIComponent(id)}/tools/refresh`;
const res = await fetch(url, { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
}
async function disconnectMcp(serverId: string): Promise<void> {
const res = await fetch(`/api/mcp/connections/${encodeURIComponent(serverId)}`, {
method: 'DELETE', credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
}
// ── Sub-components ──────────────────────────────────────────────────────
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="block text-2xs text-slate-600 mb-1">{label}</span>
{children}
</label>
);
}
const INPUT_CLS = 'w-full border border-hairline rounded px-2 py-1 text-[13px]';
const emptyForm = (): ServerFormBody => ({
id: '', name: '', url: '', authKind: 'oauth',
oauthClientId: '', oauthClientSecret: '', oauthScopes: '', staticToken: '',
});
function ServerForm({
initial,
isEdit,
sectionLabel,
onSubmit,
onCancel,
isPending,
}: {
initial: ServerFormBody;
isEdit: boolean;
sectionLabel: string;
onSubmit: (body: ServerFormBody) => Promise<void>;
onCancel?: () => void;
isPending: boolean;
}) {
const [form, setForm] = useState<ServerFormBody>(initial);
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
try {
await onSubmit(form);
if (!isEdit) setForm(emptyForm());
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
}
};
return (
<form className="space-y-3 max-w-xl" onSubmit={handleSubmit}>
<FormField label="ID (slug, 例: canva)">
<input className={INPUT_CLS} value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
placeholder="canva" pattern="[a-z0-9_-]{1,64}" required disabled={isEdit}
/>
</FormField>
<FormField label="表示名">
<input className={INPUT_CLS} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Canva" required
/>
</FormField>
<FormField label="MCP URL (https://...)">
<input className={INPUT_CLS} value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://example.com/mcp" type="url" required
/>
</FormField>
<fieldset>
<legend className="block text-2xs text-slate-600 mb-1"></legend>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
<input type="radio" name={`authKind-${sectionLabel}`} value="oauth"
checked={form.authKind === 'oauth'}
onChange={() => setForm({ ...form, authKind: 'oauth' })}
disabled={isEdit}
/> OAuth
</label>
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
<input type="radio" name={`authKind-${sectionLabel}`} value="api_key"
checked={form.authKind === 'api_key'}
onChange={() => setForm({ ...form, authKind: 'api_key' })}
disabled={isEdit}
/> API key
</label>
</div>
</fieldset>
{form.authKind === 'oauth' && (
<>
<FormField label="OAuth client_id">
<input className={INPUT_CLS} value={form.oauthClientId ?? ''}
onChange={(e) => setForm({ ...form, oauthClientId: e.target.value })}
required={!isEdit}
/>
</FormField>
<FormField label={isEdit ? 'OAuth client_secret (空欄なら変更なし)' : 'OAuth client_secret'}>
<input className={INPUT_CLS} type="password" value={form.oauthClientSecret ?? ''}
onChange={(e) => setForm({ ...form, oauthClientSecret: e.target.value })}
required={!isEdit}
/>
</FormField>
<FormField label="scopes (space-separated, 任意)">
<input className={INPUT_CLS} value={form.oauthScopes ?? ''}
onChange={(e) => setForm({ ...form, oauthScopes: e.target.value })}
placeholder="read write"
/>
</FormField>
</>
)}
{form.authKind === 'api_key' && (
<FormField label={isEdit ? 'API key (空欄なら変更なし)' : 'API key / Bearer token'}>
<input className={INPUT_CLS} type="password" value={form.staticToken ?? ''}
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
placeholder="sk-..." required={!isEdit}
/>
</FormField>
)}
{formError && <div className="text-xs text-red-600">{formError}</div>}
<div className="flex gap-2">
<button type="submit" disabled={isPending}
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50">
{isPending ? '保存中…' : isEdit ? '更新' : '追加'}
</button>
{onCancel && (
<button type="button" onClick={onCancel}
className="px-4 py-1.5 rounded-md text-xs text-slate-700 border border-hairline hover:bg-surface transition-colors">
</button>
)}
</div>
</form>
);
}
function ScopeBadge({ ownerId }: { ownerId: string | null }) {
return ownerId === null ? (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">global</span>
) : (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">personal</span>
);
}
function ConnectionBadge({ connection, serverId }: { connection?: ConnectionRow; serverId: string }) {
if (!connection) return <span className="text-2xs text-slate-400"></span>;
if (connection.authKind === 'api_key') {
return connection.connected
? <span className="text-2xs text-emerald-600 font-medium">API key </span>
: <span className="text-2xs text-amber-600">API key </span>;
}
if (connection.connected) {
return <span className="text-2xs text-emerald-600 font-medium">OAuth </span>;
}
return (
<a className="px-2 py-0.5 rounded text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
href={`/auth/mcp/${encodeURIComponent(serverId)}/start`}>
</a>
);
}
// ── Main component ──────────────────────────────────────────────────────
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
export function McpPanel({ showToast }: { showToast?: ShowToast }) {
const qc = useQueryClient();
const auth = useAuthState();
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
const [editingId, setEditingId] = useState<string | null>(null);
const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null);
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
};
const { data: globalServers, isLoading: globalLoading } = useQuery({
queryKey: ['mcp-servers-admin'], queryFn: fetchAdminServers,
staleTime: 30_000, enabled: isAdmin,
});
const { data: userServers, isLoading: userLoading } = useQuery({
queryKey: ['mcp-user-servers'], queryFn: fetchUserServers, staleTime: 30_000,
});
const { data: connections } = useQuery({
queryKey: ['mcp-connections'], queryFn: fetchConnections, staleTime: 30_000,
});
const connMap = new Map((connections ?? []).map(c => [c.serverId, c]));
const saveMut = useMutation({
mutationFn: ({ body, isGlobal }: { body: ServerFormBody; isGlobal: boolean }) =>
upsertServer(body, isGlobal),
onSuccess: () => { invalidateAll(); setEditingId(null); setAddingSection(null); },
onError: (err) => showToast?.('保存に失敗: ' + (err instanceof Error ? err.message : String(err)), 'error'),
});
const delMut = useMutation({
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => deleteServer(id, isGlobal),
onSuccess: invalidateAll,
});
const refreshMut = useMutation({
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) => refreshTools(id, isGlobal),
onSuccess: invalidateAll,
onError: (err) => showToast?.('ツール更新に失敗: ' + (err instanceof Error ? err.message : String(err)), 'error'),
});
const disconnectMut = useMutation({
mutationFn: disconnectMcp,
onSuccess: invalidateAll,
});
const handleDelete = (id: string, name: string, isGlobal: boolean) => {
const msg = isGlobal ? `${id} を削除しますか? 全ユーザーのトークンも失効します。` : `${name} を削除しますか?`;
if (window.confirm(msg)) delMut.mutate({ id, isGlobal });
};
const renderServerRow = (s: ServerPublic, isGlobal: boolean) => {
const conn = connMap.get(s.id);
const isEditing = editingId === s.id;
if (isEditing) {
return (
<div key={s.id} className="p-3 bg-surface/50 border border-hairline rounded-md">
<ServerForm
initial={{ id: s.id, name: s.name, url: s.url, authKind: s.authKind,
oauthClientId: s.oauthClientId ?? '', oauthClientSecret: '', oauthScopes: s.oauthScopes ?? '',
staticToken: '', enabled: s.enabled }}
isEdit sectionLabel={`edit-${s.id}`}
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal }); }}
onCancel={() => setEditingId(null)}
isPending={saveMut.isPending}
/>
</div>
);
}
return (
<div key={s.id} className="flex items-center gap-3 py-2.5 border-b border-hairline last:border-b-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-medium text-slate-900">{s.name}</span>
<ScopeBadge ownerId={s.ownerId} />
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${
s.authKind === 'oauth' ? 'bg-purple-50 text-purple-600' : 'bg-amber-50 text-amber-600'
}`}>{s.authKind === 'oauth' ? 'OAuth' : 'API key'}</span>
{s.toolCount != null && s.toolCount > 0 && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-50 text-green-700 leading-none">
{s.toolCount}
</span>
)}
</div>
<div className="text-2xs text-slate-500 font-mono truncate mt-0.5">{s.url}</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ConnectionBadge connection={conn} serverId={s.id} />
{conn?.connected && conn.authKind === 'oauth' && (
<button type="button" className="text-2xs text-slate-500 hover:text-slate-700 underline"
onClick={() => { if (window.confirm(`${s.name} の連携を解除しますか?`)) disconnectMut.mutate(s.id); }}
disabled={disconnectMut.isPending}>
</button>
)}
<button type="button" className="text-2xs text-slate-600 hover:text-slate-800 underline"
onClick={() => refreshMut.mutate({ id: s.id, isGlobal })}
disabled={refreshMut.isPending}>
</button>
<button type="button" className="text-2xs text-slate-600 hover:text-slate-800 underline"
onClick={() => setEditingId(s.id)}>
</button>
<button type="button" className="text-2xs text-red-600 hover:text-red-800 underline"
onClick={() => handleDelete(s.id, s.name, isGlobal)}
disabled={delMut.isPending}>
</button>
</div>
</div>
);
};
const isLoading = globalLoading || userLoading;
return (
<div className="h-full overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP </h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
MCP
OAuth credentials API key AES-256-GCM
</p>
</div>
{isLoading && <div className="text-[13px] text-slate-400"></div>}
{/* Global Servers (admin) */}
{isAdmin && (globalServers ?? []).length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-[13px] font-semibold text-slate-900">Global </h3>
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none"></span>
</div>
<div>{(globalServers ?? []).map(s => renderServerRow(s, true))}</div>
</section>
)}
{/* Personal Servers */}
{(userServers ?? []).length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-[13px] font-semibold text-slate-900"></h3>
</div>
<div>{(userServers ?? []).map(s => renderServerRow(s, false))}</div>
</section>
)}
{/* Empty state */}
{!isLoading && (globalServers ?? []).length === 0 && (userServers ?? []).length === 0 && (
<div className="text-[13px] text-slate-400 text-center py-8">
MCP
</div>
)}
{/* Add buttons / forms */}
<div className="space-y-4">
{addingSection ? (
<div>
<h4 className="text-xs font-semibold text-slate-700 mb-2">
{addingSection === 'global' ? 'Global サーバーを追加' : 'Personal サーバーを追加'}
</h4>
<ServerForm
initial={emptyForm()} isEdit={false}
sectionLabel={addingSection}
onSubmit={async (body) => { await saveMut.mutateAsync({ body, isGlobal: addingSection === 'global' }); }}
onCancel={() => setAddingSection(null)}
isPending={saveMut.isPending}
/>
</div>
) : (
<div className="flex gap-2">
<button type="button" onClick={() => setAddingSection('personal')}
className="px-3 py-1.5 rounded-md text-xs font-semibold text-accent border border-accent/30 hover:bg-accent-soft transition-colors">
+ Personal
</button>
{isAdmin && (
<button type="button" onClick={() => setAddingSection('global')}
className="px-3 py-1.5 rounded-md text-xs font-semibold text-slate-600 border border-hairline hover:bg-surface transition-colors">
+ Global
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,518 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthState } from '../../App';
// ── Types ─────────────────────────────────────────────────────────────────────
interface ServerPublic {
id: string;
name: string;
url: string;
authKind: 'oauth' | 'api_key';
ownerId: string | null;
oauthClientId: string | null;
oauthScopes: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
authorizationEndpoint: string | null;
toolCount?: number;
}
interface ServerListResponse {
servers: ServerPublic[];
}
interface UserServerListResponse {
servers: ServerPublic[];
}
// ── API helpers ───────────────────────────────────────────────────────────────
async function fetchAdminServers(): Promise<ServerPublic[]> {
const res = await fetch('/api/mcp/servers', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
const data: ServerListResponse = await res.json();
return data.servers ?? [];
}
async function fetchUserServers(): Promise<ServerPublic[]> {
const res = await fetch('/api/mcp/user-servers', { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
const data: UserServerListResponse = await res.json();
return data.servers ?? [];
}
async function createGlobalServer(body: ServerFormBody): Promise<void> {
const res = await fetch('/api/mcp/servers', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${text}`);
}
}
async function createUserServer(body: ServerFormBody): Promise<void> {
const res = await fetch('/api/mcp/user-servers', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${text}`);
}
}
async function deleteServer(id: string, isGlobal: boolean): Promise<void> {
const url = isGlobal
? `/api/mcp/servers/${encodeURIComponent(id)}`
: `/api/mcp/user-servers/${encodeURIComponent(id)}`;
const res = await fetch(url, { method: 'DELETE', credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
}
async function refreshTools(id: string, isGlobal: boolean): Promise<void> {
const url = isGlobal
? `/api/mcp/servers/${encodeURIComponent(id)}/tools/refresh`
: `/api/mcp/user-servers/${encodeURIComponent(id)}/tools/refresh`;
const res = await fetch(url, { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
}
// ── Form types & helpers ──────────────────────────────────────────────────────
interface ServerFormBody {
id: string;
name: string;
url: string;
authKind: 'oauth' | 'api_key';
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string;
staticToken?: string;
enabled?: boolean;
}
const emptyForm = (): ServerFormBody => ({
id: '',
name: '',
url: '',
authKind: 'oauth',
oauthClientId: '',
oauthClientSecret: '',
oauthScopes: '',
staticToken: '',
});
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="block text-2xs text-slate-600 mb-1">{label}</span>
{children}
</label>
);
}
// ── AddServerForm ─────────────────────────────────────────────────────────────
interface AddServerFormProps {
sectionLabel: string;
onSubmit: (body: ServerFormBody) => Promise<void>;
isPending: boolean;
}
function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps) {
const [form, setForm] = useState<ServerFormBody>(emptyForm());
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
try {
await onSubmit(form);
setForm(emptyForm());
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
}
};
return (
<form className="space-y-3 max-w-xl" onSubmit={handleSubmit}>
<FormField label="ID (slug, 例: canva)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
placeholder="canva"
pattern="[a-z0-9_-]{1,64}"
required
/>
</FormField>
<FormField label="表示名">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Canva"
required
/>
</FormField>
<FormField label="MCP URL (https://...)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://example.com/mcp"
type="url"
required
/>
</FormField>
{/* Auth kind radio */}
<fieldset>
<legend className="block text-2xs text-slate-600 mb-1"></legend>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
<input
type="radio"
name={`authKind-${sectionLabel}`}
value="oauth"
checked={form.authKind === 'oauth'}
onChange={() => setForm({ ...form, authKind: 'oauth' })}
/>
OAuth
</label>
<label className="flex items-center gap-1.5 text-[13px] cursor-pointer">
<input
type="radio"
name={`authKind-${sectionLabel}`}
value="api_key"
checked={form.authKind === 'api_key'}
onChange={() => setForm({ ...form, authKind: 'api_key' })}
/>
API key
</label>
</div>
</fieldset>
{/* OAuth-only fields */}
{form.authKind === 'oauth' && (
<>
<FormField label="OAuth client_id">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.oauthClientId ?? ''}
onChange={(e) => setForm({ ...form, oauthClientId: e.target.value })}
required
/>
</FormField>
<FormField label="OAuth client_secret">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
type="password"
value={form.oauthClientSecret ?? ''}
onChange={(e) => setForm({ ...form, oauthClientSecret: e.target.value })}
required
/>
</FormField>
<FormField label="scopes (space-separated, 任意)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.oauthScopes ?? ''}
onChange={(e) => setForm({ ...form, oauthScopes: e.target.value })}
placeholder="read write"
/>
</FormField>
</>
)}
{/* API key field */}
{form.authKind === 'api_key' && (
<FormField label="API key / Bearer token">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
type="password"
value={form.staticToken ?? ''}
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
placeholder="sk-..."
required
/>
</FormField>
)}
{formError && (
<div className="text-xs text-red-600">{formError}</div>
)}
<button
type="submit"
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={isPending}
>
{isPending ? '保存中…' : '追加'}
</button>
</form>
);
}
// ── ServerTable ───────────────────────────────────────────────────────────────
interface ServerTableProps {
servers: ServerPublic[];
isGlobal: boolean;
canDelete: boolean;
onRefresh: (id: string, isGlobal: boolean) => void;
onDelete: (id: string, name: string, isGlobal: boolean) => void;
refreshPending: boolean;
deletePending: boolean;
}
function ServerTable({
servers,
isGlobal,
canDelete,
onRefresh,
onDelete,
refreshPending,
deletePending,
}: ServerTableProps) {
if (servers.length === 0) {
return (
<div className="text-[13px] text-slate-400">
{isGlobal ? '登録された global サーバーがありません。' : 'あなたのサーバーがありません。'}
</div>
);
}
return (
<table className="w-full text-[13px]">
<thead className="text-left text-2xs uppercase tracking-wide text-slate-500">
<tr className="border-b border-hairline">
<th className="py-2 pr-2">ID</th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2">URL</th>
<th className="py-2 pr-2 w-20"></th>
<th className="py-2 pr-2 w-16"></th>
<th className="py-2 pr-2 w-24"></th>
<th className="py-2 pr-2 w-40"></th>
</tr>
</thead>
<tbody className="divide-y divide-hairline">
{servers.map((s) => (
<tr key={s.id}>
<td className="py-2 pr-2 font-mono">{s.id}</td>
<td className="py-2 pr-2">{s.name}</td>
<td className="py-2 pr-2 font-mono text-2xs truncate max-w-xs" title={s.url}>{s.url}</td>
<td className="py-2 pr-2">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${
s.authKind === 'oauth'
? 'bg-purple-50 text-purple-600'
: 'bg-amber-50 text-amber-600'
}`}>
{s.authKind === 'oauth' ? 'OAuth' : 'API key'}
</span>
</td>
<td className="py-2 pr-2">{s.enabled ? '✓' : '—'}</td>
<td className="py-2 pr-2">
{s.toolCount == null || s.toolCount === 0 ? (
<span className="text-[10px] text-slate-400 italic"> </span>
) : (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-50 text-green-700 leading-none">
{s.toolCount}
</span>
)}
</td>
<td className="py-2 pr-2 space-x-2">
<button
type="button"
className="text-2xs text-slate-600 hover:text-slate-800 underline"
onClick={() => onRefresh(s.id, isGlobal)}
disabled={refreshPending}
></button>
{canDelete && (
<button
type="button"
className="text-2xs text-red-600 hover:text-red-800 underline"
onClick={() => onDelete(s.id, s.name, isGlobal)}
disabled={deletePending}
></button>
)}
</td>
</tr>
))}
</tbody>
</table>
);
}
// ── McpServersPanel ───────────────────────────────────────────────────────────
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
interface McpServersPanelProps {
showToast?: ShowToast;
}
export function McpServersPanel({ showToast }: McpServersPanelProps = {}) {
const qc = useQueryClient();
const auth = useAuthState();
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
// Admin fetches global servers from /api/mcp/servers
const { data: globalServers, isLoading: globalLoading, error: globalError } = useQuery({
queryKey: ['mcp-servers-admin'],
queryFn: fetchAdminServers,
staleTime: 30_000,
enabled: isAdmin,
});
// All users fetch their own servers
const { data: userServers, isLoading: userLoading, error: userError } = useQuery({
queryKey: ['mcp-user-servers'],
queryFn: fetchUserServers,
staleTime: 30_000,
});
const createGlobal = useMutation({
mutationFn: createGlobalServer,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
},
});
const createUser = useMutation({
mutationFn: createUserServer,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
},
});
const del = useMutation({
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) =>
deleteServer(id, isGlobal),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
},
});
const refresh = useMutation({
mutationFn: ({ id, isGlobal }: { id: string; isGlobal: boolean }) =>
refreshTools(id, isGlobal),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
},
onError: (err) => {
const msg = 'ツール更新に失敗: ' + (err instanceof Error ? err.message : String(err));
if (showToast) showToast(msg, 'error');
else console.error(msg);
},
});
const handleDelete = (id: string, name: string, isGlobal: boolean) => {
const msg = isGlobal
? `${id} を削除しますか? 全ユーザーのトークンも失効します。`
: `${name} を削除しますか?`;
if (window.confirm(msg)) {
del.mutate({ id, isGlobal });
}
};
const handleRefresh = (id: string, isGlobal: boolean) => {
refresh.mutate({ id, isGlobal });
};
return (
<div className="h-full overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">MCP </h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
MCP
{isAdmin
? ' 管理者は全ユーザー共有の global サーバーと自分専用の personal サーバーを登録できます。'
: ' あなた専用の personal サーバーを登録できます。'}
OAuth credentials API key AES-256-GCM
</p>
</div>
{/* ── Global Servers (admin only) ──────────────────────────────── */}
{isAdmin && (
<section className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-semibold text-slate-900">Global Servers (admin )</h3>
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-100 text-slate-500 leading-none">
</span>
</div>
{globalLoading && <div className="text-[13px] text-slate-400"></div>}
{globalError && (
<div className="text-[13px] text-red-500">: {String(globalError)}</div>
)}
{!globalLoading && !globalError && (
<ServerTable
servers={globalServers ?? []}
isGlobal={true}
canDelete={true}
onRefresh={handleRefresh}
onDelete={handleDelete}
refreshPending={refresh.isPending}
deletePending={del.isPending}
/>
)}
<div>
<h4 className="text-xs font-semibold text-slate-700 mb-2">Global </h4>
<AddServerForm
sectionLabel="global"
onSubmit={createGlobal.mutateAsync}
isPending={createGlobal.isPending}
/>
</div>
</section>
)}
{/* ── Your Servers (all users) ──────────────────────────────────── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-[13px] font-semibold text-slate-900"></h3>
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 text-blue-600 leading-none">
personal
</span>
</div>
{userLoading && <div className="text-[13px] text-slate-400"></div>}
{userError && (
<div className="text-[13px] text-red-500">: {String(userError)}</div>
)}
{!userLoading && !userError && (
<ServerTable
servers={userServers ?? []}
isGlobal={false}
canDelete={true}
onRefresh={handleRefresh}
onDelete={handleDelete}
refreshPending={refresh.isPending}
deletePending={del.isPending}
/>
)}
<div>
<h4 className="text-xs font-semibold text-slate-700 mb-2">Personal </h4>
<AddServerForm
sectionLabel="personal"
onSubmit={createUser.mutateAsync}
isPending={createUser.isPending}
/>
</div>
</section>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import type { SubdirId } from './FileTree';
interface MonacoFileEditorProps {
subdir: SubdirId;
filename: string;
content: string;
mtime: string;
size: number;
onSave: (content: string) => Promise<void>;
onDirtyChange: (dirty: boolean) => void;
}
function detectLanguage(filename: string): string {
const dotIdx = filename.lastIndexOf('.');
const ext = dotIdx >= 0 ? filename.slice(dotIdx).toLowerCase() : '';
switch (ext) {
case '.js': return 'javascript';
case '.ts': return 'typescript';
case '.md': return 'markdown';
case '.json': return 'json';
case '.yaml':
case '.yml': return 'yaml';
case '.sh': return 'shell';
case '.py': return 'python';
default: return 'plaintext';
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatMtime(iso: string): string {
try {
return new Date(iso).toLocaleString('ja-JP', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
} catch {
return iso;
}
}
export function MonacoFileEditor({ subdir, filename, content, mtime, size, onSave, onDirtyChange }: MonacoFileEditorProps) {
const [localContent, setLocalContent] = useState(content);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
// Fix #2 (part): Reset editor state on file navigation only — not on background refetch.
// content is intentionally excluded from deps so a refetchOnWindowFocus doesn't clobber edits.
useEffect(() => {
setLocalContent(content);
setDirty(false);
setSaveError(null);
}, [subdir, filename]); // eslint-disable-line react-hooks/exhaustive-deps -- intentional: do not depend on content
// Fix #5: Notify parent when dirty state changes so the nav guard can run there.
useEffect(() => {
onDirtyChange(dirty);
}, [dirty, onDirtyChange]);
const isReadOnly = subdir === 'trash' || subdir === 'memory';
const language = detectLanguage(filename);
const handleSave = async () => {
if (isReadOnly || !dirty || saving) return;
setSaving(true);
setSaveError(null);
try {
await onSave(localContent);
setDirty(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Save failed');
} finally {
setSaving(false);
}
};
// Fix #1: Keep a ref that always points to the latest handleSave so the
// keyboard shortcut (bound once at mount) never closes over a stale version.
const handleSaveRef = useRef<() => void | Promise<void>>(() => {});
handleSaveRef.current = handleSave;
const handleMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
// Cmd/Ctrl+S binding — dispatches through the ref so it always sees the
// latest dirty state, not the value captured at mount time.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
void handleSaveRef.current();
});
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* File header */}
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-hairline bg-white">
<span className="text-xs font-mono font-semibold text-slate-800 truncate">
{filename}
</span>
{dirty && (
<span className="text-[10px] font-medium text-amber-600 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5">
unsaved
</span>
)}
{isReadOnly && (
<span className="text-[10px] font-medium text-slate-500 bg-surface-2 border border-hairline rounded px-1.5 py-0.5">
read-only
</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-slate-400 font-mono hidden sm:inline">
{language}
</span>
</div>
{/* Monaco editor */}
<div className="flex-1 min-h-0 overflow-hidden">
<Editor
height="100%"
language={language}
value={localContent}
options={{
readOnly: isReadOnly,
fontSize: 13,
lineHeight: 20,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
tabSize: 2,
}}
onMount={handleMount}
onChange={val => {
if (!isReadOnly) {
setLocalContent(val ?? '');
setDirty((val ?? '') !== content);
}
}}
/>
</div>
{/* Footer: save button + metadata */}
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 border-t border-hairline bg-white">
{!isReadOnly && (
<button
type="button"
onClick={handleSave}
disabled={!dirty || saving}
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Saving…' : 'Save'}
</button>
)}
{saveError && (
<span className="text-2xs text-red-600">{saveError}</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-slate-400 font-mono tabular-nums">
{formatSize(size)} · {formatMtime(mtime)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,138 @@
import { useState } from 'react';
type WritableSubdir = 'scripts' | 'browser-macros' | 'templates';
interface NewFileFormProps {
subdir: WritableSubdir;
existingFilenames: string[];
onCreate: (filename: string, skeleton: string) => Promise<void>;
}
const TODAY = new Date().toISOString().slice(0, 10);
const SKELETON: Record<WritableSubdir, { ext: string; body: string }> = {
scripts: {
ext: '.js',
body: `---
description: <short summary>
params:
# name: { type: string, required: true, description: "..." }
---
/**
* Generated ${TODAY} via User Folder UI.
*/
export async function main({ params }) {
console.log('script start', params);
return { ok: true };
}
`,
},
'browser-macros': {
ext: '.js',
body: `---
description: <short summary>
# session_profile_id: 1 # optional: bind to a saved login profile
params:
# url: { type: string, required: true }
---
/**
* Generated ${TODAY} via User Folder UI.
*/
export async function main({ context, params }) {
const page = await context.newPage();
// await page.goto(params.url);
return { ok: true };
}
`,
},
templates: {
ext: '.md',
body: `---
description: <short summary>
params:
# title: { type: string, required: true }
---
# {{title}}
Content here.
`,
},
};
const SUBDIR_LABEL: Record<WritableSubdir, string> = {
scripts: 'スクリプト',
'browser-macros': 'ブラウザマクロ',
templates: 'テンプレート',
};
export function NewFileForm({ subdir, existingFilenames, onCreate }: NewFileFormProps) {
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const config = SKELETON[subdir];
const label = SUBDIR_LABEL[subdir];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const baseName = name.trim().replace(new RegExp(`\\${config.ext}$`, 'i'), '');
if (!baseName) {
setError('ファイル名を入力してください');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(baseName)) {
setError('英数字 / ハイフン / アンダースコアのみ使用可能 (スペース不可、拡張子は自動付与)');
return;
}
const filename = `${baseName}${config.ext}`;
if (existingFilenames.includes(filename)) {
setError(`${filename} は既に存在します。別の名前を指定してください`);
return;
}
setSubmitting(true);
try {
await onCreate(filename, config.body);
setName('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="border-t border-hairline pt-4 mt-4">
<h3 className="text-[13px] font-semibold text-slate-900 mb-2">
{label}
</h3>
<div className="flex items-stretch gap-2 max-w-md">
<input
type="text"
className="flex-1 min-w-0 border border-hairline rounded px-2 py-1 text-[13px] font-mono"
placeholder={`ファイル名 (拡張子なし、${config.ext} は自動付与)`}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={submitting}
pattern="[a-zA-Z0-9_-]+"
/>
<button
type="submit"
className="shrink-0 px-3 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={submitting || !name.trim()}
>
{submitting ? '作成中…' : '作成'}
</button>
</div>
{error && (
<div className="mt-2 text-xs text-red-600">{error}</div>
)}
<p className="mt-2 text-2xs text-slate-500">
(frontmatter + main )
</p>
</form>
);
}
+354
View File
@@ -0,0 +1,354 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface NotesPanelProps {
filePath: string | null; // e.g. "cve/foo.md"
onSaved?: () => void;
onSelectFile?: (filePath: string) => void; // called after new note created
}
interface ParsedFm {
title: string;
visibility: 'private' | 'org' | 'public';
scope_org_id: string;
mode_hint: '' | 'search' | 'inject';
tags: string;
body: string;
}
function parseMdToFmAndBody(md: string): ParsedFm {
// Lightweight FM parse (UI-side)
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(md);
if (!m) {
return { title: '', visibility: 'private', scope_org_id: '', mode_hint: '', tags: '', body: md };
}
const fmText = m[1]!;
const body = m[2] ?? '';
const get = (k: string): string => {
const re = new RegExp(`^${k}:\\s*(.+)$`, 'm');
const found = re.exec(fmText);
return found ? found[1]!.trim().replace(/^['"]|['"]$/g, '') : '';
};
const tagsArr = /^tags:\s*\[([^\]]*)\]$/m.exec(fmText);
return {
title: get('title'),
visibility: (get('visibility') as ParsedFm['visibility']) || 'private',
scope_org_id: get('scope_org_id'),
mode_hint: (get('mode_hint') as ParsedFm['mode_hint']) || '',
tags: tagsArr ? tagsArr[1]!.split(',').map((s) => s.trim()).filter(Boolean).join(', ') : '',
body,
};
}
function serializeFm(p: ParsedFm): string {
const fm: string[] = ['---'];
if (p.title) fm.push(`title: ${p.title}`);
fm.push(`visibility: ${p.visibility}`);
if (p.visibility === 'org' && p.scope_org_id) fm.push(`scope_org_id: ${p.scope_org_id}`);
if (p.mode_hint) fm.push(`mode_hint: ${p.mode_hint}`);
const tagsArr = p.tags.split(',').map((s) => s.trim()).filter(Boolean);
if (tagsArr.length > 0) fm.push(`tags: [${tagsArr.join(', ')}]`);
fm.push('---');
fm.push('');
fm.push(p.body);
return fm.join('\n');
}
const NAME_RE = /^[a-zA-Z0-9._-]+$/;
function NewNoteForm({ onCreated }: { onCreated: (filePath: string) => void }) {
const qc = useQueryClient();
const [folder, setFolder] = useState('');
const [fileName, setFileName] = useState('');
const [error, setError] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
const fn = fileName.endsWith('.md') ? fileName : `${fileName}.md`;
if (!NAME_RE.test(folder)) { setError('フォルダー名は英数字・. - _ のみ'); return; }
if (!NAME_RE.test(fn)) { setError('ファイル名は英数字・. - _ のみ (.md で終わる)'); return; }
setError('');
setCreating(true);
try {
const stub = `---\nvisibility: private\n---\n\n`;
const r = await fetch(
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(`${folder}/${fn}`)}`,
{
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: stub,
},
);
if (!r.ok) {
const j = await r.json().catch(() => ({ error: 'save failed' }));
throw new Error((j as { error?: string }).error ?? 'save failed');
}
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
onCreated(`${folder}/${fn}`);
} catch (err) {
setError((err as Error).message);
} finally {
setCreating(false);
}
};
return (
<div className="mt-6 border border-hairline rounded-md p-4 bg-surface-2/40">
<p className="text-[13px] font-semibold text-slate-700 mb-3"> Note </p>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<input
className="flex-1 border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
placeholder="フォルダー名 (例: cve)"
value={folder}
onChange={(e) => setFolder(e.target.value)}
/>
<span className="text-slate-400 text-[13px]">/</span>
<input
className="flex-1 border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
placeholder="ファイル名 (例: foo.md)"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
/>
</div>
{error && <p className="text-2xs text-red-600">{error}</p>}
<button
type="button"
className="self-start px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={creating || !folder || !fileName}
onClick={handleCreate}
>
{creating ? '作成中…' : '+ New Note'}
</button>
</div>
</div>
);
}
export function NotesPanel({ filePath, onSaved, onSelectFile }: NotesPanelProps) {
const qc = useQueryClient();
const { data: fileText, isLoading, isError } = useQuery({
queryKey: ['notes-file', filePath],
queryFn: async () => {
if (!filePath) return '';
const r = await fetch(
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(filePath)}`,
{ credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
return r.text();
},
enabled: !!filePath,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
const [state, setState] = useState<ParsedFm>({
title: '',
visibility: 'private',
scope_org_id: '',
mode_hint: '',
tags: '',
body: '',
});
useEffect(() => {
if (fileText !== undefined) setState(parseMdToFmAndBody(fileText));
}, [fileText]);
const save = useMutation({
mutationFn: async () => {
if (!filePath) return;
const md = serializeFm(state);
const r = await fetch(
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(filePath)}`,
{
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: md,
},
);
if (!r.ok) {
const j = await r.json().catch(() => ({ error: 'save failed' }));
throw new Error((j as { error?: string }).error ?? 'save failed');
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notes-file', filePath] });
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
onSaved?.();
},
});
if (!filePath) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-6 flex gap-3">
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>📝</span>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-slate-900">Notes ()</h2>
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">
Markdown
visibility
</p>
</div>
</div>
<div className="bg-surface-2 rounded-md p-4 text-[13px] font-mono text-slate-600 whitespace-pre leading-relaxed">
{`# ファイル構成: notes/<folder>/<file>.md
# 例: notes/cve/CVE-2026-1234.md
---
title: CVE-2026-1234 メモ
visibility: public # private | org | public
mode_hint: inject # 推奨モード (任意)
tags: [security, cve]
---
## 概要
...`}
</div>
<div className="mt-6 text-[13px] text-slate-500 space-y-2">
<p>
<span className="font-semibold text-slate-700">:</span>{' '}
Subscribed Notes notes
</p>
<p>
<span className="font-semibold text-slate-700">:</span>{' '}
SearchNotes / ReadNote / WriteNote
</p>
</div>
<NewNoteForm onCreated={(path) => {
onSaved?.();
onSelectFile?.(path);
}} />
</div>
</div>
);
}
if (isLoading) {
return (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading
</div>
);
}
if (isError) {
return (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
</div>
);
}
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/30">
<div className="flex items-center justify-between">
<div>
<h2 className="text-[13px] font-semibold text-slate-900 font-mono">{filePath}</h2>
<p className="text-2xs text-slate-500 mt-0.5">
frontmatter
</p>
</div>
<button
type="button"
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? 'Saving…' : 'Save'}
</button>
</div>
{save.isError && (
<p className="mt-1 text-2xs text-red-600">{(save.error as Error).message}</p>
)}
</div>
{/* FM form */}
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/20">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{/* Title */}
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Title</span>
<input
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
value={state.title}
onChange={(e) => setState({ ...state, title: e.target.value })}
placeholder="(optional)"
/>
</label>
{/* Visibility */}
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Visibility</span>
<select
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
value={state.visibility}
onChange={(e) => setState({ ...state, visibility: e.target.value as ParsedFm['visibility'] })}
>
<option value="private">private</option>
<option value="org">org</option>
<option value="public">public</option>
</select>
</label>
{/* Scope org id — conditional */}
{state.visibility === 'org' && (
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Scope Org ID</span>
<input
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
value={state.scope_org_id}
onChange={(e) => setState({ ...state, scope_org_id: e.target.value })}
placeholder="gitea-org-name"
/>
</label>
)}
{/* Mode hint */}
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Mode Hint</span>
<select
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
value={state.mode_hint}
onChange={(e) => setState({ ...state, mode_hint: e.target.value as ParsedFm['mode_hint'] })}
>
<option value="">(none)</option>
<option value="search">search</option>
<option value="inject">inject</option>
</select>
</label>
{/* Tags */}
<label className="flex flex-col gap-0.5 col-span-2">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Tags ()</span>
<input
className="border border-hairline rounded px-2 py-1 text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent"
value={state.tags}
onChange={(e) => setState({ ...state, tags: e.target.value })}
placeholder="security, cve, ..."
/>
</label>
</div>
</div>
{/* Markdown body */}
<div className="flex-1 min-h-0 overflow-hidden">
<textarea
className="w-full h-full resize-none p-4 font-mono text-[13px] text-slate-800 bg-white focus:outline-none"
value={state.body}
onChange={(e) => setState({ ...state, body: e.target.value })}
placeholder="Markdown body…"
spellCheck={false}
/>
</div>
</div>
);
}
+501
View File
@@ -0,0 +1,501 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import {
deletePet,
fetchPets,
fetchWorkerBackends,
fetchWorkers,
importPet,
petAssetUrl,
updatePetSettings,
type PetSettings,
type PetSummary,
type WorkerBackendsResponse,
type WorkerInfo,
} from '../../api';
import { PetSprite } from '../pets/PetSprite';
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
function ToggleRow({
label,
checked,
onChange,
disabled,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<label className="flex items-center justify-between gap-3 py-2">
<span className="text-[13px] font-medium text-slate-700">{label}</span>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full text-left transition-colors ${
checked ? 'bg-accent' : 'bg-slate-200'
}`}
>
<span
className={`absolute left-0 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</label>
);
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const q = window.matchMedia('(prefers-reduced-motion: reduce)');
const update = () => setReduced(q.matches);
update();
q.addEventListener('change', update);
return () => q.removeEventListener('change', update);
}, []);
return reduced;
}
function PetPreview({ pet, size = 56 }: { pet: PetSummary; size?: number }) {
const reduced = usePrefersReducedMotion();
const spriteUrl = pet.spriteFile ? petAssetUrl(pet.id, pet.spriteFile) : null;
const framesPerRow = usePetFrameAnalysis(spriteUrl, pet.gridCols, pet.gridRows);
const canAnimate = !!(spriteUrl && pet.gridCols && pet.gridRows);
return (
<div className="rounded-md border border-hairline bg-surface overflow-hidden grid place-items-center shrink-0" style={{ width: size, height: size }}>
{canAnimate ? (
<PetSprite
name={pet.name}
imageUrl={spriteUrl}
frameWidth={pet.frameWidth}
frameHeight={pet.frameHeight}
gridCols={pet.gridCols}
gridRows={pet.gridRows}
framesPerRow={framesPerRow}
state="idle"
size={size}
reducedMotion={reduced}
/>
) : pet.previewFile || pet.spriteFile ? (
<img
src={petAssetUrl(pet.id, (pet.previewFile ?? pet.spriteFile)!)}
alt=""
className="w-full h-full object-contain"
draggable={false}
/>
) : (
<span className="text-slate-400 text-xs font-mono">pet</span>
)}
</div>
);
}
function WorkerMappingRow({
workerLabel,
workerSubLabel,
selectedPetId,
pets,
disabled,
onChange,
}: {
workerLabel: string;
workerSubLabel?: string | null;
selectedPetId: string;
pets: PetSummary[];
disabled: boolean;
onChange: (petId: string) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-slate-700 truncate">{workerLabel}</div>
{workerSubLabel && (
<div className="text-2xs text-slate-500 font-mono truncate">{workerSubLabel}</div>
)}
</div>
<select
className="input w-40"
value={selectedPetId}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
<option value=""></option>
{pets.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
);
}
export function PetsPanel({ showToast }: { showToast?: ShowToast }) {
const inputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const qc = useQueryClient();
const query = useQuery({
queryKey: ['user-pets'],
queryFn: fetchPets,
staleTime: 30_000,
});
const workersQuery = useQuery<WorkerInfo[]>({
queryKey: ['workers'],
queryFn: fetchWorkers,
staleTime: 60_000,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['user-pets'] });
};
const importMutation = useMutation({
mutationFn: (file: File) => importPet(file),
onSuccess: (pet) => {
setImportError(null);
showToast?.(`${pet.name} を import しました`, 'success');
invalidate();
},
onError: (err) => {
const message = err instanceof Error ? err.message : 'Import failed';
setImportError(message);
showToast?.(`Pet import に失敗しました: ${message}`, 'error');
},
});
const settingsMutation = useMutation({
mutationFn: (patch: Partial<PetSettings>) => updatePetSettings(patch),
onSuccess: () => {
invalidate();
},
onError: (err) => {
const message = err instanceof Error ? err.message : 'Settings update failed';
showToast?.(`Pet 設定の保存に失敗しました: ${message}`, 'error');
},
});
const deleteMutation = useMutation({
mutationFn: deletePet,
onSuccess: () => {
invalidate();
},
onError: (err) => {
const message = err instanceof Error ? err.message : 'Delete failed';
showToast?.(`Pet 削除に失敗しました: ${message}`, 'error');
},
});
const settings = query.data?.settings;
const pets = query.data?.pets ?? [];
const workers = workersQuery.data ?? [];
// Show the workers section whenever the user has at least one worker
// AND we have a settings row to read from. Two prior gates were
// dropped, each having broken a real verification flow:
//
// 1. `pets.length > 0` (removed earlier) hid the topology until
// a Pet was imported, even though the topology view is what
// operators want immediately after wiring a proxy worker.
// 2. `workers.length > 1` hid the section in single-proxy-worker
// setups — exactly the canonical "AAO client → AAO Gateway
// with N backends" deployment. The user has 1 worker config
// (the gateway) but the tree below expands to N backends that
// they want to assign Pets to. Requiring >1 worker turned the
// Pets page into a blank slate for this whole class of users.
//
// With workers.length >= 1, single-direct-worker setups show a
// mildly redundant "default + 1 worker" pair, which is harmless.
const showWorkerMapping = workers.length >= 1 && !!settings;
const noPetsYet = pets.length === 0;
const workerPets = settings?.workerPets ?? {};
// For each proxy worker, fetch its backend list so the panel can render
// a tree (worker → backends) and allow per-backend pet assignment. Direct
// workers don't fire the query (enabled=false) so we don't waste a
// round-trip on a known-empty result.
const proxyWorkers = workers.filter(w => w.proxy === true);
const backendsQueries = useQueries({
queries: proxyWorkers.map(w => ({
queryKey: ['worker-backends', w.id] as const,
queryFn: () => fetchWorkerBackends(w.id),
staleTime: 60_000,
})),
});
const backendsByWorker = new Map<string, WorkerBackendsResponse | undefined>();
proxyWorkers.forEach((w, i) => backendsByWorker.set(w.id, backendsQueries[i]?.data));
// Per-proxy-worker collapsed/expanded state. Default: expanded so the
// user sees their backends immediately on first load; toggling is a
// local convenience for installs with many backends per pool.
const [collapsedProxies, setCollapsedProxies] = useState<Record<string, boolean>>({});
const toggleCollapse = (workerId: string) => {
setCollapsedProxies(prev => ({ ...prev, [workerId]: !prev[workerId] }));
};
const updateSettings = (patch: Partial<PetSettings>) => {
settingsMutation.mutate(patch);
};
const setDefaultPet = (petId: string) => {
updateSettings({ activePetId: petId === '' ? null : petId });
};
const setWorkerPet = (workerId: string, petId: string) => {
const next = { ...workerPets };
if (petId === '') {
delete next[workerId];
} else {
next[workerId] = petId;
}
updateSettings({ workerPets: next });
};
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">Codex Pets</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
Codex Pets zip import Chat pet
</p>
</div>
<div className="flex items-center gap-2 mb-6">
<input
ref={inputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) importMutation.mutate(file);
}}
/>
<button
type="button"
className="btn btn-primary"
disabled={importMutation.isPending}
onClick={() => inputRef.current?.click()}
>
{importMutation.isPending ? 'Importing…' : 'Pet zip を import'}
</button>
{importError && <span className="text-xs text-red-600 truncate">{importError}</span>}
</div>
{query.isLoading && <div className="text-[13px] text-slate-400">Loading</div>}
{query.error && <div className="text-[13px] text-red-500">: {String(query.error)}</div>}
{settings && (
<div className="mb-6 border-y border-hairline py-2">
<ToggleRow
label="Chat 画面に表示"
checked={settings.enabled}
disabled={settingsMutation.isPending}
onChange={(enabled) => updateSettings({ enabled })}
/>
<ToggleRow
label="Tool Spark を表示"
checked={settings.toolSparkEnabled}
disabled={settingsMutation.isPending}
onChange={(toolSparkEnabled) => updateSettings({ toolSparkEnabled })}
/>
<ToggleRow
label="動きを抑える"
checked={settings.reducedMotion}
disabled={settingsMutation.isPending}
onChange={(reducedMotion) => updateSettings({ reducedMotion })}
/>
<div className="flex items-center justify-between gap-3 py-2">
<span className="text-[13px] font-medium text-slate-700"></span>
<select
className="input w-24"
value={settings.size}
disabled={settingsMutation.isPending}
onChange={(event) => updateSettings({ size: Number(event.target.value) as PetSettings['size'] })}
>
<option value={32}>32</option>
<option value={48}>48</option>
<option value={64}>64</option>
<option value={80}>80</option>
</select>
</div>
</div>
)}
{showWorkerMapping && (
<section className="mb-6 border-y border-hairline py-2" aria-labelledby="workers-heading">
<div className="flex items-baseline justify-between pt-1 pb-2">
<h3 id="workers-heading" className="text-[13px] font-semibold text-slate-700">
Workers ({workers.length})
</h3>
<span className="text-2xs text-slate-500">
{noPetsYet ? 'Worker / GPU pool 構成を確認できます' : 'Worker ごとに表示する pet を割り当て'}
</span>
</div>
{noPetsYet && (
<div className="text-2xs text-slate-500 bg-slate-50 border border-slate-200 rounded px-2 py-1 mb-2">
pet import Worker / backend
</div>
)}
<WorkerMappingRow
workerLabel="default (未割当)"
workerSubLabel="activePetId と同義"
selectedPetId={settings?.activePetId ?? ''}
pets={pets}
disabled={settingsMutation.isPending || noPetsYet}
onChange={setDefaultPet}
/>
{workers.map(worker => {
const isProxy = worker.proxy === true;
const rolesLabel = worker.roles.length > 0 ? worker.roles.join(',') : null;
const subLabel = isProxy
? `proxy${worker.proxyType ? `: ${worker.proxyType}` : ''}${rolesLabel ? ` · ${rolesLabel}` : ''}`
: rolesLabel;
if (!isProxy) {
return (
<WorkerMappingRow
key={worker.id}
workerLabel={worker.id}
workerSubLabel={subLabel}
selectedPetId={workerPets[worker.id] ?? ''}
pets={pets}
disabled={settingsMutation.isPending || noPetsYet}
onChange={(petId) => setWorkerPet(worker.id, petId)}
/>
);
}
// Proxy worker — collapsible tree rendering. The worker row
// itself maps to workerPets[worker.id] (fallback when no
// backend matches); each backend row maps to workerPets[backendId].
const backendsResp = backendsByWorker.get(worker.id);
const backends = backendsResp?.backends ?? [];
const collapsed = !!collapsedProxies[worker.id];
const backendError = backendsResp?.error ?? null;
return (
<div key={worker.id} className="border-l-2 border-slate-100 ml-1 pl-2">
<div className="flex items-center justify-between gap-2 py-2">
<button
type="button"
onClick={() => toggleCollapse(worker.id)}
className="flex-1 min-w-0 text-left"
aria-expanded={!collapsed}
>
<div className="text-[13px] font-medium text-slate-700 truncate">
<span className="inline-block w-3 text-slate-400">{collapsed ? '▶' : '▼'}</span>
{' '}
{worker.id}
<span className="ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
proxy
</span>
</div>
{subLabel && (
<div className="text-2xs text-slate-500 font-mono truncate pl-5">{subLabel}</div>
)}
</button>
<select
className="input w-40"
value={workerPets[worker.id] ?? ''}
disabled={settingsMutation.isPending || noPetsYet}
onChange={(event) => setWorkerPet(worker.id, event.target.value)}
title="proxy 既定 (どの backend にも個別マッピングが無い場合)"
>
<option value=""> / </option>
{pets.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{!collapsed && (
<div className="pl-5">
{backendError && (
<div className="text-2xs text-red-600 py-1">
backend : {backendError}
</div>
)}
{!backendError && backends.length === 0 && (
<div className="text-2xs text-slate-400 py-1">
backend (proxy /v1/models )
</div>
)}
{backends.map(backend => (
<WorkerMappingRow
key={backend.id}
workerLabel={backend.id}
workerSubLabel={backend.model && backend.model !== backend.id ? backend.model : null}
selectedPetId={workerPets[backend.id] ?? ''}
pets={pets}
disabled={settingsMutation.isPending || noPetsYet}
onChange={(petId) => setWorkerPet(backend.id, petId)}
/>
))}
</div>
)}
</div>
);
})}
</section>
)}
{!query.isLoading && pets.length === 0 && (
<div className="text-[13px] text-slate-400">
pet `pet.json` zip import
</div>
)}
<ul className="divide-y divide-hairline">
{pets.map((pet) => {
const active = settings?.activePetId === pet.id;
return (
<li key={pet.id} className="py-3 flex items-center gap-3">
<PetPreview pet={pet} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-slate-900 truncate">{pet.name}</span>
{active && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">
default
</span>
)}
</div>
<div className="text-2xs text-slate-500 font-mono truncate">{pet.id}</div>
{pet.description && <p className="text-xs text-slate-500 mt-1 line-clamp-2">{pet.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
className="btn btn-ghost"
disabled={active || settingsMutation.isPending}
onClick={() => updateSettings({ activePetId: pet.id })}
>
</button>
<button
type="button"
className="btn btn-danger"
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm(`${pet.name} を削除しますか?`)) {
deleteMutation.mutate(pet.id);
}
}}
>
</button>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,329 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listBrowserSessionProfiles } from '../../api';
export interface ParamHint {
name: string;
valueToReplace: string;
type: 'string' | 'number' | 'boolean';
}
interface SaveAsScriptDialogProps {
/** The name of the recording (without .json extension). */
recordingName: string;
onClose: () => void;
/** Called with the new script filename (e.g. "my-script.js") after a successful compile. */
onSuccess: (scriptName: string) => void;
}
const SCRIPT_NAME_RE = /^[A-Za-z0-9_\-.]+$/;
async function apiCompileScript(body: {
recordingName: string;
scriptName: string;
description: string;
sessionProfileId?: number;
paramHints?: ParamHint[];
overwrite?: boolean;
}): Promise<{ ok: boolean; scriptName: string }> {
const { overwrite, ...rest } = body;
const qs = overwrite ? '?overwrite=true' : '';
const res = await fetch(`/api/users/me/browser-macros/compile${qs}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const err = new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
(err as any).status = res.status;
throw err;
}
return res.json();
}
function emptyHint(): ParamHint {
return { name: '', valueToReplace: '', type: 'string' };
}
export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAsScriptDialogProps) {
const qc = useQueryClient();
const [scriptName, setScriptName] = useState(recordingName);
const [description, setDescription] = useState('');
const [sessionProfileId, setSessionProfileId] = useState('');
const [paramHints, setParamHints] = useState<ParamHint[]>([]);
const [overwrite, setOverwrite] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [conflictError, setConflictError] = useState<string | null>(null);
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
staleTime: 60 * 1000,
});
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
const compileMutation = useMutation({
mutationFn: apiCompileScript,
onSuccess: (data) => {
// Invalidate browser-macros list so the new file appears
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
onSuccess(data.scriptName);
},
onError: (err: any) => {
if (err.status === 409) {
setConflictError('Script already exists; check the overwrite box and retry.');
}
// Other errors surface via compileMutation.error
},
});
function validate(): boolean {
if (!scriptName.trim()) {
setValidationError('Script name is required.');
return false;
}
const nameWithoutExt = scriptName.endsWith('.js') ? scriptName.slice(0, -3) : scriptName;
if (!SCRIPT_NAME_RE.test(nameWithoutExt)) {
setValidationError('Script name may only contain letters, numbers, dashes, underscores, and dots.');
return false;
}
if (!description.trim()) {
setValidationError('Description is required.');
return false;
}
for (let i = 0; i < paramHints.length; i++) {
const h = paramHints[i]!;
if (!h.name.trim() || !h.valueToReplace.trim()) {
setValidationError(`Param hint #${i + 1} must have a name and value to replace.`);
return false;
}
}
setValidationError(null);
setConflictError(null);
return true;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const profileId = sessionProfileId.trim() !== '' ? parseInt(sessionProfileId, 10) : undefined;
compileMutation.mutate({
recordingName,
scriptName: scriptName.trim(),
description: description.trim(),
sessionProfileId: profileId !== undefined && !isNaN(profileId) ? profileId : undefined,
paramHints: paramHints.length > 0 ? paramHints : undefined,
overwrite,
});
}
function addHint() {
setParamHints(prev => [...prev, emptyHint()]);
}
function updateHint(idx: number, patch: Partial<ParamHint>) {
setParamHints(prev => prev.map((h, i) => i === idx ? { ...h, ...patch } : h));
}
function removeHint(idx: number) {
setParamHints(prev => prev.filter((_, i) => i !== idx));
}
const isSubmitting = compileMutation.isPending;
const submitError = compileMutation.isError && !conflictError
? ((compileMutation.error as any)?.message ?? 'Compile failed')
: null;
return (
/* Backdrop */
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-hairline">
<span className="text-sm font-semibold text-slate-800 flex-1">Save as Script</span>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-2 text-slate-400 hover:text-slate-700 transition-colors"
aria-label="Close"
>
<svg viewBox="0 0 16 16" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-5 py-4 overflow-y-auto flex-1">
{/* Recording name (read-only) */}
<div className="flex flex-col gap-1">
<label className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Recording
</label>
<div className="px-3 py-2 rounded-md bg-surface-2 text-xs font-mono text-slate-700 border border-hairline">
{recordingName}.json
</div>
</div>
{/* Script name */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-script-name" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Script name <span className="text-red-500">*</span>
</label>
<input
id="sas-script-name"
type="text"
value={scriptName}
onChange={e => setScriptName(e.target.value)}
placeholder="my-script"
className="px-3 py-2 rounded-md border border-hairline text-[13px] font-mono focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
<span className="text-[10px] text-slate-400">Alphanumeric, dashes, underscores, dots. A .js extension will be added automatically.</span>
</div>
{/* Description */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-description" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Description <span className="text-red-500">*</span>
</label>
<textarea
id="sas-description"
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder="What does this script do?"
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 resize-none"
/>
</div>
{/* Session profile */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-session-profile" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Session profile <span className="text-slate-400 font-normal">(optional)</span>
</label>
{profilesLoading ? (
<div className="px-3 py-2 text-xs text-slate-500">Loading profiles</div>
) : activeSessionProfiles.length === 0 ? (
<div className="px-3 py-2 text-xs text-slate-500">
No active session profiles. Create one in the Browser tab to enable authenticated automation.
</div>
) : (
<select
id="sas-session-profile"
value={sessionProfileId}
onChange={e => setSessionProfileId(e.target.value)}
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"
>
<option value="">None</option>
{activeSessionProfiles.map(p => (
<option key={p.id} value={String(p.id)}>{p.label} (#{p.id})</option>
))}
</select>
)}
</div>
{/* Param hints */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide flex-1">
Param hints <span className="text-slate-400 font-normal">(optional)</span>
</span>
<button
type="button"
onClick={addHint}
className="text-2xs px-2 py-1 rounded border border-hairline text-slate-600 hover:bg-surface-2 transition-colors"
>
+ Add hint
</button>
</div>
{paramHints.map((hint, idx) => (
<div key={idx} className="flex gap-2 items-start p-2 rounded-md border border-hairline bg-surface-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<input
type="text"
value={hint.name}
onChange={e => updateHint(idx, { name: e.target.value })}
placeholder="param name"
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<input
type="text"
value={hint.valueToReplace}
onChange={e => updateHint(idx, { valueToReplace: e.target.value })}
placeholder="value to replace (literal)"
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<select
value={hint.type}
onChange={e => updateHint(idx, { type: e.target.value as ParamHint['type'] })}
className="px-2 py-1 rounded border border-hairline text-2xs bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<button
type="button"
onClick={() => removeHint(idx)}
aria-label="Remove hint"
className="mt-1 w-5 h-5 flex-shrink-0 flex items-center justify-center rounded hover:bg-red-100 hover:text-red-600 text-slate-400 transition-colors"
>
<svg viewBox="0 0 16 16" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
))}
</div>
{/* Overwrite checkbox */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={overwrite}
onChange={e => { setOverwrite(e.target.checked); setConflictError(null); }}
className="rounded border-hairline accent-accent"
/>
<span className="text-xs text-slate-700">Overwrite if script already exists</span>
</label>
{/* Errors */}
{(validationError || conflictError || submitError) && (
<div className="px-3 py-2 rounded-md bg-red-50 border border-red-200 text-xs text-red-700">
{validationError ?? conflictError ?? submitError}
</div>
)}
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline bg-surface-2/50">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-1.5 rounded-md text-xs font-medium text-slate-700 hover:bg-surface-2 disabled:opacity-50 transition-colors border border-hairline"
>
Cancel
</button>
<button
type="submit"
form=""
onClick={handleSubmit}
disabled={isSubmitting}
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Compiling…' : 'Save as Script'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,163 @@
/**
* ScriptDiffReview — side-by-side diff view for a pending .next.js patch.
*
* Design choice: .next.js files appear as sibling rows in the FileTree just like
* any other file. Clicking a .next.js file opens this component (instead of
* MonacoFileEditor). The diff view shows the current .js on the left (original)
* and the candidate .next.js on the right (modified), read-only.
*
* Accept: archives scripts/{name}.js to trash, renames .next.js into place.
* Reject: moves .next.js to trash; original is untouched.
* Both actions invalidate the scripts listing and navigate back to scripts/{name}.js.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DiffEditor } from '@monaco-editor/react';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
interface ScriptDiffReviewProps {
/** The bare script name without extension, e.g. "myscript" */
scriptName: string;
onClose: (acceptedScript?: string) => void;
showToast?: ShowToast;
}
interface DiffResponse {
current: string | null;
candidate: string;
candidateMtime: string;
}
async function fetchDiff(name: string): Promise<DiffResponse> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/diff`, {
credentials: 'include',
});
if (res.status === 404) throw new Error('No pending patch found.');
if (!res.ok) throw new Error(`Diff fetch failed: ${res.status}`);
return res.json() as Promise<DiffResponse>;
}
async function postAccept(name: string): Promise<void> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/accept`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`Accept failed: ${res.status}`);
}
async function postReject(name: string): Promise<void> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/reject`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`Reject failed: ${res.status}`);
}
export function ScriptDiffReview({ scriptName, onClose, showToast }: ScriptDiffReviewProps) {
const qc = useQueryClient();
const notifyError = (label: string, err: unknown) => {
const msg = `${label}: ${err instanceof Error ? err.message : 'Unknown error'}`;
if (showToast) showToast(msg, 'error');
else console.error(msg);
};
const diffQuery = useQuery<DiffResponse, Error>({
queryKey: ['userfolder', 'diff', scriptName],
queryFn: () => fetchDiff(scriptName),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
const candidateMtimeLabel = diffQuery.data?.candidateMtime
? new Date(diffQuery.data.candidateMtime).toLocaleString()
: '';
async function handleAccept() {
try {
await postAccept(scriptName);
// Invalidate browser-macros listing so the .next.js row disappears
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
// Navigate back to the now-accepted script
onClose(`${scriptName}.js`);
} catch (err) {
notifyError('Accept failed', err);
}
}
async function handleReject() {
try {
await postReject(scriptName);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
// Navigate back to the original (unchanged) script if it exists; otherwise close
const hasOriginal = diffQuery.data?.current !== null;
onClose(hasOriginal ? `${scriptName}.js` : undefined);
} catch (err) {
notifyError('Reject failed', err);
}
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-hairline bg-surface-2/50">
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-slate-700">Patch review: </span>
<span className="font-mono text-xs text-slate-600">{scriptName}.next.js</span>
{candidateMtimeLabel && (
<span className="ml-2 text-2xs text-slate-400">{candidateMtimeLabel}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-2xs text-slate-400 font-mono">original patch</span>
<button
type="button"
onClick={handleReject}
disabled={diffQuery.isLoading}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
Reject
</button>
<button
type="button"
onClick={handleAccept}
disabled={diffQuery.isLoading}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
>
Accept
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-hidden">
{diffQuery.isLoading && (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading diff
</div>
)}
{diffQuery.isError && (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
{diffQuery.error?.message ?? 'Failed to load diff.'}
</div>
)}
{diffQuery.data && (
<DiffEditor
height="100%"
language="javascript"
original={diffQuery.data.current ?? ''}
modified={diffQuery.data.candidate}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
}}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,11 @@
import { SkillsForm } from '../settings/SkillsForm';
export function SkillsPanel() {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8">
<SkillsForm />
</div>
</div>
);
}
@@ -0,0 +1,410 @@
import { useState, useEffect } from 'react';
import type { SshConnection } from '../../lib/ssh-types';
interface SshConnectionFormProps {
/** Existing connection for edit; null for create. */
existing: SshConnection | null;
/** True when rendered in admin context — exposes admin-only flags. */
adminContext: boolean;
/** Submit handler; receives the request body and returns a Promise. */
onSubmit: (body: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
type KeypairSource = 'provided' | 'generate';
type GeneratedKeyType = 'ed25519' | 'rsa-4096';
interface FormState {
label: string;
host: string;
port: string;
username: string;
keypairSource: KeypairSource;
generateKeyType: GeneratedKeyType;
privateKeyPem: string;
passphrase: string;
remotePathPrefix: string;
commandDenyPatterns: string;
commandAllowPatterns: string;
allowRemoteUnrestricted: boolean;
allowPrivateAddresses: boolean;
reason: string;
}
function initialFromExisting(existing: SshConnection | null): FormState {
return {
label: existing?.label ?? '',
host: existing?.host ?? '',
port: existing ? String(existing.port) : '22',
username: existing?.username ?? '',
keypairSource: 'provided',
generateKeyType: 'ed25519',
privateKeyPem: '',
passphrase: '',
remotePathPrefix: existing?.remotePathPrefix ?? '/srv/agent',
commandDenyPatterns: existing?.commandDenyPatterns ?? '',
commandAllowPatterns: existing?.commandAllowPatterns ?? '',
allowRemoteUnrestricted: existing?.allowRemoteUnrestricted ?? false,
allowPrivateAddresses: existing?.allowPrivateAddresses ?? false,
reason: '',
};
}
/**
* Create/edit form for an SSH connection.
*
* - Create: `existing === null`, all required fields visible, privateKeyPem required.
* - Edit: `existing !== null`, privateKeyPem optional (omitted = keep current key).
* - Admin context shows `allowRemoteUnrestricted` and `allowPrivateAddresses` toggles.
* These flags are admin-only at the API layer; user-context renders them omitted.
* - When `adminContext && isCreate`: also collect `reason` (required ≥ 8 chars) for the
* audit row that POST /admin/globals will write. Edit reasons go via PATCH.
*/
export function SshConnectionForm({ existing, adminContext, onSubmit, onCancel }: SshConnectionFormProps) {
const isCreate = existing === null;
const [state, setState] = useState<FormState>(() => initialFromExisting(existing));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setState(initialFromExisting(existing));
setError(null);
}, [existing?.id]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setState(prev => ({ ...prev, [key]: value }));
}
const portNum = Number(state.port);
const portValid = Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
const remotePathOk = state.allowRemoteUnrestricted
? true
: (() => {
const p = state.remotePathPrefix.trim();
if (p.length === 0) return false;
// Accept POSIX (`/srv/agent`), Windows drive (`C:\Users\agent`),
// UNC (`\\server\share`), or no-leading-slash prefixes. Reject any
// `..` parent-ref segment in either separator.
return !p.split(/[\\/]/).includes('..');
})();
const needsUploadedKey = isCreate && state.keypairSource === 'provided';
const baseValid =
state.label.trim().length > 0 &&
state.host.trim().length > 0 &&
state.username.trim().length > 0 &&
portValid &&
remotePathOk &&
(!needsUploadedKey || state.privateKeyPem.length > 0);
const reasonNeeded = adminContext;
const reasonValid = !reasonNeeded || state.reason.trim().length >= 8;
const canSubmit = baseValid && reasonValid && !submitting;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
const body: Record<string, unknown> = {
label: state.label.trim(),
host: state.host.trim(),
port: portNum,
username: state.username.trim(),
};
// Keypair handling. Only meaningful at create-time; on edit we never
// re-key (a separate rotation flow handles that).
if (isCreate && state.keypairSource === 'generate') {
body.keypairSource = 'generate';
body.generateKeyType = state.generateKeyType;
} else {
if (state.privateKeyPem.length > 0) {
body.privateKeyPem = state.privateKeyPem;
}
if (state.passphrase.length > 0) {
body.passphrase = state.passphrase;
}
}
if (state.allowRemoteUnrestricted && adminContext) {
body.allowRemoteUnrestricted = true;
} else {
body.remotePathPrefix = state.remotePathPrefix.trim();
}
if (adminContext && state.allowPrivateAddresses) {
body.allowPrivateAddresses = true;
}
if (state.commandDenyPatterns.trim().length > 0) {
body.commandDenyPatterns = state.commandDenyPatterns.trim();
} else if (existing?.commandDenyPatterns) {
body.commandDenyPatterns = '';
}
if (state.commandAllowPatterns.trim().length > 0) {
body.commandAllowPatterns = state.commandAllowPatterns.trim();
} else if (existing?.commandAllowPatterns) {
body.commandAllowPatterns = '';
}
if (reasonNeeded) {
body.reason = state.reason.trim();
}
try {
await onSubmit(body);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Label" required>
<input
type="text"
value={state.label}
onChange={e => update('label', e.target.value)}
className={inputCls}
placeholder="prod-db (任意の表示名)"
required
/>
</Field>
<Field label="Username" required>
<input
type="text"
value={state.username}
onChange={e => update('username', e.target.value)}
className={inputCls + ' font-mono'}
placeholder="agent"
required
/>
</Field>
<Field label="Host" required>
<input
type="text"
value={state.host}
onChange={e => update('host', e.target.value)}
className={inputCls + ' font-mono'}
placeholder="db.example.com"
required
/>
</Field>
<Field label="Port" required>
<input
type="number"
value={state.port}
onChange={e => update('port', e.target.value)}
className={inputCls + ' font-mono'}
min={1}
max={65535}
required
/>
</Field>
</div>
{isCreate && (
<fieldset className="rounded border border-hairline bg-surface/40 p-3 space-y-2">
<legend className="px-1 text-2xs font-semibold text-slate-600 uppercase tracking-wide">
</legend>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="radio"
name="keypairSource"
value="provided"
checked={state.keypairSource === 'provided'}
onChange={() => update('keypairSource', 'provided')}
className="mt-0.5"
/>
<span>
<span className="font-semibold"></span>
<span className="block text-2xs text-slate-600">
<code className="font-mono">ssh-keygen</code> upload
</span>
</span>
</label>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="radio"
name="keypairSource"
value="generate"
checked={state.keypairSource === 'generate'}
onChange={() => update('keypairSource', 'generate')}
className="mt-0.5"
/>
<span>
<span className="font-semibold">Orchestrator </span>
<span className="block text-2xs text-slate-600">
Orchestrator
<code className="font-mono"> ~/.ssh/authorized_keys</code>
</span>
</span>
</label>
{state.keypairSource === 'generate' && (
<Field label="Key type">
<select
value={state.generateKeyType}
onChange={e => update('generateKeyType', e.target.value as GeneratedKeyType)}
className={inputCls}
>
<option value="ed25519">Ed25519 (recommended, )</option>
<option value="rsa-4096">RSA 4096-bit ()</option>
</select>
</Field>
)}
</fieldset>
)}
{(!isCreate || state.keypairSource === 'provided') && (
<>
<Field
label={`Private Key (PEM)${isCreate ? '' : ' ← 空欄なら現在のキーを維持'}`}
required={isCreate && state.keypairSource === 'provided'}
>
<textarea
value={state.privateKeyPem}
onChange={e => update('privateKeyPem', e.target.value)}
className={inputCls + ' font-mono h-32 resize-y'}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
spellCheck={false}
autoComplete="off"
required={isCreate && state.keypairSource === 'provided'}
/>
<p className="text-2xs text-slate-500 mt-1">
AES-256-GCM SHA1 RSA reject
</p>
</Field>
<Field label="Passphrase ← キーが encrypted な場合のみ">
<input
type="password"
value={state.passphrase}
onChange={e => update('passphrase', e.target.value)}
className={inputCls + ' font-mono'}
autoComplete="new-password"
/>
</Field>
</>
)}
{!state.allowRemoteUnrestricted && (
<Field label="Remote Path Prefix" required>
<input
type="text"
value={state.remotePathPrefix}
onChange={e => update('remotePathPrefix', e.target.value)}
className={inputCls + ' font-mono'}
placeholder="/srv/agent"
required
/>
<p className="text-2xs text-slate-500 mt-1">
(Upload/Download)
</p>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Command Deny Patterns (1 行 1 regex)">
<textarea
value={state.commandDenyPatterns}
onChange={e => update('commandDenyPatterns', e.target.value)}
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
placeholder="^rm\s+-rf\s+/&#10;^dd\s"
spellCheck={false}
/>
</Field>
<Field label="Command Allow Patterns (空 = 全許可)">
<textarea
value={state.commandAllowPatterns}
onChange={e => update('commandAllowPatterns', e.target.value)}
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
placeholder="^psql\s&#10;^ls\b"
spellCheck={false}
/>
</Field>
</div>
{adminContext && (
<fieldset className="rounded border border-amber-200 bg-amber-50/50 p-3">
<legend className="px-1 text-2xs font-semibold text-amber-800 uppercase tracking-wide">
Admin-only flags
</legend>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="checkbox"
checked={state.allowRemoteUnrestricted}
onChange={e => update('allowRemoteUnrestricted', e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-semibold">Allow remote unrestricted</span>
<span className="block text-2xs text-slate-600">
Remote Path Prefix
</span>
</span>
</label>
<label className="flex items-start gap-2 text-xs cursor-pointer mt-2">
<input
type="checkbox"
checked={state.allowPrivateAddresses}
onChange={e => update('allowPrivateAddresses', e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-semibold">Allow private addresses</span>
<span className="block text-2xs text-slate-600">
RFC1918 / localhost SSRF
</span>
</span>
</label>
</fieldset>
)}
{reasonNeeded && (
<Field label="Reason (≥ 8 chars; 監査ログに残ります)" required>
<input
type="text"
value={state.reason}
onChange={e => update('reason', e.target.value)}
className={inputCls}
placeholder="新規ステージング用 global 接続を追加"
required
/>
</Field>
)}
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-hairline">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface disabled:opacity-50"
>
</button>
<button
type="submit"
disabled={!canSubmit}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
{submitting ? '保存中…' : isCreate ? '作成' : '更新'}
</button>
</div>
</form>
);
}
const inputCls = 'w-full text-xs px-2 py-1.5 border border-hairline rounded';
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</div>
{children}
</label>
);
}
@@ -0,0 +1,521 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
import { SshConnectionForm } from './SshConnectionForm';
import { SshHostKeyDialog } from './SshHostKeyDialog';
import { SshPublicKeyDialog } from './SshPublicKeyDialog';
interface ConnectionsResponse {
connections: SshConnection[];
}
interface CreateResponse {
connection: SshConnection;
publicKey?: string | null;
}
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
if (res.status === 404) {
return { list: [], sshDisabled: true };
}
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data: ConnectionsResponse = await res.json();
return { list: data.connections ?? [], sshDisabled: false };
}
async function apiCreate(body: Record<string, unknown>): Promise<CreateResponse> {
const res = await fetch('/api/ssh/connections', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(parseApiError(txt, res.status));
}
return (await res.json()) as CreateResponse;
}
async function apiGetPublicKey(id: string): Promise<string | null> {
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
credentials: 'include',
});
if (!res.ok) {
const txt = await res.text();
throw new Error(parseApiError(txt, res.status));
}
const json = (await res.json()) as { publicKey?: string | null };
return json.publicKey ?? null;
}
async function apiPatch(id: string, body: Record<string, unknown>): Promise<SshConnection> {
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(parseApiError(txt, res.status));
}
const json = (await res.json()) as { connection: SshConnection };
return json.connection;
}
async function apiDelete(id: string): Promise<void> {
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) {
const txt = await res.text();
throw new Error(parseApiError(txt, res.status));
}
}
async function apiTest(id: string): Promise<TestResponse> {
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/test`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
const txt = await res.text();
throw new Error(parseApiError(txt, res.status));
}
return (await res.json()) as TestResponse;
}
async function apiVerifyHostKey(
id: string,
body: { fingerprint: string; token: string; reason?: string },
): Promise<void> {
const endpoint = body.reason ? 'replace-host-key' : 'verify-host-key';
const res = await fetch(`/api/ssh/connections/${encodeURIComponent(id)}/${endpoint}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(parseApiError(txt, res.status));
}
}
function parseApiError(rawText: string, status: number): string {
try {
const parsed = JSON.parse(rawText);
if (parsed?.error) {
return parsed.detail ? `${parsed.error}: ${typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)}` : parsed.error;
}
} catch {
// ignore
}
return `HTTP ${status}`;
}
interface SshConnectionsPanelProps {
/** Render personal+globals (user mode) or only globals via admin endpoints. */
scope?: 'user';
showToast?: (msg: string, variant?: 'success' | 'error') => void;
}
export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}) {
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['ssh', 'connections'],
queryFn: fetchConnections,
staleTime: 15_000,
});
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
const [pubKeyDialog, setPubKeyDialog] = useState<{
publicKey: string;
label?: string;
freshlyGenerated: boolean;
} | null>(null);
const createMutation = useMutation({
mutationFn: apiCreate,
onSuccess: (resp) => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
setCreating(false);
showToast?.('SSH 接続を作成しました', 'success');
// If the server returned a public key (always for keypairSource=generate;
// also surfaced for provided keys), open the dialog so the user can
// copy it into authorized_keys.
if (resp.publicKey) {
setPubKeyDialog({
publicKey: resp.publicKey,
label: resp.connection.label,
freshlyGenerated: true,
});
}
},
});
const showPubKeyMutation = useMutation({
mutationFn: async ({ id, label }: { id: string; label: string }) => {
const pk = await apiGetPublicKey(id);
return { publicKey: pk, label };
},
onSuccess: ({ publicKey, label }) => {
if (publicKey) {
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
} else {
showToast?.('公開鍵の取得に失敗しました', 'error');
}
},
onError: (e) => {
showToast?.(e instanceof Error ? e.message : '公開鍵取得失敗', 'error');
},
});
const patchMutation = useMutation({
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => apiPatch(id, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
setEditingId(null);
showToast?.('SSH 接続を更新しました', 'success');
},
});
const deleteMutation = useMutation({
mutationFn: apiDelete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
showToast?.('SSH 接続を削除しました', 'success');
},
onError: (e) => {
showToast?.(e instanceof Error ? e.message : '削除失敗', 'error');
},
});
const testMutation = useMutation({
mutationFn: apiTest,
onSuccess: (response, id) => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
// Surface result. pass = already verified; first_observe/mismatch = needs confirm.
if (response.verdict === 'pass') {
showToast?.(`ホストキーは一致しています (${response.fingerprint.slice(0, 20)}…)`, 'success');
} else if (response.verdict === 'first_observe' || response.verdict === 'mismatch') {
setTestResult({ id, test: response, replaceMode: response.verdict === 'mismatch' });
} else if (response.verdict === 'alg_not_allowed') {
showToast?.('ホストキーのアルゴリズムが許可リストにありません', 'error');
}
},
onError: (e) => {
showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error');
},
});
async function handleVerifyHostKey(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
await apiVerifyHostKey(connId, args);
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
showToast?.('ホストキーを検証しました', 'success');
}
if (data?.sshDisabled) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<h2 className="text-base font-semibold text-slate-900 mb-2">SSH </h2>
<div className="text-xs text-slate-600 bg-surface border border-hairline rounded-md p-3 leading-relaxed">
SSH <code className="font-mono">config.yaml</code> {' '}
<code className="font-mono">ssh.enabled: true</code> {' '}
<code className="font-mono">MCP_ENCRYPTION_KEY</code>
</div>
</div>
</div>
);
}
const owned = (data?.list ?? []).filter(c => c.ownerId !== null);
const globals = (data?.list ?? []).filter(c => c.ownerId === null);
return (
<div className="h-full overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-slate-900">SSH </h2>
<p className="text-2xs text-slate-500 mt-0.5">
SshExec / SshUpload / SshDownload SSH
</p>
</div>
<button
type="button"
onClick={() => { setCreating(true); setEditingId(null); }}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
disabled={creating}
>
+
</button>
</div>
{isLoading && <div className="text-xs text-slate-400">Loading</div>}
{error && <div className="text-xs text-red-500">: {String(error)}</div>}
{creating && (
<section className="mb-5 border border-accent/40 rounded-md bg-white p-4">
<h3 className="text-xs font-semibold text-slate-700 mb-2"> SSH </h3>
<SshConnectionForm
existing={null}
adminContext={false}
onSubmit={async (body) => { await createMutation.mutateAsync(body); }}
onCancel={() => setCreating(false)}
/>
</section>
)}
<SectionHeader title="自分の接続" count={owned.length} />
{owned.length === 0 && !creating && (
<div className="text-xs text-slate-400 px-3 py-4">
+
</div>
)}
<ul className="divide-y divide-hairline mb-6">
{owned.map(c => (
<ConnectionRow
key={c.id}
connection={c}
isOwner
editing={editingId === c.id}
onEdit={() => { setEditingId(c.id); setCreating(false); }}
onCancelEdit={() => setEditingId(null)}
onPatch={async (body) => { await patchMutation.mutateAsync({ id: c.id, body }); }}
onDelete={() => {
if (window.confirm(`接続 "${c.label}" を削除しますか?`)) {
deleteMutation.mutate(c.id);
}
}}
onTest={() => testMutation.mutate(c.id)}
testing={testMutation.isPending && testMutation.variables === c.id}
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
/>
))}
</ul>
{globals.length > 0 && (
<>
<SectionHeader title="グローバル接続 (info-only)" count={globals.length} />
<p className="text-2xs text-slate-500 px-3 mb-2">
grant
</p>
<ul className="divide-y divide-hairline">
{globals.map(c => (
<ConnectionRow
key={c.id}
connection={c}
isOwner={false}
editing={false}
onEdit={() => {}}
onCancelEdit={() => {}}
onPatch={async () => {}}
onDelete={() => {}}
onTest={() => testMutation.mutate(c.id)}
testing={testMutation.isPending && testMutation.variables === c.id}
onShowPublicKey={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
showingPublicKey={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
/>
))}
</ul>
</>
)}
</div>
{testResult && (
<SshHostKeyDialog
test={testResult.test}
replaceMode={testResult.replaceMode}
onClose={() => setTestResult(null)}
onVerify={(args) => handleVerifyHostKey(testResult.id, args)}
/>
)}
{pubKeyDialog && (
<SshPublicKeyDialog
publicKey={pubKeyDialog.publicKey}
label={pubKeyDialog.label}
freshlyGenerated={pubKeyDialog.freshlyGenerated}
onClose={() => setPubKeyDialog(null)}
/>
)}
</div>
);
}
/** Re-exported for use by other panels that render ConnectionRow. */
export type ShowPublicKeyHandler = (args: { id: string; label: string }) => void;
function SectionHeader({ title, count }: { title: string; count: number }) {
return (
<div className="flex items-center gap-2 px-1 py-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">
<span>{title}</span>
<span className="text-slate-400 font-mono">({count})</span>
</div>
);
}
interface ConnectionRowProps {
connection: SshConnection;
isOwner: boolean;
editing: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onPatch: (body: Record<string, unknown>) => Promise<void>;
onDelete: () => void;
onTest: () => void;
testing: boolean;
onShowPublicKey: () => void;
showingPublicKey: boolean;
}
function ConnectionRow(props: ConnectionRowProps) {
const {
connection: c, isOwner, editing,
onEdit, onCancelEdit, onPatch, onDelete, onTest, testing,
onShowPublicKey, showingPublicKey,
} = props;
const verified = c.hostKeyVerifiedAt !== null;
const pending = c.hostKeyPending;
const disabled = c.disabledByAdmin || !c.enabled;
return (
<li className="py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-900 truncate">{c.label}</span>
<ScopeBadge owner={c.ownerId} />
<HostKeyBadge verified={verified} pending={pending} />
{disabled && <Badge color="red">{c.disabledByAdmin ? 'admin-disabled' : 'disabled'}</Badge>}
{c.allowRemoteUnrestricted && <Badge color="amber">remote: unrestricted</Badge>}
{c.allowPrivateAddresses && <Badge color="amber">private addrs</Badge>}
</div>
<div className="text-2xs text-slate-600 font-mono mt-1 truncate">
{c.username}@{c.host}:{c.port}
</div>
<div className="text-2xs text-slate-500 mt-0.5">
id: <CopyableUuid value={c.id} />
{' · '}path-prefix: <span className="font-mono">{c.remotePathPrefix}</span>
{c.keyFingerprint && (
<>
{' · '}key fp: <span className="font-mono">{c.keyFingerprint.slice(0, 24)}</span>
</>
)}
</div>
{c.disabledByAdminReason && (
<div className="text-2xs text-red-700 mt-0.5">: {c.disabledByAdminReason}</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
<button
type="button"
onClick={onTest}
disabled={testing}
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
>
{testing ? 'テスト中…' : 'Test'}
</button>
<button
type="button"
onClick={onShowPublicKey}
disabled={showingPublicKey}
title="authorized_keys に貼る公開鍵を表示"
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
>
{showingPublicKey ? '取得中…' : '公開鍵'}
</button>
{isOwner && !editing && (
<button
type="button"
onClick={onEdit}
className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface"
>
</button>
)}
{isOwner && (
<button
type="button"
onClick={onDelete}
className="px-2 h-7 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50"
>
</button>
)}
</div>
</div>
{editing && (
<div className="mt-3 ml-1 pl-3 border-l-2 border-accent/30">
<SshConnectionForm
existing={c}
adminContext={false}
onSubmit={async (body) => { await onPatch(body); }}
onCancel={onCancelEdit}
/>
</div>
)}
</li>
);
}
/**
* Display a connection UUID with a click-to-copy action. The full UUID is
* shown inline so agents that ask "give me the connection_id" can be
* answered by selecting/copying without opening any sub-view.
*/
function CopyableUuid({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API can fail in non-secure contexts; user can still select manually.
}
}
return (
<button
type="button"
onClick={copy}
title={`クリックで UUID をコピー: ${value}`}
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
>
{copied ? '✓ コピーしました' : value}
</button>
);
}
function ScopeBadge({ owner }: { owner: string | null }) {
return owner === null ? (
<Badge color="slate">global</Badge>
) : (
<Badge color="blue">personal</Badge>
);
}
function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) {
if (pending) return <Badge color="amber">host-key pending</Badge>;
if (verified) return <Badge color="emerald">host-key verified</Badge>;
return <Badge color="slate">host-key untested</Badge>;
}
function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
const cls: Record<typeof color, string> = {
slate: 'bg-slate-100 text-slate-600',
blue: 'bg-blue-50 text-blue-600',
emerald: 'bg-emerald-50 text-emerald-700',
amber: 'bg-amber-50 text-amber-700',
red: 'bg-red-50 text-red-700',
};
return (
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${cls[color]}`}>
{children}
</span>
);
}
@@ -0,0 +1,117 @@
import { useState } from 'react';
import type { TestResponse } from '../../lib/ssh-types';
interface SshHostKeyDialogProps {
/** Test response that produced this dialog (contains verdict + fingerprint + token). */
test: TestResponse;
/** True when an existing verified host key already differs from the observed one. */
replaceMode: boolean;
onClose: () => void;
onVerify: (args: { fingerprint: string; token: string; reason?: string }) => Promise<void>;
}
/**
* Shown after POST /test returns a pending verdict (first_observe or mismatch).
* The user must confirm the observed fingerprint to persist it as the verified
* host key. For mismatch the API requires a `reason` (≥ 8 chars) and routes
* through /replace-host-key, which writes a ssh.connection.host_key.replace
* audit row.
*/
export function SshHostKeyDialog({ test, replaceMode, onClose, onVerify }: SshHostKeyDialogProps) {
const [reason, setReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (test.pendingToken === null) {
return null;
}
const needsReason = replaceMode || test.verdict === 'mismatch';
const reasonValid = !needsReason || reason.trim().length >= 8;
async function handleConfirm() {
if (test.pendingToken === null) return;
setSubmitting(true);
setError(null);
try {
await onVerify({
fingerprint: test.fingerprint,
token: test.pendingToken,
reason: needsReason ? reason.trim() : undefined,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-lg bg-white rounded-md shadow-lg border border-hairline overflow-hidden">
<div className="px-5 py-3 border-b border-hairline">
<h3 className="text-sm font-semibold text-slate-900">
{test.verdict === 'first_observe' ? 'ホストキーを記録' : 'ホストキーを置き換え'}
</h3>
<p className="text-2xs text-slate-500 mt-0.5">
{test.verdict === 'first_observe'
? '初回観測したホストキーを確認してください。'
: '⚠️ 記録済みのホストキーと異なります。MITM の可能性も含めて慎重に確認してください。'}
</p>
</div>
<div className="px-5 py-3 space-y-3">
<div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Fingerprint
</div>
<div className="font-mono text-xs text-slate-900 bg-surface px-2 py-1.5 rounded border border-hairline break-all select-all">
{test.fingerprint}
</div>
</div>
<div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Host Key Type
</div>
<div className="font-mono text-xs text-slate-700">{test.hostKeyType}</div>
</div>
{needsReason && (
<div>
<label className="block text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
Reason ( 8 chars)
</label>
<input
type="text"
value={reason}
onChange={e => setReason(e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-hairline rounded font-mono"
placeholder="ホスト OS 再構築のため差し替え"
/>
</div>
)}
{error && (
<div className="text-xs text-red-600">{error}</div>
)}
</div>
<div className="px-5 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface disabled:opacity-50"
>
</button>
<button
type="button"
onClick={handleConfirm}
disabled={submitting || !reasonValid}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
{submitting ? '保存中…' : test.verdict === 'first_observe' ? '記録する' : '置き換える'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
interface Props {
publicKey: string;
/** Optional label shown in the title (e.g. the connection's display name). */
label?: string;
/** When true, surface a "newly generated" callout. Default false. */
freshlyGenerated?: boolean;
onClose: () => void;
}
/**
* Modal that shows the OpenSSH-format public key (`ssh-ed25519 AAAA...`)
* with a one-click copy button. Used both:
* - after creating a connection with `keypairSource=generate` (so the user
* can paste the key into the remote `authorized_keys`), and
* - on demand from any existing connection (so the user can re-verify
* that the registered authorized_keys entry matches our stored key).
*/
export function SshPublicKeyDialog({ publicKey, label, freshlyGenerated, onClose }: Props) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(publicKey);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API can fail in non-secure contexts; ignore and let the
// user select the textarea manually.
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-2xl bg-white rounded-md shadow-lg border border-hairline overflow-hidden">
<div className="px-4 py-3 border-b border-hairline bg-surface/40">
<h3 className="text-sm font-semibold text-slate-900">
{label && <span className="font-normal text-slate-500"> {label}</span>}
</h3>
{freshlyGenerated && (
<p className="text-2xs text-emerald-700 mt-1">
Orchestrator
<code className="font-mono"> ~/.ssh/authorized_keys</code>
</p>
)}
{!freshlyGenerated && (
<p className="text-2xs text-slate-600 mt-1">
<code className="font-mono"> ~/.ssh/authorized_keys</code>
</p>
)}
</div>
<div className="px-4 py-3">
<label className="block text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
authorized_keys 1
</label>
<textarea
readOnly
value={publicKey}
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
className="w-full text-xs font-mono px-2 py-1.5 border border-hairline rounded h-24 resize-y bg-surface/40"
spellCheck={false}
/>
<div className="flex items-center justify-between mt-2">
<p className="text-2xs text-slate-500">
SSH All configured authentication methods failed
<code className="font-mono">authorized_keys</code>
</p>
<button
type="button"
onClick={handleCopy}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep ml-2 flex-shrink-0"
>
{copied ? 'コピーしました' : 'コピー'}
</button>
</div>
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end bg-surface/30">
<button
type="button"
onClick={onClose}
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface"
>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,535 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MarkdownText } from '../../lib/markdown-text';
interface Subscription {
consumer_user_id: string;
publisher_user_id: string;
folder: string;
mode: 'search' | 'inject';
enabled: number;
}
interface DiscoverRow {
owner_id: string;
folder: string;
file_name: string;
title: string | null;
visibility: string;
mode_hint: string | null;
updated_at: number;
}
interface InjectItem {
owner_id: string;
folder: string;
file_name: string;
size_kb: number;
}
interface InjectPreview {
items: InjectItem[];
total_kb: number;
budget_kb: number;
per_note_max_kb: number;
}
function NotesListExpanded({
ownerId,
folder,
onSelectNote,
}: {
ownerId: string;
folder: string;
onSelectNote: (fileName: string) => void;
}) {
const list = useQuery<{ rows: DiscoverRow[] }>({
queryKey: ['notes-folder-list', ownerId, folder],
queryFn: async () => {
const r = await fetch(
`/api/notes/discover?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&limit=200`,
{ credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
if (list.isLoading) return <p className="text-2xs text-slate-400 pl-3 py-1">Loading</p>;
if (list.isError) return <p className="text-2xs text-red-500 pl-3 py-1">Failed to load notes.</p>;
const rows = list.data?.rows ?? [];
if (rows.length === 0) return <p className="text-2xs text-slate-400 pl-3 py-1">()</p>;
return (
<ul className="pl-3 pt-1 pb-1 space-y-0.5">
{rows.map((n) => (
<li key={n.file_name}>
<button
type="button"
onClick={() => onSelectNote(n.file_name)}
className="w-full text-left flex items-center gap-2 px-2 py-1 rounded text-2xs hover:bg-surface-2/60 transition-colors"
title={`${n.owner_id}/${n.folder}/${n.file_name}`}
>
<span className="text-slate-400 flex-shrink-0">📄</span>
<span className="flex-1 min-w-0 truncate text-slate-700">
{n.title || <span className="font-mono text-slate-500">{n.file_name}</span>}
</span>
{n.mode_hint && (
<span className="text-slate-400 text-[10px] font-mono">{n.mode_hint}</span>
)}
</button>
</li>
))}
</ul>
);
}
function NoteContentModal({
ownerId,
folder,
fileName,
onClose,
}: {
ownerId: string;
folder: string;
fileName: string;
onClose: () => void;
}) {
const note = useQuery<{ fm: Record<string, unknown>; body: string; content: string }>({
queryKey: ['notes-cross-user-file', ownerId, folder, fileName],
queryFn: async () => {
const r = await fetch(
`/api/notes/file?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&file_name=${encodeURIComponent(fileName)}`,
{ credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
const title = (note.data?.fm.title as string | undefined) || fileName;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
onClick={onClose}
>
<div
className="bg-white rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-center justify-between border-b border-hairline px-4 py-3 flex-shrink-0">
<div className="min-w-0">
<h3 className="text-[13px] font-semibold text-slate-900 truncate">{title}</h3>
<p className="text-2xs text-slate-500 font-mono truncate">
{ownerId}/{folder}/{fileName}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="閉じる"
className="px-2 py-1 text-slate-500 hover:text-slate-800 rounded hover:bg-surface-2"
>
×
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
{note.isLoading && <p className="text-[13px] text-slate-400">Loading</p>}
{note.isError && <p className="text-[13px] text-red-500"></p>}
{note.data && note.data.body
? <MarkdownText text={note.data.body} />
: note.data && <p className="text-[13px] text-slate-400 italic">()</p>}
</div>
</div>
</div>
);
}
function ModeSelect({
mode,
onChange,
}: {
mode: 'search' | 'inject';
onChange: (m: 'search' | 'inject') => void;
}) {
return (
<select
className="border border-hairline rounded text-2xs px-1 py-0.5 bg-white focus:outline-none focus:ring-1 focus:ring-accent"
value={mode}
onChange={(e) => onChange(e.target.value as 'search' | 'inject')}
>
<option value="search">search</option>
<option value="inject">inject</option>
</select>
);
}
export function SubscriptionsPanel({ currentUserId }: { currentUserId: string }) {
const qc = useQueryClient();
const [q, setQ] = useState('');
// Track which (owner, folder) rows are expanded to show the notes list inline.
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (key: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Open-modal target for note content preview.
const [openNote, setOpenNote] = useState<{ ownerId: string; folder: string; fileName: string } | null>(null);
const subs = useQuery<{ rows: Subscription[] }>({
queryKey: ['notes-subscriptions'],
queryFn: async () => {
const r = await fetch('/api/notes/subscriptions', { credentials: 'include' });
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
const discover = useQuery<{ rows: DiscoverRow[] }>({
queryKey: ['notes-discover', q],
queryFn: async () => {
const r = await fetch(`/api/notes/discover?q=${encodeURIComponent(q)}`, {
credentials: 'include',
});
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 15_000,
});
const preview = useQuery<InjectPreview>({
queryKey: ['notes-inject-preview'],
queryFn: async () => {
const r = await fetch('/api/notes/inject-preview', { credentials: 'include' });
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
const subscribe = useMutation({
mutationFn: async ({
publisher,
folder,
mode,
}: {
publisher: string;
folder: string;
mode: 'search' | 'inject';
}) => {
const r = await fetch('/api/notes/subscriptions', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publisher_user_id: publisher, folder, mode, enabled: true }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({ error: 'failed' }));
throw new Error((j as { error?: string }).error ?? 'failed');
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
qc.invalidateQueries({ queryKey: ['notes-discover'] });
},
});
const unsubscribe = useMutation({
mutationFn: async ({ publisher, folder }: { publisher: string; folder: string }) => {
const r = await fetch(
`/api/notes/subscriptions?publisher_user_id=${encodeURIComponent(publisher)}&folder=${encodeURIComponent(folder)}`,
{ method: 'DELETE', credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
},
});
// Group discover rows by (owner_id, folder)
const folderGroups = new Map<
string,
{ owner_id: string; folder: string; count: number; visibility: string }
>();
for (const row of discover.data?.rows ?? []) {
const key = `${row.owner_id}/${row.folder}`;
const existing = folderGroups.get(key);
if (existing) {
existing.count++;
} else {
folderGroups.set(key, {
owner_id: row.owner_id,
folder: row.folder,
count: 1,
visibility: row.visibility,
});
}
}
const myFolders = (subs.data?.rows ?? []).filter(
(s) => s.publisher_user_id === currentUserId,
);
const otherSubs = (subs.data?.rows ?? []).filter(
(s) => s.publisher_user_id !== currentUserId,
);
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-6 space-y-8">
{/* My Folders */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
My Folders{' '}
<span className="text-slate-400 font-normal">({myFolders.length})</span>
</h3>
{subs.isLoading && (
<p className="text-[13px] text-slate-400">Loading</p>
)}
{subs.isError && (
<p className="text-[13px] text-red-500">Failed to load subscriptions.</p>
)}
{!subs.isLoading && !subs.isError && myFolders.length === 0 && (
<p className="text-[13px] text-slate-400"> folder </p>
)}
<ul className="space-y-1">
{myFolders.map((s) => {
const key = `${s.publisher_user_id}/${s.folder}`;
const isOpen = expanded.has(key);
return (
<li
key={key}
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => toggleExpanded(key)}
aria-label={isOpen ? '折りたたむ' : '展開'}
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
>
{isOpen ? '▼' : '▶'}
</button>
<span className="flex-1 text-[13px] font-mono text-slate-700">{s.folder}</span>
<ModeSelect
mode={s.mode}
onChange={(m) =>
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
}
/>
</div>
{isOpen && (
<NotesListExpanded
ownerId={s.publisher_user_id}
folder={s.folder}
onSelectNote={(fileName) =>
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
}
/>
)}
</li>
);
})}
</ul>
</section>
{/* My Subscriptions */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
My Subscriptions{' '}
<span className="text-slate-400 font-normal">({otherSubs.length})</span>
</h3>
{!subs.isLoading && !subs.isError && otherSubs.length === 0 && (
<p className="text-[13px] text-slate-400"> folder </p>
)}
<ul className="space-y-1">
{otherSubs.map((s) => {
const key = `${s.publisher_user_id}/${s.folder}`;
const isOpen = expanded.has(key);
return (
<li
key={key}
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => toggleExpanded(key)}
aria-label={isOpen ? '折りたたむ' : '展開'}
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
>
{isOpen ? '▼' : '▶'}
</button>
<span className="flex-1 text-[13px] font-mono text-slate-700">
{s.publisher_user_id}/{s.folder}
</span>
<ModeSelect
mode={s.mode}
onChange={(m) =>
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
}
/>
<button
type="button"
className="text-2xs text-red-600 hover:text-red-800 font-medium px-2 py-0.5 rounded hover:bg-red-50 transition-colors"
onClick={() =>
unsubscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder })
}
disabled={unsubscribe.isPending}
>
Unsubscribe
</button>
</div>
{isOpen && (
<NotesListExpanded
ownerId={s.publisher_user_id}
folder={s.folder}
onSelectNote={(fileName) =>
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
}
/>
)}
</li>
);
})}
</ul>
{unsubscribe.isError && (
<p className="mt-1 text-2xs text-red-600">{(unsubscribe.error as Error).message}</p>
)}
</section>
{/* Discover */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">Discover</h3>
<input
className="border border-hairline rounded px-2 py-1.5 mb-3 w-full text-[13px] bg-white focus:outline-none focus:ring-1 focus:ring-accent placeholder:text-slate-400"
placeholder="Search by title, tag, body…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{discover.isLoading && (
<p className="text-[13px] text-slate-400">Searching</p>
)}
{discover.isError && (
<p className="text-[13px] text-red-500">Search failed.</p>
)}
{!discover.isLoading && !discover.isError && folderGroups.size === 0 && (
<p className="text-[13px] text-slate-400">
{q ? '結果なし' : '検索してフォルダーを探してください'}
</p>
)}
<ul className="space-y-2">
{Array.from(folderGroups.values()).map((g) => {
const alreadySubbed = (subs.data?.rows ?? []).some(
(s) => s.publisher_user_id === g.owner_id && s.folder === g.folder,
);
return (
<li
key={`${g.owner_id}/${g.folder}`}
className="border border-hairline rounded-md px-3 py-2 bg-surface-2/30"
>
<div className="flex items-center gap-2 mb-1.5">
<span className="flex-1 text-[13px] font-mono text-slate-700">
{g.owner_id}/{g.folder}
</span>
<span className="text-2xs text-slate-400">
{g.visibility} · {g.count} notes
</span>
</div>
{alreadySubbed ? (
<span className="text-2xs text-slate-400 italic"></span>
) : (
<div className="flex gap-2">
<button
type="button"
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
disabled={subscribe.isPending}
onClick={() =>
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'search' })
}
>
Subscribe (search)
</button>
<button
type="button"
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
disabled={subscribe.isPending}
onClick={() =>
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'inject' })
}
>
Subscribe (inject)
</button>
</div>
)}
</li>
);
})}
</ul>
{subscribe.isError && (
<p className="mt-1 text-2xs text-red-600">{(subscribe.error as Error).message}</p>
)}
</section>
{/* Inject Preview */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-1">Inject Preview</h3>
<p className="text-2xs text-slate-500 mb-2">
inject LLM
</p>
{preview.isLoading && (
<p className="text-[13px] text-slate-400">Loading</p>
)}
{preview.isError && (
<p className="text-[13px] text-red-500">Failed to load inject preview.</p>
)}
{!preview.isLoading && !preview.isError && (
<>
{(preview.data?.items ?? []).length === 0 ? (
<p className="text-[13px] text-slate-400">inject </p>
) : (
<ul className="space-y-0.5 mb-2">
{(preview.data?.items ?? []).map((it) => (
<li
key={`${it.owner_id}/${it.folder}/${it.file_name}`}
className="flex items-center gap-2 text-[13px] font-mono text-slate-700"
>
<span className="flex-1">{it.owner_id}/{it.folder}/{it.file_name}</span>
<span className="text-slate-400 text-2xs">{it.size_kb} KB</span>
</li>
))}
</ul>
)}
<div className="text-2xs text-slate-500 pt-1 border-t border-hairline">
Total:{' '}
<span className="font-semibold text-slate-700">{preview.data?.total_kb ?? 0} KB</span>
{' '}/{' '}
<span className="font-semibold text-slate-700">{preview.data?.budget_kb ?? 0} KB</span>
{' '}budget
</div>
</>
)}
</section>
</div>
{openNote && (
<NoteContentModal
ownerId={openNote.ownerId}
folder={openNote.folder}
fileName={openNote.fileName}
onClose={() => setOpenNote(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,538 @@
import { useState } from 'react';
import { useQueries, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileTree, type SubdirId, type FileEntry, FILE_SUBDIRS } from './FileTree';
import { MonacoFileEditor } from './MonacoFileEditor';
import { SaveAsScriptDialog } from './SaveAsScriptDialog';
import { ScriptDiffReview } from './ScriptDiffReview';
import { BrowserSessionsPanel } from './BrowserSessionsPanel';
import { McpPanel } from './McpPanel';
import { AgentsMdPanel } from './AgentsMdPanel';
import { NewFileForm } from './NewFileForm';
import { PetsPanel } from './PetsPanel';
import { SshConnectionsPanel } from './SshConnectionsPanel';
import { NotesPanel } from './NotesPanel';
import { SubscriptionsPanel } from './SubscriptionsPanel';
import { SkillsPanel } from './SkillsPanel';
/** All subdirs shown in the tree — both real file-based and virtual. */
const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'scripts', 'browser-macros', 'templates', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory'];
const SUBDIR_INFO: { id: SubdirId; icon: string; title: string; desc: string; agency: string }[] = [
{
id: 'agents-md',
icon: '📖',
title: 'AGENTS.md',
desc: 'タスク起動時に system prompt へ自動注入される、ユーザー専用の永続的な指示。 「常に丁寧な日本語で答える」「Tailwind を優先」等、毎タスクで覚えて欲しい好み・ルールを書く。 最大 64KB。 ファイル形式は markdown。',
agency: 'ユーザー編集 / エージェントが自動参照',
},
{
id: 'scripts',
icon: '📜',
title: 'scripts/',
desc: 'AI 生成の汎用 Node スクリプト。エージェントが RunUserScript ツールで実行 (kind: "script")。Chromium 起動なし、main({ params }) シグネチャ。データ整形・API 呼び出し・計算・ファイル変換等の繰り返し処理に向く。',
agency: 'エージェント/ユーザー両方 / 軽量・高速',
},
{
id: 'browser-macros',
icon: '🤖',
title: 'browser-macros/',
desc: 'Playwright ベースのブラウザマクロ。recordings/ から "Save as Script" で生成、または UI で手書き。RunUserScript ツール (kind: "browser-macro") で実行。main({ context, params }) シグネチャで context は Playwright BrowserContext。session_profile_id で保存済みログインを利用可能。.next.js は self-healing 失敗時の自動パッチ候補で、Diff レビュー後に accept/reject。',
agency: 'エージェント実行 / UI で recordings → スクリプト化',
},
{
id: 'templates',
icon: '📄',
title: 'templates/',
desc: '定型文・雛形の置き場。UI で作成・編集する。エージェントが ReadUserTemplate で本文を読むか、RenderUserTemplate で frontmatter.params の {{var}} を埋めた結果を取得できる。報告書の雛形・メール文面・コードボイラープレート等を貯めておくと、繰り返しタスクで「雛形を埋めて」と指示しやすい。',
agency: 'ユーザー作成 / エージェントが ReadUserTemplate / RenderUserTemplate で利用',
},
{
id: 'recordings',
icon: '🎬',
title: 'recordings/',
desc: 'BrowseWeb 操作トレース (JSON)。BrowseWeb 呼び出しで recordTo パラメータを指定すると、成功したアクションがバッファされ、タスク終了時にここへ書き出される。"Save as Script" でブラウザマクロに変換できる (browser-macros/ へ保存)。',
agency: 'エージェントが記録 / UI でスクリプト化',
},
{
id: 'pets',
icon: '◉',
title: 'pets/',
desc: 'Codex Pets 互換のキャラクターをユーザーごとに import する場所。Chat 画面右下に表示され、タスク状態やツール呼び出しに応じて小さく反応する。',
agency: 'ユーザー管理 / Chat UI が参照',
},
{
id: 'browser-sessions',
icon: '🌐',
title: 'browser-sessions/',
desc: 'ブラウザのログインプロファイル管理。CAPTCHA や 2FA の壁を越えて取得した cookie/storage を user-scoped に暗号化保存し、browser-macros から session_profile_id で参照する。',
agency: 'ユーザー管理 (noVNC でログイン → save) / browser-macros が利用',
},
{
id: 'trash',
icon: '🗑',
title: 'trash/',
desc: '削除ファイルの退避先。ハードデリートはせず、`{YYYYMMDD-HHMMSS}-{rand4hex}-{name}` 命名で着地する。script の accept/reject で旧版もここへ。閲覧は read-only、復元したい場合は内容コピーで元 subdir に PUT。30 日経過したファイルはサーバ起動時 / 24h 毎に自動削除 (`tools.trash_retention_days` で変更可)。',
agency: 'ソフト削除 / read-only / 30 日自動 cleanup',
},
{
id: 'memory',
icon: '🧠',
title: 'memory/',
desc: 'エージェントの永続事実置き場。`MEMORY.md` (index) がタスク起動時に system prompt へ自動注入 (32 KB cap)。`{name}.md` は frontmatter (type ∈ user/feedback/project/reference) + 本文の構造。UpdateUserMemory / ReadUserMemory ツール経由でエージェントが管理。',
agency: 'エージェント管理 / UI からは read-only',
},
{
id: 'mcp',
icon: '🔌',
title: 'mcp/',
desc: 'MCP サーバーの登録・接続管理・設定変更をまとめて行えます。OAuth / API key 認証、ツール一覧の取得、接続状態の確認がここで完結します。credentials は AES-256-GCM で暗号化して保存されます。',
agency: 'ユーザー管理 / 管理者は global サーバーも追加可能',
},
{
id: 'skills',
icon: '📚',
title: 'skills/',
desc: 'エージェントのスキル (参照知識・手順書) を管理します。URL からインストール、手動作成、編集、削除が可能。スキルはタスク実行時にエージェントへコンテキストとして注入されます。',
agency: 'ユーザー管理 / エージェントが ReadSkill で参照',
},
{
id: 'ssh-connections',
icon: '🔐',
title: 'ssh-connections/',
desc: 'エージェントの SshExec / SshUpload / SshDownload ツールが利用する SSH 接続を管理します。秘密鍵は envelope encryption (AES-256-GCM + per-user DEK) で保存され、ホストキーは TOFU でユーザー確認後に固定されます。グローバル接続は管理者が登録し、ピースごとに grant を付与した時のみ参照可能です。',
agency: 'ユーザー管理 / グローバル接続は管理者が登録',
},
{
id: 'notes',
icon: '📝',
title: 'notes/',
desc: '他のエージェントや他のユーザーと共有したい情報を Markdown で書く場所です。visibility を設定して公開範囲を制御できます。SearchNotes / ReadNote / WriteNote ツールでエージェントがアクセスできます。',
agency: 'ユーザー作成 / エージェントが SearchNotes / ReadNote / WriteNote で利用',
},
{
id: 'subscribed-notes',
icon: '🔔',
title: 'Subscribed Notes',
desc: '他のユーザーが公開している notes フォルダーを購読・発見します。search モードは SearchNotes ツールで横断検索でき、inject モードは LLM コンテキストに自動注入します。',
agency: 'ユーザー管理 / エージェントが自動参照 (inject モード)',
},
];
interface FolderListResponse {
files: FileEntry[];
}
async function apiFolderList(subdir: SubdirId): Promise<FileEntry[]> {
const res = await fetch(`/api/users/me/folder/list?subdir=${subdir}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`List failed: ${res.status}`);
const data: FolderListResponse = await res.json();
return data.files ?? [];
}
interface NoteDiscoverRow {
folder: string;
file_name: string;
updated_at: number;
content_size: number;
}
/** Fetch all own notes via the discover API (unlimited depth, returns folder/file pairs). */
async function apiNotesList(): Promise<FileEntry[]> {
const res = await fetch('/api/notes/discover?owner_id=me&limit=200', {
credentials: 'include',
});
if (!res.ok) throw new Error(`Notes list failed: ${res.status}`);
const data: { rows: NoteDiscoverRow[] } = await res.json();
return (data.rows ?? []).map((r) => ({
// Use "folder/file.md" as the virtual file name so FileTree shows the full path
name: `${r.folder}/${r.file_name}`,
size: r.content_size,
mtime: new Date(r.updated_at).toISOString(),
}));
}
async function apiFolderGet(subdir: SubdirId, path: string): Promise<string> {
const res = await fetch(
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
{ credentials: 'include' },
);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.text();
}
async function apiFolderPut(subdir: SubdirId, path: string, body: string): Promise<void> {
const res = await fetch(
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
{
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body,
},
);
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
}
async function apiFolderDelete(subdir: SubdirId, path: string): Promise<void> {
const res = await fetch(
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
{ method: 'DELETE', credentials: 'include' },
);
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
}
/** Virtual subdirs don't have real files on disk */
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
/** Subdirs where users can create new files from the UI */
const WRITABLE_USER_SUBDIRS = new Set<SubdirId>(['scripts', 'browser-macros', 'templates']);
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
interface UserFolderTabProps {
showToast?: ShowToast;
}
export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
const [selectedSubdir, setSelectedSubdir] = useState<SubdirId | null>('scripts');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorDirty, setEditorDirty] = useState(false);
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
const qc = useQueryClient();
// Fetch the current user (needed for SubscriptionsPanel)
const meQuery = useQuery<{ id: string }>({
queryKey: ['auth', 'me'],
queryFn: async () => {
const res = await fetch('/api/auth/me');
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
},
staleTime: 60_000,
});
const currentUserId = meQuery.data?.id ?? '';
// Only file-based subdirs are fetched; notes uses a separate discover endpoint
// because notes live at depth 2 (notes/<folder>/<file>.md) and folder/list only shows depth 1.
const fileSubdirs = FILE_SUBDIRS.filter((s) => s !== 'notes');
const subdirResults = useQueries({
queries: fileSubdirs.map(subdir => ({
queryKey: ['userfolder', 'list', subdir],
queryFn: () => apiFolderList(subdir),
staleTime: 10_000,
})),
});
// Separate query for notes that uses the discover API instead of the folder-list API
const notesListQuery = useQuery<FileEntry[]>({
queryKey: ['userfolder', 'list', 'notes'],
queryFn: () => apiNotesList(),
staleTime: 10_000,
});
const subdirFilesMap: Partial<Record<SubdirId, { subdir: SubdirId; files: FileEntry[]; loading: boolean }>> = Object.fromEntries(
fileSubdirs.map((subdir, i) => [
subdir,
{
subdir,
files: subdirResults[i]!.data ?? [],
loading: subdirResults[i]!.isLoading,
},
])
);
// Inject notes separately using the discover-based listing (depth-2 aware)
subdirFilesMap['notes'] = {
subdir: 'notes',
files: notesListQuery.data ?? [],
loading: notesListQuery.isLoading,
};
// Build the tree data: real subdirs get files, virtual ones get empty placeholders
const SUBDIRS = ALL_SUBDIRS;
const subdirQueries = SUBDIRS.map(subdir => {
if (VIRTUAL_SUBDIRS.has(subdir)) {
return { subdir, files: [], loading: false };
}
return subdirFilesMap[subdir] ?? { subdir, files: [], loading: false };
});
// File content query — only when a file is selected (and not virtual subdir)
const fileQuery = useQuery<string>({
queryKey: ['userfolder', 'file', selectedSubdir, selectedFile],
queryFn: () => apiFolderGet(selectedSubdir!, selectedFile!),
enabled: !!(selectedSubdir && selectedFile && !VIRTUAL_SUBDIRS.has(selectedSubdir)),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
const deleteMutation = useMutation({
mutationFn: ({ subdir, file }: { subdir: SubdirId; file: string }) =>
apiFolderDelete(subdir, file),
onSuccess: (_data, { subdir, file }) => {
qc.invalidateQueries({ queryKey: ['userfolder', 'list', subdir] });
if (selectedSubdir === subdir && selectedFile === file) {
setSelectedFile(null);
}
},
onError: (err, { subdir, file }) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
const label = `${subdir}/${file} の削除に失敗`;
if (showToast) showToast(`${label}: ${msg}`, 'error');
else console.error(`${label}: ${msg}`);
},
});
const selectedSubdirData = subdirQueries.find(q => q.subdir === selectedSubdir);
const selectedFileMeta = selectedSubdirData?.files.find(f => f.name === selectedFile);
const handleSave = async (content: string) => {
if (!selectedSubdir || !selectedFile) return;
await apiFolderPut(selectedSubdir, selectedFile, content);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
qc.setQueryData(
['userfolder', 'file', selectedSubdir, selectedFile],
content,
);
};
const handleDelete = (subdir: SubdirId, file: string) => {
if (!window.confirm(`Delete ${subdir}/${file}?`)) return;
deleteMutation.mutate({ subdir, file });
};
function handleSelectSubdir(subdir: SubdirId) {
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
if (selectedSubdir === subdir) {
setSelectedSubdir(null);
setSelectedFile(null);
} else {
setSelectedSubdir(subdir);
setSelectedFile(null);
}
}
function handleSelectFile(subdir: SubdirId, file: string) {
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
setSelectedSubdir(subdir);
setSelectedFile(file);
}
// Determine right-pane content
const isVirtualSelected = selectedSubdir !== null && VIRTUAL_SUBDIRS.has(selectedSubdir);
return (
<div className="flex h-full gap-2 p-2 overflow-hidden">
{/* Left: file tree */}
<div
className="bg-white border border-hairline rounded-md overflow-hidden flex flex-col"
style={{ width: 'clamp(200px, 22vw, 280px)', flexShrink: 0 }}
>
<div className="flex-shrink-0 px-3 py-2.5 border-b border-hairline">
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
User Folder
</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<FileTree
subdirData={subdirQueries}
selectedSubdir={selectedSubdir}
selectedFile={selectedFile}
onSelectSubdir={handleSelectSubdir}
onSelectFile={handleSelectFile}
onDeleteFile={handleDelete}
/>
</div>
</div>
{/* Right: editor / virtual panel */}
<div className="flex-1 min-w-0 bg-white border border-hairline rounded-md overflow-hidden flex flex-col">
{/* agents-md virtual pane */}
{isVirtualSelected && selectedSubdir === 'agents-md' && (
<AgentsMdPanel onDirtyChange={setEditorDirty} />
)}
{/* browser-sessions virtual pane */}
{isVirtualSelected && selectedSubdir === 'browser-sessions' && (
<BrowserSessionsPanel />
)}
{/* mcp virtual pane */}
{isVirtualSelected && selectedSubdir === 'mcp' && (
<McpPanel showToast={showToast} />
)}
{/* skills virtual pane */}
{isVirtualSelected && selectedSubdir === 'skills' && (
<SkillsPanel />
)}
{/* pets virtual pane */}
{isVirtualSelected && selectedSubdir === 'pets' && (
<PetsPanel showToast={showToast} />
)}
{/* ssh-connections virtual pane */}
{isVirtualSelected && selectedSubdir === 'ssh-connections' && (
<SshConnectionsPanel showToast={showToast} />
)}
{/* subscribed-notes virtual pane */}
{isVirtualSelected && selectedSubdir === 'subscribed-notes' && (
<SubscriptionsPanel currentUserId={currentUserId} />
)}
{/* notes/ pane — uses discover API for listing (depth 2) + NotesPanel editor */}
{selectedSubdir === 'notes' && (
<NotesPanel
filePath={selectedFile}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
}}
onSelectFile={(path) => {
setSelectedFile(path);
}}
/>
)}
{/* File-based content (non-notes subdirs) */}
{!isVirtualSelected && selectedSubdir !== 'notes' && (
<>
{/* Save as Script toolbar — shown only in recordings/ when a .json file is selected */}
{selectedSubdir === 'recordings' && selectedFile?.endsWith('.json') && (
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border-b border-hairline bg-surface-2/50">
<span className="text-2xs text-slate-500 flex-1">
Recording: <span className="font-mono">{selectedFile}</span>
</span>
<button
type="button"
onClick={() => setSaveAsDialogOpen(true)}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
>
Save as Script
</button>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{selectedSubdir && selectedFile ? (
/* If a .next.js patch file is selected in browser-macros/, show the diff review pane */
selectedSubdir === 'browser-macros' && selectedFile.endsWith('.next.js') ? (
<ScriptDiffReview
scriptName={selectedFile.slice(0, -'.next.js'.length)}
showToast={showToast}
onClose={(acceptedScript) => {
if (acceptedScript) {
setSelectedFile(acceptedScript);
} else {
setSelectedFile(null);
}
}}
/>
) : fileQuery.isLoading ? (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading
</div>
) : fileQuery.isError ? (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
Failed to load file.
</div>
) : (
<MonacoFileEditor
subdir={selectedSubdir}
filename={selectedFile}
content={fileQuery.data ?? ''}
mtime={selectedFileMeta?.mtime ?? ''}
size={selectedFileMeta?.size ?? 0}
onSave={handleSave}
onDirtyChange={setEditorDirty}
/>
)
) : (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
{selectedSubdir && WRITABLE_USER_SUBDIRS.has(selectedSubdir) ? (
/* Focused view for a selected writable subdir: info + new-file form */
(() => {
const info = SUBDIR_INFO.find(i => i.id === selectedSubdir);
if (!info) return null;
const files = selectedSubdirData?.files ?? [];
return (
<>
<div className="mb-6 flex gap-3">
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>
{info.icon}
</span>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-slate-900">{info.title}</h2>
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">{info.desc}</p>
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{info.agency}</p>
</div>
</div>
{files.length > 0 && (
<div className="mb-4 text-xs text-slate-500">
{files.length}
</div>
)}
<NewFileForm
subdir={selectedSubdir as 'scripts' | 'browser-macros' | 'templates'}
existingFilenames={files.map(f => f.name)}
onCreate={async (filename, skeleton) => {
await apiFolderPut(selectedSubdir, filename, skeleton);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
setSelectedFile(filename);
}}
/>
</>
);
})()
) : (
/* Full overview when no subdir is selected (or non-writable subdir selected without a file) */
<>
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">User Folder</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
subdirectory
</p>
</div>
<ul className="space-y-5">
{SUBDIR_INFO.map(({ id, icon, title, desc, agency }) => (
<li key={id} className="flex gap-3">
<span className="text-xl leading-none mt-0.5 select-none" aria-hidden>
{icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-slate-900">{title}</div>
<p className="text-[13px] text-slate-600 mt-1 leading-relaxed">{desc}</p>
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{agency}</p>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Save as Script dialog — navigates to browser-macros on success */}
{saveAsDialogOpen && selectedFile?.endsWith('.json') && (
<SaveAsScriptDialog
recordingName={selectedFile.endsWith('.json') ? selectedFile.slice(0, -5) : selectedFile}
onClose={() => setSaveAsDialogOpen(false)}
onSuccess={(scriptName) => {
setSaveAsDialogOpen(false);
// Navigate to the new macro in browser-macros/
setSelectedSubdir('browser-macros');
setSelectedFile(scriptName);
}}
/>
)}
</div>
);
}