feat: initial public release (MAESTRO)
This commit is contained in:
+665
@@ -0,0 +1,665 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LocalTask, type Visibility } from './api';
|
||||
import { useUrlState } from './hooks/useUrlState';
|
||||
import { useToast } from './hooks/useToast';
|
||||
import { useFileBrowser } from './hooks/useFileBrowser';
|
||||
import { useFilePreview } from './hooks/useFilePreview';
|
||||
import { useTaskOperations } from './hooks/useTaskOperations';
|
||||
import { useLocalTaskList } from './hooks/useTaskList';
|
||||
import { useLocalTask, useLocalTaskComments } from './hooks/useTaskDetail';
|
||||
import { useSubtaskActivities } from './hooks/useSubtaskActivities';
|
||||
import { useBranding } from './hooks/useBranding';
|
||||
import { useSwipeNav } from './hooks/useSwipeNav';
|
||||
import { useLocalStorageState } from './hooks/useLocalStorageState';
|
||||
import { useTaskNotifications } from './hooks/useTaskNotifications';
|
||||
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
|
||||
import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId } from './lib/urlState';
|
||||
import { confirmDiscardUnsaved } from './lib/unsavedGuard';
|
||||
import { TopBar } from './components/layout/TopBar';
|
||||
import { NavDrawer } from './components/layout/NavDrawer';
|
||||
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
|
||||
import { visibleNavItemsFor, useCompactNav } from './components/layout/TopBar';
|
||||
import { ResizeHandle } from './components/layout/ResizeHandle';
|
||||
import { TaskListPanel } from './components/list/TaskListPanel';
|
||||
import { ChatPane } from './components/chat/ChatPane';
|
||||
import { LocalDetailPanel } from './components/detail/DetailPanel';
|
||||
import { CreateTaskDialog } from './components/create/CreateTaskDialog';
|
||||
import { FilePreview } from './components/files/FilePreview';
|
||||
import { OutputPreviewProvider } from './lib/output-preview-context';
|
||||
import { stripOutputPrefix } from './lib/output-path-detect';
|
||||
import { EmptyState } from './components/shared/EmptyState';
|
||||
import { SkeletonChatPane } from './components/shared/Skeleton';
|
||||
import { ChatPetOverlay } from './components/pets/ChatPetOverlay';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { PiecesPage } from './pages/PiecesPage';
|
||||
import { SchedulesPage } from './pages/SchedulesPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
|
||||
import { SharedView } from './pages/SharedView';
|
||||
import { UserFolderTab } from './components/userfolder/UserFolderTab';
|
||||
import { HelpPage } from './pages/HelpPage';
|
||||
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
||||
import type { ConsoleStatus } from './lib/ssh-console-types';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
role: 'admin' | 'user';
|
||||
orgIds?: string[];
|
||||
defaultVisibility?: Visibility;
|
||||
defaultVisibilityOrgId?: string | null;
|
||||
}
|
||||
|
||||
type AuthMode =
|
||||
| { mode: 'disabled' }
|
||||
| { mode: 'loading' }
|
||||
| { mode: 'authenticated'; user: AuthUser }
|
||||
| { mode: 'unauthenticated' };
|
||||
|
||||
export function useAuthState(): AuthMode {
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.status === 404) return { mode: 'disabled' as const };
|
||||
if (res.status === 401) return { mode: 'unauthenticated' as const };
|
||||
if (!res.ok) throw new Error('Auth check failed');
|
||||
const user = await res.json();
|
||||
return { mode: 'authenticated' as const, user };
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) return { mode: 'loading' };
|
||||
if (error || !data) return { mode: 'disabled' };
|
||||
return data;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// 共有ページ: /ui/shared/:token — 認証不要
|
||||
const sharedMatch = window.location.pathname.match(/^\/ui\/shared\/([^/]+)/);
|
||||
if (sharedMatch) {
|
||||
return <SharedView token={sharedMatch[1]} />;
|
||||
}
|
||||
|
||||
return <AuthenticatedApp />;
|
||||
}
|
||||
|
||||
function AuthenticatedApp() {
|
||||
const auth = useAuthState();
|
||||
|
||||
// Redirect to login if unauthenticated
|
||||
if (auth.mode === 'unauthenticated') {
|
||||
window.location.href = '/auth/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (auth.mode === 'loading') {
|
||||
return (
|
||||
<div className="h-dvh flex items-center justify-center bg-slate-50">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
|
||||
const authEnabled = auth.mode !== 'disabled';
|
||||
const user = auth.mode === 'authenticated' ? auth.user : null;
|
||||
|
||||
return <AppInner isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
|
||||
}
|
||||
|
||||
function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) {
|
||||
// Apply branding (document.title + --brand-primary CSS var)
|
||||
const branding = useBranding();
|
||||
const { urlState, setUrlState, pushUrlState } = useUrlState();
|
||||
const { status, search, sort, detailTab, mobileTab, taskId: localTaskId } = urlState;
|
||||
const dashboardWidget = urlState.dashboardWidget ?? 'worker-status';
|
||||
const setDashboardWidget = (slug: string) =>
|
||||
setUrlState(prev => ({ ...prev, dashboardWidget: slug }));
|
||||
// 認証なしで users ページにアクセスした場合は tasks にフォールバック
|
||||
const page = (urlState.page === 'users' && !authEnabled) ? 'tasks' : urlState.page;
|
||||
|
||||
// UI state
|
||||
const [detailWidth, setDetailWidth] = useLocalStorageState<'normal' | 'focused'>(
|
||||
'ui.detailMode',
|
||||
'normal',
|
||||
);
|
||||
// focused 時の Chat 列幅 (px)。null は default (30vw) を意味する。
|
||||
const [focusedChatPx, setFocusedChatPx] = useLocalStorageState<number | null>(
|
||||
'ui.focusedChatPx',
|
||||
null,
|
||||
);
|
||||
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
|
||||
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
const compactMode = useCompactNav(isAdmin, authEnabled);
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
|
||||
const openNavDrawer = () => {
|
||||
setTabletDetailOpen(false);
|
||||
setNavDrawerOpen(true);
|
||||
};
|
||||
|
||||
// Shared navigation handler used by both TopBar and NavDrawer.
|
||||
// Guards against discarding unsaved edits before switching pages.
|
||||
const handleNavigatePage = (p: PageId) => {
|
||||
if (p === page) {
|
||||
setNavDrawerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!confirmDiscardUnsaved()) return;
|
||||
setUrlState(prev => ({ ...prev, page: p }));
|
||||
setNavDrawerOpen(false);
|
||||
};
|
||||
|
||||
const edgeSwipe = useEdgeSwipe({
|
||||
enabled: compactMode && !navDrawerOpen,
|
||||
onOpen: openNavDrawer,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!compactMode) setNavDrawerOpen(false);
|
||||
}, [compactMode]);
|
||||
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
/**
|
||||
* When set, CreateTaskDialog opens with the given piece preselected (and a
|
||||
* help-themed placeholder). Used by HelpPage "AI に聞く" so the user lands
|
||||
* directly in the help assistant. Cleared on dialog close.
|
||||
*/
|
||||
const [createInitialPiece, setCreateInitialPiece] = useState<string | null>(null);
|
||||
|
||||
const panelOpen = localTaskId !== null;
|
||||
|
||||
// Toast
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
// URL sync
|
||||
useEffect(() => { pushUrlState(urlState); }, [urlState, pushUrlState]);
|
||||
|
||||
// Data queries — split per concern so each tab fetches what it needs.
|
||||
// Overview/Chat render as soon as task + comments arrive, without waiting
|
||||
// for the (potentially large) activity.log. ProgressTab fetches that on
|
||||
// its own when mounted.
|
||||
const localTasksQuery = useLocalTaskList();
|
||||
|
||||
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
|
||||
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
|
||||
const [notifyEvents] = useLocalStorageState<NotifyEventSettings>(
|
||||
'notify.events',
|
||||
DEFAULT_NOTIFY_EVENTS,
|
||||
);
|
||||
|
||||
useTaskNotifications({
|
||||
tasks: localTasksQuery.data,
|
||||
currentUserId: user?.id ?? null,
|
||||
enabled: notifyEnabled,
|
||||
events: notifyEvents,
|
||||
onNotificationClick: (taskId) => {
|
||||
setUrlState(prev => ({ ...prev, page: 'tasks', taskId }));
|
||||
},
|
||||
});
|
||||
|
||||
// V2: SW posts `open-task` when the user clicks an OS notification and the
|
||||
// SW focuses (or opens) this tab. We route it through the same URL state
|
||||
// transition as V1's onclick handler.
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
const handler = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type === 'open-task' && typeof data.taskId === 'number') {
|
||||
setUrlState(prev => ({ ...prev, page: 'tasks', taskId: data.taskId }));
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker.addEventListener('message', handler);
|
||||
return () => navigator.serviceWorker.removeEventListener('message', handler);
|
||||
}, [setUrlState]);
|
||||
|
||||
const localTaskQuery = useLocalTask(localTaskId, panelOpen);
|
||||
const localCommentsQuery = useLocalTaskComments(localTaskId, panelOpen);
|
||||
const localTasks = localTasksQuery.data ?? [];
|
||||
const localTask = localTaskQuery.data ?? null;
|
||||
const localComments = localCommentsQuery.data ?? [];
|
||||
// Both queries must finish before mounting ChatPane — otherwise the task
|
||||
// detail resolves first and ChatPane briefly renders with comments=[],
|
||||
// which trips the "メッセージはまだありません" empty-state. data===undefined
|
||||
// means not yet loaded; once loaded (even with zero comments) it's at
|
||||
// worst [].
|
||||
const chatReady = localTask !== null && localCommentsQuery.data !== undefined;
|
||||
const hasSubtasks = (localTask?.subtasks?.length ?? 0) > 0;
|
||||
const { data: subtaskActivities } = useSubtaskActivities(localTaskId, hasSubtasks);
|
||||
|
||||
// SSH console status (for conditional mobile SSH tab)
|
||||
const { data: consoleStatus } = useQuery<ConsoleStatus>({
|
||||
queryKey: ['console-status', localTaskId],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/tasks/${localTaskId}/console/status`);
|
||||
return r.ok ? r.json() : { active: false };
|
||||
},
|
||||
enabled: !!localTaskId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showSshMobileTab = consoleStatus?.active === true;
|
||||
|
||||
// File browser
|
||||
const fileBrowser = useFileBrowser(localTaskId);
|
||||
|
||||
// File preview
|
||||
const { previewState, previewLocalFile, previewSubtaskFile, closePreview } = useFilePreview(showToast);
|
||||
|
||||
// Task operations
|
||||
const { handleCreateTask, handleComment, handleDelete, handleCancel } = useTaskOperations({
|
||||
taskId: localTaskId,
|
||||
showToast,
|
||||
setUrlState,
|
||||
setShowCreateDialog,
|
||||
});
|
||||
|
||||
// Close tablet overlay when task changes
|
||||
useEffect(() => { setTabletDetailOpen(false); }, [localTaskId]);
|
||||
|
||||
// Counts for TopBar
|
||||
const localColumns = COLUMN_LIST.reduce((acc, s) => {
|
||||
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
||||
return acc;
|
||||
}, {} as Record<string, LocalTask[]>);
|
||||
|
||||
// File preview handlers (bind taskId and section)
|
||||
const handleLocalFilePreview = (filePath: string, name: string) => {
|
||||
if (localTaskId) previewLocalFile(localTaskId, fileBrowser.section, filePath, name);
|
||||
};
|
||||
const handleSubtaskFilePreview = (taskId: number, jobId: string, category: string, filePath: string) => {
|
||||
previewSubtaskFile(taskId, jobId, category, filePath);
|
||||
};
|
||||
|
||||
// Output path link click handler for the OutputPreviewProvider that
|
||||
// wraps the tasks page. Matches paths look like `output/sub/foo.md`
|
||||
// — strip the `output/` prefix and pass the relative path to
|
||||
// previewLocalFile with section pinned to 'output' (ignoring the
|
||||
// current FileBrowser section, which may be 'logs' or 'input').
|
||||
const handleOutputPathLinkClick = (matchedPath: string) => {
|
||||
if (!localTaskId) return;
|
||||
const relative = stripOutputPrefix(matchedPath);
|
||||
const displayName = relative.includes('/') ? relative.substring(relative.lastIndexOf('/') + 1) : relative;
|
||||
previewLocalFile(localTaskId, 'output', relative, displayName);
|
||||
};
|
||||
|
||||
// TaskListPanel shared props
|
||||
const taskListProps = {
|
||||
localTasks,
|
||||
selectedStatus: status,
|
||||
sortMode: sort,
|
||||
searchQuery: search,
|
||||
activeTaskId: localTaskId,
|
||||
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
|
||||
onSortChange: (s: string) => setUrlState(prev => ({ ...prev, sort: s as typeof sort })),
|
||||
onSearchChange: (q: string) => setUrlState(prev => ({ ...prev, search: q })),
|
||||
onSelectTask: (id: number) => setUrlState(prev => ({ ...prev, taskId: id, detailTab: 'overview' as const })),
|
||||
onOpenCreate: () => setShowCreateDialog(true),
|
||||
};
|
||||
|
||||
// Detail panel shared props
|
||||
const detailPanelProps = (overrides?: { detailTab?: string; showWidthToggle?: boolean; onTabChange?: (t: string) => void; onClose?: () => void }) => ({
|
||||
task: localTask,
|
||||
taskId: localTaskId!,
|
||||
section: fileBrowser.section,
|
||||
currentPath: fileBrowser.currentPath,
|
||||
entries: fileBrowser.entries,
|
||||
pathSegments: fileBrowser.pathSegments,
|
||||
loading: localTaskQuery.isLoading,
|
||||
detailTab: overrides?.detailTab ?? detailTab,
|
||||
detailWidth,
|
||||
showWidthToggle: overrides?.showWidthToggle ?? true,
|
||||
onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))),
|
||||
onWidthToggle: () => setDetailWidth(prev => prev === 'focused' ? 'normal' : 'focused'),
|
||||
onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))),
|
||||
onDelete: handleDelete,
|
||||
onSectionChange: fileBrowser.setSection,
|
||||
onNavigate: fileBrowser.setCurrentPath,
|
||||
onPreview: handleLocalFilePreview,
|
||||
onRefresh: fileBrowser.refresh,
|
||||
isRefreshing: fileBrowser.isRefreshing,
|
||||
onViewFullLog: () => handleLocalFilePreview('activity.log', 'activity.log'),
|
||||
subtaskActivities,
|
||||
onSubtaskFilePreview: handleSubtaskFilePreview,
|
||||
shareToken: localTask?.shareToken ?? null,
|
||||
});
|
||||
|
||||
// Layout calculation
|
||||
const sidebarWidth = 'clamp(240px, 22vw, 280px)';
|
||||
const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用
|
||||
const isFocused = detailWidth === 'focused';
|
||||
const RAIL_PX = 48;
|
||||
const HANDLE_PX = 4;
|
||||
const MIN_CHAT_PX = 280;
|
||||
const MIN_WS_PX = 280;
|
||||
const RESERVED_RIGHT = RAIL_PX + HANDLE_PX + MIN_WS_PX; // = 332
|
||||
|
||||
// focused 用 grid: rail | chat (variable) | handle | workspace
|
||||
const focusedGridCols = panelOpen
|
||||
? `${RAIL_PX}px clamp(${MIN_CHAT_PX}px, var(--chat-w, 30vw), calc(100% - ${RESERVED_RIGHT}px)) ${HANDLE_PX}px minmax(${MIN_WS_PX}px, 1fr)`
|
||||
: `${RAIL_PX}px minmax(0, 1fr)`;
|
||||
// normal 用 grid (現状を維持)
|
||||
const normalGridCols = panelOpen
|
||||
? `${sidebarWidth} minmax(280px, 1fr) ${detailPanelWidth}`
|
||||
: `${sidebarWidth} minmax(0, 1fr)`;
|
||||
const gridStyle: React.CSSProperties = isFocused
|
||||
? {
|
||||
gridTemplateColumns: focusedGridCols,
|
||||
['--chat-w' as string]: focusedChatPx !== null ? `${focusedChatPx}px` : '30vw',
|
||||
}
|
||||
: {
|
||||
gridTemplateColumns: normalGridCols,
|
||||
};
|
||||
|
||||
// Dynamic mobile tab list: always show Browser, conditionally show SSH
|
||||
const mobileVisibleTabs: Array<{ id: MobileTabId; label: string }> = [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'activity', label: 'Progress' },
|
||||
{ id: 'files', label: 'Files' },
|
||||
{ id: 'trace', label: 'Trace' },
|
||||
{ id: 'browser', label: 'Browser' },
|
||||
...(showSshMobileTab ? [{ id: 'ssh' as MobileTabId, label: 'SSH' }] : []),
|
||||
];
|
||||
const mobileVisibleTabIds = mobileVisibleTabs.map(t => t.id);
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex flex-col overflow-hidden bg-slate-50 text-slate-900" {...edgeSwipe}>
|
||||
<TopBar
|
||||
currentPage={page}
|
||||
onNavigate={handleNavigatePage}
|
||||
isAdmin={isAdmin}
|
||||
authEnabled={authEnabled}
|
||||
user={user}
|
||||
appName={branding.appName}
|
||||
logoUrl={branding.logoUrl}
|
||||
onOpenDrawer={openNavDrawer}
|
||||
hamburgerButtonRef={hamburgerRef}
|
||||
navDrawerOpen={navDrawerOpen}
|
||||
/>
|
||||
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
||||
{toast && (
|
||||
<div className={
|
||||
toast.variant === 'error'
|
||||
? 'mx-4 mt-2 px-4 py-2.5 bg-red-50 border border-red-200 rounded-xl text-[13px] text-red-800'
|
||||
: 'mx-4 mt-2 px-4 py-2.5 bg-green-50 border border-green-200 rounded-xl text-[13px] text-green-800'
|
||||
}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
|
||||
{page === 'pieces' && isAdmin && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} /></div>}
|
||||
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
||||
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
||||
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
||||
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
|
||||
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
|
||||
|
||||
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
|
||||
{/* モバイル: 単一カラム (< sm = 640px) */}
|
||||
<div className="block sm:hidden h-full">
|
||||
{!panelOpen ? (
|
||||
<div className="p-2 h-full">
|
||||
<div className="bg-white border border-hairline rounded-md h-full overflow-hidden">
|
||||
<TaskListWithSidePanel
|
||||
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
||||
activeWidgetSlug={dashboardWidget}
|
||||
onActiveWidgetSlugChange={setDashboardWidget}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MobileDetailFlow mobileTab={mobileTab} onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}>
|
||||
<div className="flex-shrink-0 flex border-b border-hairline bg-white px-2 pt-[env(safe-area-inset-top)]">
|
||||
{mobileVisibleTabs.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setUrlState(prev => ({ ...prev, mobileTab: id }))}
|
||||
className={`flex-1 py-3 text-xs border-b-2 active:bg-surface-2 active:scale-[0.97] transition-[transform,color,background-color,border-color] duration-100 ${
|
||||
mobileTab === id
|
||||
? 'text-slate-900 border-accent font-semibold'
|
||||
: 'text-slate-500 border-transparent hover:text-slate-800 font-medium'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
aria-label="閉じる"
|
||||
onClick={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))}
|
||||
className="px-3 py-3 text-slate-400 hover:text-slate-800 active:scale-[0.92] active:text-slate-700 transition-[transform,color] duration-100"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div key={mobileTab} className="flex-1 min-h-0 overflow-hidden animate-mobile-tab-swap">
|
||||
{mobileTab === 'chat' && (
|
||||
chatReady ? (
|
||||
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
|
||||
) : (
|
||||
<SkeletonChatPane />
|
||||
)
|
||||
)}
|
||||
{mobileTab !== 'chat' && localTaskId && (
|
||||
<LocalDetailPanel
|
||||
{...detailPanelProps({
|
||||
detailTab: mobileTab === 'overview' ? 'overview'
|
||||
: mobileTab === 'activity' ? 'activity'
|
||||
: mobileTab === 'trace' ? 'trace'
|
||||
: mobileTab === 'browser' ? 'browser'
|
||||
: mobileTab === 'ssh' ? 'ssh'
|
||||
: 'files',
|
||||
showWidthToggle: false,
|
||||
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
|
||||
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile-only pet overlay. Anchored to the MobileDetailFlow
|
||||
wrapper (which has `relative`) so the pet stays visible
|
||||
across all tabs, not just Chat. Tablet+ uses the
|
||||
ChatPane-internal instance instead. */}
|
||||
{localTask && (
|
||||
<ChatPetOverlay
|
||||
taskId={localTask.id}
|
||||
taskStatus={localTask.latestJob?.status ?? null}
|
||||
currentActivity={localTask.latestJob?.currentActivity ?? null}
|
||||
workerId={localTask.latestJob?.workerId ?? null}
|
||||
lastBackendId={localTask.latestJob?.lastBackendId ?? null}
|
||||
className="sm:hidden"
|
||||
/>
|
||||
)}
|
||||
</MobileDetailFlow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* タブレット: 2カラム (sm 〜 lg) */}
|
||||
<div className="hidden sm:grid lg:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
|
||||
<div className="bg-white border border-hairline rounded-md overflow-hidden">
|
||||
<TaskListWithSidePanel
|
||||
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
||||
activeWidgetSlug={dashboardWidget}
|
||||
onActiveWidgetSlugChange={setDashboardWidget}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white border border-hairline rounded-md overflow-hidden">
|
||||
{chatReady ? (
|
||||
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} onOpenDetail={() => setTabletDetailOpen(true)} />
|
||||
) : panelOpen ? (
|
||||
<SkeletonChatPane />
|
||||
) : (
|
||||
<EmptyState title="スレッドを選択してください" description="左の一覧から選ぶと、会話、進捗、成果物を追えます。" onCreateTask={() => setShowCreateDialog(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
|
||||
<div
|
||||
className="hidden lg:grid gap-2 p-2 h-full"
|
||||
data-focused-grid={isFocused ? '1' : undefined}
|
||||
style={gridStyle}
|
||||
>
|
||||
{/* col 1: list or rail. wrapper が bg/border を保持。 */}
|
||||
<div className="bg-white border border-hairline rounded-md overflow-hidden">
|
||||
<TaskListWithSidePanel
|
||||
upper={
|
||||
isFocused
|
||||
? <TaskListPanel
|
||||
{...taskListProps}
|
||||
mode="rail"
|
||||
onExitFocused={() => setDetailWidth('normal')}
|
||||
/>
|
||||
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
|
||||
}
|
||||
activeWidgetSlug={dashboardWidget}
|
||||
onActiveWidgetSlugChange={setDashboardWidget}
|
||||
defaultCollapsed={isFocused}
|
||||
/>
|
||||
</div>
|
||||
{/* col 2: Chat */}
|
||||
<div className="bg-white border border-hairline rounded-md overflow-hidden">
|
||||
{chatReady ? (
|
||||
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
|
||||
) : panelOpen ? (
|
||||
<SkeletonChatPane />
|
||||
) : (
|
||||
<EmptyState title="スレッドを選択してください" description="左の一覧から選ぶと、会話、進捗、成果物を中央で追えます。" onCreateTask={() => setShowCreateDialog(true)} />
|
||||
)}
|
||||
</div>
|
||||
{/* col 3: Resize handle (focused + panelOpen 時のみ) */}
|
||||
{isFocused && panelOpen && (
|
||||
<ResizeHandle
|
||||
onResize={(px) => {
|
||||
const grid = document.querySelector<HTMLElement>('[data-focused-grid="1"]');
|
||||
if (grid) grid.style.setProperty('--chat-w', `${px}px`);
|
||||
}}
|
||||
onResizeEnd={(px) => setFocusedChatPx(px)}
|
||||
onReset={() => setFocusedChatPx(null)}
|
||||
railPx={RAIL_PX}
|
||||
minChatPx={MIN_CHAT_PX}
|
||||
minWorkspacePx={MIN_WS_PX}
|
||||
handlePx={HANDLE_PX}
|
||||
/>
|
||||
)}
|
||||
{/* col 4: Workspace (detail) */}
|
||||
{panelOpen && (
|
||||
<div className="bg-white border border-hairline rounded-md overflow-hidden">
|
||||
{localTaskId && <LocalDetailPanel {...detailPanelProps()} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tablet: detail overlay */}
|
||||
{tabletDetailOpen && panelOpen && (
|
||||
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" onClick={() => setTabletDetailOpen(false)}>
|
||||
<div className="absolute right-0 top-0 bottom-0 bg-white shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
|
||||
{localTaskId && (
|
||||
<LocalDetailPanel
|
||||
{...detailPanelProps({
|
||||
showWidthToggle: false,
|
||||
onClose: () => setTabletDetailOpen(false),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OutputPreviewProvider>}
|
||||
|
||||
{showCreateDialog && (
|
||||
<CreateTaskDialog
|
||||
onClose={() => { setShowCreateDialog(false); setCreateInitialPiece(null); }}
|
||||
onSubmit={handleCreateTask}
|
||||
initialPiece={createInitialPiece ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{previewState && (
|
||||
<FilePreview
|
||||
name={previewState.name}
|
||||
content={previewState.content}
|
||||
imageSrc={previewState.imageSrc}
|
||||
markdownImageBaseUrl={previewState.markdownImageBaseUrl}
|
||||
onClose={closePreview}
|
||||
taskId={previewState.taskId}
|
||||
section={previewState.section}
|
||||
filePath={previewState.filePath}
|
||||
editable={previewState.editable}
|
||||
/>
|
||||
)}
|
||||
{branding.footerText && (
|
||||
<footer className="flex-shrink-0 border-t border-slate-200 bg-white px-4 py-1.5 text-[10px] text-slate-500 text-center">
|
||||
{branding.footerText}
|
||||
</footer>
|
||||
)}
|
||||
<NavDrawer
|
||||
open={navDrawerOpen}
|
||||
onClose={() => setNavDrawerOpen(false)}
|
||||
visibleNav={visibleNav}
|
||||
currentPage={page}
|
||||
onNavigate={handleNavigatePage}
|
||||
appName={branding.appName}
|
||||
logoUrl={branding.logoUrl}
|
||||
returnFocusRef={hamburgerRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile detail wrapper that adds horizontal swipe navigation between
|
||||
* the Chat / Overview / Progress / Files / Trace tabs. Tap-to-switch
|
||||
* via the tab bar still works (the swipe handler ignores touches that
|
||||
* start on form controls / buttons / anchors).
|
||||
*/
|
||||
function MobileDetailFlow({
|
||||
mobileTab,
|
||||
onTabChange,
|
||||
onSwipeRightFromEdge,
|
||||
visibleTabs,
|
||||
children,
|
||||
}: {
|
||||
mobileTab: MobileTabId;
|
||||
onTabChange: (tab: MobileTabId) => void;
|
||||
onSwipeRightFromEdge?: () => void;
|
||||
visibleTabs: MobileTabId[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const swipe = useSwipeNav({
|
||||
onSwipeLeft: () => {
|
||||
const idx = visibleTabs.indexOf(mobileTab);
|
||||
if (idx >= 0 && idx < visibleTabs.length - 1) {
|
||||
onTabChange(visibleTabs[idx + 1]);
|
||||
}
|
||||
},
|
||||
onSwipeRight: () => {
|
||||
const idx = visibleTabs.indexOf(mobileTab);
|
||||
if (idx > 0) {
|
||||
onTabChange(visibleTabs[idx - 1]);
|
||||
} else if (idx === 0) {
|
||||
onSwipeRightFromEdge?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
// `relative` is required so the app-level mobile pet overlay (rendered
|
||||
// inside this wrapper) can anchor with position: absolute.
|
||||
return (
|
||||
<div className="relative flex flex-col h-full" {...swipe}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1259
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
import { memo } from 'react';
|
||||
import { ActivityEvent, activityKindLabel, activityEventTitle, formatActivityMeta, formatActivityTimestamp } from '../../lib/utils';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
|
||||
// Kinds whose `note` field carries free-form text that may use Markdown
|
||||
// (LLM reasoning / preview, final result body, ASK question body, untagged
|
||||
// other-log lines). Structured log kinds (tool calls, preflight numbers,
|
||||
// movement-state lines like `complete -> next_step`) stay as plain text —
|
||||
// Markdown interpretation would corrupt their underscores/asterisks.
|
||||
const MD_KINDS = new Set<string>(['preview', 'final', 'ask', 'other']);
|
||||
|
||||
const KIND_COLORS: Record<string, { dot: string; badge: string; badgeText: string; border: string }> = {
|
||||
movement_start: { dot: 'bg-blue-600', badge: 'bg-blue-100', badgeText: 'text-blue-700', border: 'border-slate-200' },
|
||||
movement_complete:{ dot: 'bg-blue-600', badge: 'bg-blue-100', badgeText: 'text-blue-700', border: 'border-slate-200' },
|
||||
tool: { dot: 'bg-teal-600', badge: 'bg-teal-100', badgeText: 'text-teal-700', border: 'border-teal-200' },
|
||||
preview: { dot: 'bg-blue-600', badge: 'bg-blue-100', badgeText: 'text-blue-700', border: 'border-blue-200' },
|
||||
final: { dot: 'bg-green-600', badge: 'bg-green-100', badgeText: 'text-green-700', border: 'border-green-200' },
|
||||
ask: { dot: 'bg-amber-500', badge: 'bg-amber-100', badgeText: 'text-amber-700', border: 'border-amber-200' },
|
||||
preflight: { dot: 'bg-purple-400', badge: 'bg-purple-50', badgeText: 'text-purple-600', border: 'border-purple-100' },
|
||||
other: { dot: 'bg-slate-400', badge: 'bg-slate-100', badgeText: 'text-slate-600', border: 'border-slate-200' },
|
||||
};
|
||||
|
||||
export const ActivityEventCard = memo(function ActivityEventCard({ event, isLast }: { event: ActivityEvent; isLast?: boolean }) {
|
||||
const colors = KIND_COLORS[event.kind] ?? KIND_COLORS.other!;
|
||||
const meta = formatActivityMeta(event.workerId, event.mode);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: '16px minmax(0,1fr)' }}>
|
||||
<div className="flex flex-col items-center pt-2 relative">
|
||||
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${colors.dot}`} />
|
||||
{!isLast && <div className="flex-1 w-px bg-slate-200 mt-1" />}
|
||||
</div>
|
||||
<div className={`border rounded-xl p-2.5 mb-2.5 ${colors.border} bg-white`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold font-mono tracking-wide ${colors.badge} ${colors.badgeText}`}>
|
||||
{activityKindLabel(event.kind)}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-slate-900 min-w-0 truncate flex-1">
|
||||
{activityEventTitle(event)}
|
||||
</span>
|
||||
{event.timestamp && (
|
||||
<span className="text-2xs text-slate-500 flex-shrink-0">
|
||||
{formatActivityTimestamp(event.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{event.note && (
|
||||
MD_KINDS.has(event.kind) ? (
|
||||
<div className="mt-1.5">
|
||||
<MarkdownText text={event.note} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 text-xs text-slate-600 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{event.note}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{meta && (
|
||||
<div className="mt-1 text-2xs text-slate-400">{meta}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ActivityEvent } from '../../lib/utils';
|
||||
import { ActivityEventCard } from './ActivityEventCard';
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
events: ActivityEvent[];
|
||||
emptyLabel: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function ActivityTimeline({ events, emptyLabel, limit }: ActivityTimelineProps) {
|
||||
const visibleEvents = limit ? events.slice(-limit) : events;
|
||||
|
||||
if (visibleEvents.length === 0) {
|
||||
return <div className="text-[13px] text-slate-500">{emptyLabel}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col list-none p-0 m-0">
|
||||
{visibleEvents.map((event, index) => (
|
||||
<li key={event.id}>
|
||||
<ActivityEventCard event={event} isLast={index === visibleEvents.length - 1} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { POLLING } from '../../lib/constants.js';
|
||||
import { usePictureInPicture } from '../../lib/usePictureInPicture.js';
|
||||
import { PipButton } from './PipButton.js';
|
||||
|
||||
const BASE = '/api/local/browser/sessions';
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
userId?: string;
|
||||
state: string;
|
||||
novncPath: string;
|
||||
lockedByJobId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
async function fetchSessions(): Promise<SessionInfo[]> {
|
||||
const res = await fetch(BASE);
|
||||
const data = await res.json();
|
||||
return data.sessions ?? [];
|
||||
}
|
||||
|
||||
async function createSession(): Promise<SessionInfo> {
|
||||
const res = await fetch(BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteSession(id: string): Promise<void> {
|
||||
await fetch(`${BASE}/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function releaseSession(id: string): Promise<void> {
|
||||
await fetch(`${BASE}/${id}/release`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function BrowserSessionPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['browserSessions'],
|
||||
queryFn: fetchSessions,
|
||||
refetchInterval: POLLING.FAST,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createSession,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['browserSessions'] });
|
||||
setActiveSessionId(data.id);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteSession,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['browserSessions'] });
|
||||
setActiveSessionId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const releaseMutation = useMutation({
|
||||
mutationFn: releaseSession,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['browserSessions'] });
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = sessionsQuery.data ?? [];
|
||||
const activeSession = sessions.find(s => s.id === activeSessionId);
|
||||
const pip = usePictureInPicture(
|
||||
activeSession?.novncPath ?? null,
|
||||
activeSession ? `noVNC — Session ${activeSession.id.slice(0, 8)}` : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Browser Sessions</h3>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending}
|
||||
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<p className="text-xs text-gray-400">No active sessions</p>
|
||||
)}
|
||||
|
||||
{sessions.map(session => (
|
||||
<div key={session.id} className="border rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-xs">{session.id.slice(0, 8)}...</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
session.state === 'user_interactive' ? 'bg-yellow-100 text-yellow-700' :
|
||||
session.state === 'agent_controlled' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{session.state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
className="px-2 py-1 text-xs bg-slate-100 rounded hover:bg-slate-200"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{session.state === 'user_interactive' && (
|
||||
<button
|
||||
onClick={() => releaseMutation.mutate(session.id)}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(session.id)}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Destroy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeSession && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-slate-100 px-3 py-1.5 text-xs flex items-center gap-2">
|
||||
<span>noVNC</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<PipButton pip={pip} />
|
||||
<button onClick={() => setActiveSessionId(null)} className="text-slate-400 hover:text-slate-600 px-1">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{pip.isOpen ? (
|
||||
<div className="w-full h-[500px] flex items-center justify-center bg-slate-50 text-xs text-slate-500">
|
||||
PiP ウィンドウで表示中。閉じるとここに戻ります。
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={activeSession.novncPath}
|
||||
className="w-full h-[500px] border-0"
|
||||
title="Browser Session"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PipController } from '../../lib/usePictureInPicture.js';
|
||||
|
||||
interface Props {
|
||||
pip: PipController;
|
||||
className?: string;
|
||||
/**
|
||||
* Hide the button entirely if Document PiP is unsupported. Default true.
|
||||
* Set false to render a disabled "PiP 非対応" tag with an explanatory
|
||||
* tooltip — useful when you want to surface why PiP isn't available.
|
||||
*/
|
||||
hideWhenUnsupported?: boolean;
|
||||
}
|
||||
|
||||
const REASON_HINT: Record<string, string> = {
|
||||
browser: 'Picture-in-Picture は Chromium 系ブラウザ (Chrome / Edge / Arc / Opera 116+) のみ対応',
|
||||
'insecure-context': 'Picture-in-Picture は HTTPS / localhost からのアクセスでのみ使えます',
|
||||
iframe: 'iframe 内では親フレームに document-picture-in-picture 権限が必要です',
|
||||
};
|
||||
|
||||
/**
|
||||
* Small toolbar button that toggles a Document Picture-in-Picture window for
|
||||
* a noVNC iframe. Surfaces `lastError` from the controller as the title
|
||||
* tooltip + a one-line indicator below the button so failed clicks aren't
|
||||
* silent (Document PiP can fail for several reasons — popup blocker, missing
|
||||
* user gesture, iframe permission policy, etc).
|
||||
*/
|
||||
export function PipButton({ pip, className, hideWhenUnsupported = true }: Props) {
|
||||
if (!pip.supported && hideWhenUnsupported) return null;
|
||||
|
||||
const baseClass = 'text-2xs px-2 py-1 rounded-md border border-hairline bg-white hover:bg-surface text-slate-700 disabled:opacity-50';
|
||||
const merged = className ? `${baseClass} ${className}` : baseClass;
|
||||
|
||||
if (!pip.supported) {
|
||||
const hint = REASON_HINT[pip.unsupportedReason ?? 'browser'] ?? REASON_HINT.browser;
|
||||
return (
|
||||
<button type="button" disabled className={merged} title={hint}>
|
||||
PiP 非対応
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltip = pip.lastError
|
||||
? `直前のエラー: ${pip.lastError}`
|
||||
: pip.isOpen
|
||||
? 'PiP ウィンドウを閉じてここに戻す'
|
||||
: '別ウィンドウに切り出す(常に最前面)';
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pip.isOpen) pip.close();
|
||||
else void pip.open();
|
||||
}}
|
||||
className={merged}
|
||||
title={tooltip}
|
||||
>
|
||||
{pip.isOpen ? '↩ PiP を戻す' : '⇱ PiP'}
|
||||
</button>
|
||||
{pip.lastError && !pip.isOpen && (
|
||||
<span
|
||||
className="text-[10px] text-rose-600 max-w-[260px] truncate"
|
||||
title={pip.lastError}
|
||||
>
|
||||
{pip.lastError}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
taskId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Status =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'success'; recordingName: string }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'no_recording' };
|
||||
|
||||
/**
|
||||
* Small toolbar button that flushes the active noVNC recording buffer to
|
||||
* disk via POST /api/users/me/recordings/flush?taskId=<taskId>.
|
||||
* Shows an inline confirmation with the saved file name for 4 s on success,
|
||||
* or a plain-text error for 5 s on failure.
|
||||
*/
|
||||
export function SaveRecordingButton({ taskId, className }: Props) {
|
||||
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||
const [linkVisible, setLinkVisible] = useState(false);
|
||||
|
||||
const baseClass =
|
||||
'text-2xs px-2 py-1 rounded-md border border-hairline bg-white hover:bg-surface text-slate-700 disabled:opacity-50';
|
||||
const merged = className ? `${baseClass} ${className}` : baseClass;
|
||||
|
||||
async function handleClick() {
|
||||
setStatus({ kind: 'loading' });
|
||||
setLinkVisible(false);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/users/me/recordings/flush?taskId=${encodeURIComponent(taskId)}`,
|
||||
{ method: 'POST', credentials: 'include' },
|
||||
);
|
||||
|
||||
if (res.status === 404) {
|
||||
setStatus({ kind: 'no_recording' });
|
||||
setTimeout(() => setStatus({ kind: 'idle' }), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const body = await res.json() as { message?: string; error?: string };
|
||||
msg = body.message ?? body.error ?? msg;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
setStatus({ kind: 'error', message: msg });
|
||||
setTimeout(() => setStatus({ kind: 'idle' }), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await res.json() as { ok: boolean; recordingName?: string };
|
||||
const recordingName = body.recordingName ?? 'recording';
|
||||
setStatus({ kind: 'success', recordingName });
|
||||
setLinkVisible(true);
|
||||
setTimeout(() => setStatus({ kind: 'idle' }), 4000);
|
||||
setTimeout(() => setLinkVisible(false), 10000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setStatus({ kind: 'error', message: msg });
|
||||
setTimeout(() => setStatus({ kind: 'idle' }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserFolderClick() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'userfolder');
|
||||
window.location.search = url.searchParams.toString();
|
||||
}
|
||||
|
||||
function label(): string {
|
||||
switch (status.kind) {
|
||||
case 'loading':
|
||||
return '保存中…';
|
||||
case 'success':
|
||||
return `✓ 保存: ${status.recordingName}.json`;
|
||||
case 'error':
|
||||
return `× ${status.message}`;
|
||||
case 'no_recording':
|
||||
return 'BrowseWeb で recordTo を指定するとここで保存できます';
|
||||
default:
|
||||
return '💾 録画を保存';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-col items-start gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClick()}
|
||||
disabled={status.kind === 'loading'}
|
||||
className={merged}
|
||||
title={
|
||||
status.kind === 'no_recording'
|
||||
? 'BrowseWeb ツールの recordTo オプションで録画を開始すると保存できます'
|
||||
: '録画バッファをファイルに書き出す'
|
||||
}
|
||||
>
|
||||
{label()}
|
||||
</button>
|
||||
{linkVisible && status.kind === 'success' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUserFolderClick}
|
||||
className="text-[10px] text-accent hover:underline pl-0.5"
|
||||
>
|
||||
→ User Folder で開く
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useState } from 'react';
|
||||
import { LocalTaskComment } from '../../api';
|
||||
import { MarkdownPreview } from '../files/FilePreview';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
|
||||
|
||||
// We delegate spacing to MarkdownText's built-in COMPACT_PROSE default
|
||||
// (4px-ish paragraph margins, leading-snug, `!important` to beat the
|
||||
// prose plugin). Don't pass a className here — that would replace
|
||||
// the compact preset with whatever string we pass, which is exactly
|
||||
// the bug we kept reintroducing earlier.
|
||||
|
||||
interface ChatMessageProps {
|
||||
comment: LocalTaskComment;
|
||||
taskId: number;
|
||||
/** Override the base URL used for inline Markdown images (e.g. for shared view) */
|
||||
imageBaseUrl?: string;
|
||||
/** When true, this thinking comment has been superseded — show static dot instead of spinner */
|
||||
isStaleThinking?: boolean;
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
movement: string;
|
||||
tools: Record<string, number>;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface ThinkingData {
|
||||
type: 'thinking';
|
||||
text: string;
|
||||
movement?: string;
|
||||
}
|
||||
|
||||
function tryParseInterjectionAck(body: string): { commentIds: number[]; movement: string } | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data && data.type === 'interjection_ack' && Array.isArray(data.commentIds)) {
|
||||
return data;
|
||||
}
|
||||
} catch { /* not ack JSON */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryParseThinking(body: string): ThinkingData | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data && data.type === 'thinking' && typeof data.text === 'string') {
|
||||
return data as ThinkingData;
|
||||
}
|
||||
} catch { /* not thinking JSON */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ChecklistProgressData {
|
||||
type: 'checklist';
|
||||
name: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'done' | 'failed' | 'skipped';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
done: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
remaining: number;
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseChecklistProgress(body: string): ChecklistProgressData | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data && data.type === 'checklist' && data.name && data.items && data.summary) {
|
||||
return data as ChecklistProgressData;
|
||||
}
|
||||
} catch { /* not checklist JSON */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryParseProgress(body: string): ProgressData | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (
|
||||
data &&
|
||||
typeof data.movement === 'string' &&
|
||||
typeof data.durationMs === 'number' &&
|
||||
data.tools && typeof data.tools === 'object' &&
|
||||
data.type !== 'tool_call'
|
||||
) {
|
||||
return data as ProgressData;
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
return `${min}m ${sec % 60}s`;
|
||||
}
|
||||
|
||||
function ChecklistCard({ comment }: { comment: LocalTaskComment }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const data = tryParseChecklistProgress(comment.body);
|
||||
if (!data) return null;
|
||||
|
||||
const { name, items, summary } = data;
|
||||
const pct = summary.total > 0 ? Math.round(((summary.done + summary.failed + summary.skipped) / summary.total) * 100) : 0;
|
||||
|
||||
// Show max 20 items in collapsed, all in expanded
|
||||
const displayItems = expanded ? items : items.slice(0, 20);
|
||||
const hasMore = !expanded && items.length > 20;
|
||||
|
||||
// Refero refresh: replace unicode glyphs with inline SVG so icons render
|
||||
// consistently across font stacks and align with the new palette.
|
||||
const StatusIcon = ({ status }: { status: string }) => {
|
||||
const common = 'inline-block w-3.5 h-3.5 flex-shrink-0';
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return (
|
||||
<svg className={`${common} text-emerald-600`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2.5" fill="currentColor" fillOpacity="0.12" stroke="currentColor" />
|
||||
<path d="M5 8.5l2 2 4-4.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<svg className={`${common} text-red-600`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2.5" fill="currentColor" fillOpacity="0.1" stroke="currentColor" />
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" />
|
||||
</svg>
|
||||
);
|
||||
case 'skipped':
|
||||
return (
|
||||
<svg className={`${common} text-slate-400`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2.5" stroke="currentColor" />
|
||||
<path d="M4.5 4.5l7 7" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className={`${common} text-slate-300`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="2" width="12" height="12" rx="2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white border border-hairline rounded-md px-3.5 py-2.5 max-w-[90%] w-full">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between text-left hover:bg-surface rounded -mx-1 px-1 py-0.5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg className="w-4 h-4 text-slate-500 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
<span className="text-[13px] font-semibold text-slate-900 truncate">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-2xs text-slate-500 font-mono tabular-nums">
|
||||
{summary.done + summary.failed + summary.skipped}/{summary.total} {'\u00B7'} {pct}%
|
||||
</span>
|
||||
<svg className={`w-3 h-3 text-slate-400 transition-transform ${expanded ? 'rotate-90' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex gap-1.5 mt-2 text-2xs">
|
||||
{summary.done > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-100 px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<StatusIcon status="done" />{summary.done}
|
||||
</span>
|
||||
)}
|
||||
{summary.failed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-red-50 text-red-700 border border-red-100 px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<StatusIcon status="failed" />{summary.failed}
|
||||
</span>
|
||||
)}
|
||||
{summary.skipped > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-surface-2 text-slate-600 border border-hairline px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<StatusIcon status="skipped" />{summary.skipped}
|
||||
</span>
|
||||
)}
|
||||
{summary.remaining > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-white text-slate-500 border border-hairline px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<StatusIcon status="pending" />{summary.remaining}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items list */}
|
||||
{(expanded || items.length <= 20) && (
|
||||
<div className="mt-2 pt-2 border-t border-hairline-soft max-h-[400px] overflow-y-auto">
|
||||
{displayItems.map(item => (
|
||||
<div key={item.id} className="flex items-start gap-2 py-1 text-xs">
|
||||
<span className="mt-0.5"><StatusIcon status={item.status} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-slate-700 truncate" title={item.label || item.id}>
|
||||
{item.label || item.id}
|
||||
</span>
|
||||
{item.label && item.label !== item.id && (
|
||||
<span className="block text-[10px] leading-tight text-slate-400 font-mono truncate" title={item.id}>
|
||||
{item.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.status === 'done' && item.result && (
|
||||
<span className="text-slate-400 truncate max-w-[200px] font-mono text-2xs">{'\u2192'} {item.result}</span>
|
||||
)}
|
||||
{item.status === 'failed' && item.error && (
|
||||
<span className="text-red-500 truncate max-w-[200px] font-mono text-2xs">{item.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-2xs text-slate-500 hover:text-slate-900 hover:underline mt-1.5"
|
||||
>
|
||||
{'\u4ED6'} {items.length - 20} {'\u4EF6\u3092\u8868\u793A...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="w-3 h-3 animate-spin text-slate-500" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.ReactNode; children: React.ReactNode; variant?: 'inline' | 'block' }) {
|
||||
// inline: short one-liner (movement summary / fallback) — pill shape
|
||||
// block: potentially multi-line thinking text — rounded rectangle to avoid weird ellipse corners
|
||||
const shapeCls = variant === 'block'
|
||||
? 'rounded-xl rounded-bl-md px-3 py-2 items-start max-w-[85%]'
|
||||
: 'rounded-full px-3 py-1.5 items-center max-w-[90%]';
|
||||
// Use a div for the text wrapper so children can be block-level MD output
|
||||
// (MarkdownText renders a <div>). NB: NO whitespace-pre-wrap here — that
|
||||
// turns the literal `\n` between `</p>` and `<ol>` (etc.) in the marked
|
||||
// output into a rendered newline, adding ~22px of empty vertical space
|
||||
// between every Markdown block. The plain-text callsites in this file
|
||||
// (movement summary string) are single-line, so they don't need it
|
||||
// either.
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className={`inline-flex gap-2 bg-slate-50 border border-slate-200 text-xs text-slate-600 ${shapeCls}`}>
|
||||
<span className={`flex-shrink-0 ${variant === 'block' ? 'mt-0.5' : ''}`}>{icon}</span>
|
||||
<div className="break-words min-w-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) {
|
||||
// Interjection ack → minimal centered confirmation
|
||||
const ackData = tryParseInterjectionAck(comment.body);
|
||||
if (ackData) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 text-[10px] text-green-600 font-medium">
|
||||
<span>{'✓'}</span>
|
||||
<span>メッセージを確認しました</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool call comment → render as single-item ToolCallsSection.
|
||||
// This path fires when a tool_call comment is emitted before the parent
|
||||
// movement-complete arrives (live tool calls during running movement).
|
||||
const toolCall = parseToolCallComment(comment.body);
|
||||
if (toolCall) {
|
||||
return <ToolCallsSection toolCalls={[toolCall]} />;
|
||||
}
|
||||
|
||||
// Checklist progress → dedicated card (center, retained as per decision)
|
||||
const checklistData = tryParseChecklistProgress(comment.body);
|
||||
if (checklistData) {
|
||||
return <ChecklistCard comment={comment} />;
|
||||
}
|
||||
|
||||
// Thinking / in-flight LLM text \u2014 MD render so streaming output that
|
||||
// contains lists / fenced code / inline backticks looks right.
|
||||
const thinking = tryParseThinking(comment.body);
|
||||
if (thinking) {
|
||||
const icon = isStaleThinking
|
||||
? <span className="text-slate-400">{'\u2026'}</span>
|
||||
: <Spinner />;
|
||||
return (
|
||||
<ProgressPill icon={icon} variant="block">
|
||||
<MarkdownText text={thinking.text} />
|
||||
</ProgressPill>
|
||||
);
|
||||
}
|
||||
|
||||
// Movement completion summary JSON \u2014 structured one-liner, keep plain.
|
||||
const data = tryParseProgress(comment.body);
|
||||
if (data) {
|
||||
const toolEntries = Object.entries(data.tools);
|
||||
const toolSummary = toolEntries.map(([name, count]) => `${name}\u00D7${count}`).join(', ');
|
||||
const text = `${data.movement} \u5B8C\u4E86${toolSummary ? ` \u00B7 ${toolSummary}` : ''} \u00B7 ${formatDuration(data.durationMs)}`;
|
||||
return <ProgressPill icon={<span className="text-green-600">{'\u2713'}</span>}>{text}</ProgressPill>;
|
||||
}
|
||||
|
||||
// Fallback: free-form text, MD render
|
||||
return (
|
||||
<ProgressPill icon={<span className="text-slate-400">{'\u2022'}</span>}>
|
||||
<MarkdownText text={comment.body} />
|
||||
</ProgressPill>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
|
||||
const { kind, author, body, createdAt } = comment;
|
||||
|
||||
// Progress card (center)
|
||||
if (kind === 'progress') {
|
||||
return <ProgressCard comment={comment} isStaleThinking={isStaleThinking} />;
|
||||
}
|
||||
|
||||
// Interjection (user message sent during running)
|
||||
if (kind === 'interjection') {
|
||||
const isPending = !comment.injectedAt;
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[82%] bg-amber-50 border border-amber-200 text-slate-900 rounded-2xl rounded-br-md px-4 py-3">
|
||||
<div className="text-2xs text-amber-500 mb-1.5">
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
|
||||
{isPending ? '⏳ エージェント確認待ち' : `✓ 確認済み ${new Date(comment.injectedAt!).toLocaleTimeString()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// User messages (right, soft slate — kit style)
|
||||
if (kind === 'request' || kind === 'comment') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[82%] bg-slate-100 text-slate-900 rounded-2xl rounded-br-md px-4 py-3">
|
||||
<div className="text-2xs text-slate-400 mb-1.5">
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent ask (left, yellow)
|
||||
if (kind === 'ask') {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[82%] bg-amber-50 border border-amber-200 text-slate-900 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
|
||||
<div className="text-2xs text-amber-500 mb-1.5">
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent result (left, green) - render with Markdown
|
||||
if (kind === 'result') {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="w-full bg-green-50 border border-green-200 text-slate-900 rounded-xl px-4 py-3 shadow-sm">
|
||||
<div className="text-2xs text-green-500 mb-1.5">
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed prose prose-sm prose-slate max-w-none">
|
||||
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown kinds
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[82%] bg-white border border-slate-200 text-slate-900 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
|
||||
<div className="text-2xs text-slate-400 mb-1.5">
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { LocalTask, LocalTaskComment } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
|
||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||
import { useJobStream } from '../../hooks/useJobStream';
|
||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
|
||||
|
||||
async function toBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? '');
|
||||
resolve(result.includes(',') ? result.split(',')[1]! : result);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('file read error'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
interface ChatPaneProps {
|
||||
task: LocalTask;
|
||||
comments: LocalTaskComment[];
|
||||
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onCancel?: () => Promise<void>;
|
||||
onOpenDetail?: () => void;
|
||||
}
|
||||
|
||||
export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: ChatPaneProps) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||
const prevCommentCountRef = useRef(comments.length);
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setIsAtBottom(checkIfAtBottom());
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handler);
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const delta = comments.length - prevCommentCountRef.current;
|
||||
prevCommentCountRef.current = comments.length;
|
||||
if (delta <= 0) return;
|
||||
if (isAtBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setNewMessageCount(prev => prev + delta);
|
||||
}
|
||||
}, [comments.length, isAtBottom]);
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const converted = await Promise.all(
|
||||
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) }))
|
||||
);
|
||||
setAttachments(prev => [...prev, ...converted]);
|
||||
};
|
||||
|
||||
const removeAttachment = (name: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.name !== name));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
||||
setSendError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
|
||||
setDraft('');
|
||||
setAttachments([]);
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error && e.message ? e.message : '送信に失敗しました');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
const converted = await Promise.all(
|
||||
files.map(async f => {
|
||||
const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name;
|
||||
return { name, contentBase64: await toBase64(f) };
|
||||
})
|
||||
);
|
||||
setAttachments(prev => [...prev, ...converted]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!onCancel || cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancel();
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const { promptProgress, streamingText, toolCallStream, connected } = useJobStream(task.id, jobStatus);
|
||||
|
||||
// Most-recent content-field tool with decoded content to show live.
|
||||
const liveToolContent = useMemo(() => {
|
||||
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
||||
for (let k = entries.length - 1; k >= 0; k--) {
|
||||
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
||||
if (text) return { name: entries[k].name, text };
|
||||
}
|
||||
return null;
|
||||
}, [toolCallStream]);
|
||||
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
||||
}, [liveToolContent?.text]);
|
||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
||||
const inputLocked = jobStatus === 'dispatching';
|
||||
|
||||
// During an active run, suppress the trailing thinking comment so the
|
||||
// live SSE preview is the single source of truth for in-flight text.
|
||||
// We keep the comment in history (MovementGroup will render it once the
|
||||
// movement completes). On SSE disconnect we keep it visible as fallback.
|
||||
const visibleComments = useMemo(() => {
|
||||
if (!isBusy) return comments;
|
||||
if (!connected) return comments; // SSE disconnect fallback
|
||||
if (!hasTrailingThinking(comments)) return comments;
|
||||
return comments.slice(0, -1);
|
||||
}, [comments, isBusy, connected]);
|
||||
|
||||
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
||||
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full overflow-hidden">
|
||||
{/* Tablet+ only. Mobile renders its own app-level instance so the
|
||||
pet is visible across all mobile tabs (Progress / Files / Trace /
|
||||
Browser / SSH), not just Chat. */}
|
||||
<ChatPetOverlay
|
||||
taskId={task.id}
|
||||
taskStatus={task.latestJob?.status ?? null}
|
||||
currentActivity={task.latestJob?.currentActivity ?? null}
|
||||
workerId={task.latestJob?.workerId ?? null}
|
||||
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
||||
className="hidden sm:block"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-hairline bg-white px-4 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-slate-900 truncate">{task.title}</h2>
|
||||
<div className="text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{isBusy && (
|
||||
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
|
||||
isWaitingSubtasks
|
||||
? 'border-indigo-100 bg-indigo-50'
|
||||
: 'border-emerald-100 bg-emerald-50'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full animate-pulse ${
|
||||
isWaitingSubtasks ? 'bg-indigo-500' : 'bg-emerald-500'
|
||||
}`} />
|
||||
<span className={`text-[10px] font-medium ${
|
||||
isWaitingSubtasks ? 'text-indigo-700' : 'text-emerald-700'
|
||||
}`}>
|
||||
{isWaitingSubtasks ? 'subtasks' : 'running'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{onOpenDetail && (
|
||||
<button
|
||||
onClick={onOpenDetail}
|
||||
className="px-2.5 h-7 text-2xs font-medium text-slate-700 border border-hairline bg-white hover:bg-surface rounded-md transition-colors"
|
||||
title="詳細を表示"
|
||||
>
|
||||
詳細
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||||
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-[13px] py-8">
|
||||
メッセージはまだありません
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
let commentIdx = 0;
|
||||
return groupedItems.map((item, gi) => {
|
||||
if (item.type === 'movement') {
|
||||
const startIdx = commentIdx;
|
||||
commentIdx += item.inner.length;
|
||||
return (
|
||||
<MovementGroupExpanded
|
||||
key={`mg-${gi}`}
|
||||
item={item}
|
||||
taskId={task.id}
|
||||
isLast={gi === groupedItems.length - 1}
|
||||
isRunning={isBusy}
|
||||
animatingIdx={animatingIdx}
|
||||
startIdx={startIdx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const idx = commentIdx;
|
||||
commentIdx++;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.comment.id}
|
||||
comment={item.comment}
|
||||
taskId={task.id}
|
||||
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||||
<SubtaskInlineCard
|
||||
subtasks={task.subtasks}
|
||||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBusy && !isWaitingSubtasks && (
|
||||
<div className="flex justify-start">
|
||||
{promptProgress ? (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span>Processing</span>
|
||||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||
<div
|
||||
className="bg-emerald-500 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${promptProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : streamingText ? (
|
||||
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-white border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||||
{streamingText}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</div>
|
||||
) : liveToolContent ? (
|
||||
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||||
<div className="text-2xs text-slate-500 mb-1 font-mono">{liveToolContent.name} 生成中…</div>
|
||||
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||||
{liveToolContent.text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
エージェントが応答を生成中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll-to-bottom button */}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-slate-200 rounded-full shadow-md text-xs text-slate-600 hover:bg-slate-50 transition-colors z-10"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
{newMessageCount > 0 ? (
|
||||
<span className="text-blue-600 font-medium">{newMessageCount} 件の新着</span>
|
||||
) : (
|
||||
<span>最新へ</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-white p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
{isBusy && (
|
||||
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
||||
canInterject
|
||||
? 'bg-amber-50 border border-amber-100 text-amber-700'
|
||||
: 'bg-blue-50 border border-blue-100 text-blue-700'
|
||||
}`}>
|
||||
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span>{canInterject ? 'エージェント実行中 — メッセージで指示を送れます' : 'エージェントがタスクを実行中です。少々お待ちください。'}</span>
|
||||
</div>
|
||||
)}
|
||||
{sendError && !isBusy && (
|
||||
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 border border-red-100 rounded-md text-2xs text-red-700">
|
||||
<span className="truncate">⚠ {sendError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
className="flex-shrink-0 px-2 h-6 bg-white border border-red-200 rounded text-[10px] font-medium text-red-700 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
再送信
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{attachments.map(a => (
|
||||
<span key={a.name} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-2 border border-hairline rounded text-[10px] text-slate-700 font-mono">
|
||||
{a.name}
|
||||
<button onClick={() => removeAttachment(a.name)} className="text-slate-400 hover:text-slate-700 ml-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1.5 items-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={inputLocked}
|
||||
className="flex-shrink-0 w-9 h-9 flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-surface rounded-md transition-colors disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed"
|
||||
title="ファイルを添付"
|
||||
aria-label="ファイルを添付"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={e => void handlePaste(e)}
|
||||
rows={2}
|
||||
disabled={inputLocked}
|
||||
placeholder={inputLocked ? 'ジョブ割り当て中...' : canInterject ? '実行中のエージェントに指示...' : 'メッセージを入力... (Ctrl+Enter で送信)'}
|
||||
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
<div className="flex gap-1.5">
|
||||
{canInterject && (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className="px-3 h-9 bg-amber-500 text-white rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-amber-600 flex-shrink-0 transition-colors"
|
||||
>
|
||||
割り込み
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={cancelling}
|
||||
onClick={() => void handleCancel()}
|
||||
className="px-3 h-9 bg-white border border-red-200 text-red-700 rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-red-50 flex-shrink-0 transition-colors"
|
||||
title="エージェントの実行を停止"
|
||||
>
|
||||
{cancelling ? '停止中...' : '停止'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className="px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||||
>
|
||||
送信
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { LocalTaskComment } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment } from './thinkingUtils';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { ToolCallsSection, parseToolCallComment, type ToolCallData } from './ToolCallsSection';
|
||||
|
||||
interface ProgressData {
|
||||
movement: string;
|
||||
tools: Record<string, number>;
|
||||
durationMs: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
function tryParseMovementComplete(body: string): ProgressData | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data && typeof data.movement === 'string' && typeof data.durationMs === 'number' && typeof data.tools === 'object') {
|
||||
return data as ProgressData;
|
||||
}
|
||||
} catch { /* not movement JSON */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMovementCompleteComment(c: LocalTaskComment): boolean {
|
||||
return c.kind === 'progress' && tryParseMovementComplete(c.body) !== null;
|
||||
}
|
||||
|
||||
function isToolCallComment(c: LocalTaskComment): boolean {
|
||||
return c.kind === 'progress' && parseToolCallComment(c.body) !== null;
|
||||
}
|
||||
|
||||
function getThinkingText(c: LocalTaskComment): string | null {
|
||||
try {
|
||||
const data = JSON.parse(c.body);
|
||||
if (data && data.type === 'thinking' && typeof data.text === 'string') {
|
||||
return data.text;
|
||||
}
|
||||
} catch { /* not thinking */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ChatItem =
|
||||
| { type: 'comment'; comment: LocalTaskComment }
|
||||
| { type: 'movement'; movementName: string; summary: ProgressData; inner: LocalTaskComment[]; completionComment: LocalTaskComment };
|
||||
|
||||
export function groupCommentsByMovement(comments: LocalTaskComment[]): ChatItem[] {
|
||||
const items: ChatItem[] = [];
|
||||
let pendingInner: LocalTaskComment[] = [];
|
||||
|
||||
const isUserComment = (c: LocalTaskComment) =>
|
||||
c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection';
|
||||
|
||||
for (const c of comments) {
|
||||
if (isMovementCompleteComment(c)) {
|
||||
const summary = tryParseMovementComplete(c.body)!;
|
||||
items.push({
|
||||
type: 'movement',
|
||||
movementName: summary.movement,
|
||||
summary,
|
||||
inner: pendingInner,
|
||||
completionComment: c,
|
||||
});
|
||||
pendingInner = [];
|
||||
} else if (isUserComment(c)) {
|
||||
if (pendingInner.length > 0) {
|
||||
for (const p of pendingInner) {
|
||||
items.push({ type: 'comment', comment: p });
|
||||
}
|
||||
pendingInner = [];
|
||||
}
|
||||
items.push({ type: 'comment', comment: c });
|
||||
} else {
|
||||
pendingInner.push(c);
|
||||
}
|
||||
}
|
||||
for (const p of pendingInner) {
|
||||
items.push({ type: 'comment', comment: p });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
return `${min}m ${sec % 60}s`;
|
||||
}
|
||||
|
||||
function getPreviewText(item: ChatItem & { type: 'movement' }): string | null {
|
||||
if (item.summary.summary) return item.summary.summary;
|
||||
for (let i = item.inner.length - 1; i >= 0; i--) {
|
||||
const text = getThinkingText(item.inner[i]!);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MovementGroupExpandedProps {
|
||||
item: ChatItem & { type: 'movement' };
|
||||
taskId: number;
|
||||
isLast: boolean;
|
||||
isRunning: boolean;
|
||||
animatingIdx: number;
|
||||
startIdx: number;
|
||||
}
|
||||
|
||||
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }: MovementGroupExpandedProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { movementName, summary, inner } = item;
|
||||
const previewText = getPreviewText(item);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Header — always visible */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-start gap-2 text-left py-1.5 hover:bg-surface/50 rounded -mx-1 px-1 transition-colors group"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-slate-400 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-700">{movementName}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono tabular-nums">{formatDuration(summary.durationMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Collapsed: summary full text */}
|
||||
{!expanded && previewText && (
|
||||
<div className="ml-5 mt-0.5 mb-1">
|
||||
<div className="text-xs text-slate-600 leading-relaxed">
|
||||
<MarkdownText text={previewText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded: render inner comments in chronological order. Consecutive
|
||||
tool_call comments are merged into one ToolCallsSection so the
|
||||
expanded view matches what users saw while the movement was running. */}
|
||||
{expanded && (() => {
|
||||
type Block =
|
||||
| { kind: 'comment'; comment: LocalTaskComment; origIdx: number }
|
||||
| { kind: 'tools'; toolCalls: ToolCallData[]; firstId: number };
|
||||
const blocks: Block[] = [];
|
||||
let toolBuf: ToolCallData[] = [];
|
||||
let toolFirstId = 0;
|
||||
const flushTools = () => {
|
||||
if (toolBuf.length > 0) {
|
||||
blocks.push({ kind: 'tools', toolCalls: toolBuf, firstId: toolFirstId });
|
||||
toolBuf = [];
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const c = inner[i]!;
|
||||
const tc = isToolCallComment(c) ? parseToolCallComment(c.body) : null;
|
||||
if (tc) {
|
||||
if (toolBuf.length === 0) toolFirstId = c.id;
|
||||
toolBuf.push(tc);
|
||||
} else {
|
||||
flushTools();
|
||||
blocks.push({ kind: 'comment', comment: c, origIdx: i });
|
||||
}
|
||||
}
|
||||
flushTools();
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<div className="ml-5 mt-1 mb-1 border-l-2 border-slate-100 pl-3">
|
||||
<div className="text-[10px] text-slate-400 py-1">中間出力なし</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="ml-5 mt-1 mb-1 flex flex-col gap-3 border-l-2 border-slate-100 pl-3">
|
||||
{blocks.map((b) =>
|
||||
b.kind === 'comment' ? (
|
||||
<ChatMessage
|
||||
key={`c-${b.comment.id}`}
|
||||
comment={b.comment}
|
||||
taskId={taskId}
|
||||
isStaleThinking={isThinkingComment(b.comment) && (startIdx + b.origIdx) !== animatingIdx}
|
||||
/>
|
||||
) : (
|
||||
<ToolCallsSection key={`t-${b.firstId}`} toolCalls={b.toolCalls} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { SubtaskInfo } from '../../api';
|
||||
import { statusTone, formatStatusLabel } from '../../lib/utils';
|
||||
|
||||
interface SubtaskInlineCardProps {
|
||||
subtasks: SubtaskInfo[];
|
||||
subtaskCount: number;
|
||||
subtaskCompleted: number;
|
||||
}
|
||||
|
||||
function SubtaskStatusIcon({ status }: { status: string }) {
|
||||
const common = 'inline-block w-3.5 h-3.5 flex-shrink-0';
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
return (
|
||||
<svg className={`${common} text-emerald-600`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="6" fill="currentColor" fillOpacity="0.12" stroke="currentColor" />
|
||||
<path d="M5.5 8.5l2 2 3-3.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<svg className={`${common} text-red-600`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" />
|
||||
<path d="M6 6l4 4M10 6l-4 4" />
|
||||
</svg>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<svg className={`${common} text-blue-500 animate-spin`} viewBox="0 0 16 16" fill="none">
|
||||
<circle className="opacity-25" cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" />
|
||||
<path className="opacity-75" fill="currentColor" d="M2 8a6 6 0 016-6v2a4 4 0 00-4 4H2z" />
|
||||
</svg>
|
||||
);
|
||||
case 'waiting_subtasks':
|
||||
return (
|
||||
<svg className={`${common} text-indigo-500 animate-pulse`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75">
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<circle cx="5" cy="8" r="1" fill="currentColor" />
|
||||
<circle cx="8" cy="8" r="1" fill="currentColor" />
|
||||
<circle cx="11" cy="8" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
default: // queued, cancelled, etc.
|
||||
return (
|
||||
<svg className={`${common} text-slate-300`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function SubtaskInlineCard({ subtasks, subtaskCount, subtaskCompleted }: SubtaskInlineCardProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const progressPct = subtaskCount > 0 ? Math.round((subtaskCompleted / subtaskCount) * 100) : 0;
|
||||
const allDone = subtaskCompleted === subtaskCount;
|
||||
const running = subtasks.filter(st => st.status === 'running').length;
|
||||
const failed = subtasks.filter(st => st.status === 'failed').length;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white border border-hairline rounded-md px-3.5 py-2.5 max-w-[90%] w-full">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between text-left hover:bg-surface rounded -mx-1 px-1 py-0.5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{!allDone ? (
|
||||
<svg className="w-4 h-4 text-indigo-500 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-[13px] font-semibold text-slate-900 truncate">サブタスク</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-2xs text-slate-500 font-mono tabular-nums">
|
||||
{subtaskCompleted}/{subtaskCount} · {progressPct}%
|
||||
</span>
|
||||
<svg className={`w-3 h-3 text-slate-400 transition-transform ${expanded ? 'rotate-90' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex gap-1.5 mt-2 text-2xs">
|
||||
{subtaskCompleted > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-100 px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<SubtaskStatusIcon status="succeeded" />{subtaskCompleted}
|
||||
</span>
|
||||
)}
|
||||
{running > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-blue-50 text-blue-700 border border-blue-100 px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<SubtaskStatusIcon status="running" />{running}
|
||||
</span>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-red-50 text-red-700 border border-red-100 px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<SubtaskStatusIcon status="failed" />{failed}
|
||||
</span>
|
||||
)}
|
||||
{subtaskCount - subtaskCompleted - running - failed > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-white text-slate-500 border border-hairline px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||
<SubtaskStatusIcon status="queued" />{subtaskCount - subtaskCompleted - running - failed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-slate-100 rounded-full h-1 mt-2">
|
||||
<div
|
||||
className={`h-1 rounded-full transition-all duration-500 ${allDone ? 'bg-emerald-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtask list */}
|
||||
{expanded && (
|
||||
<div className="mt-2 pt-2 border-t border-hairline-soft max-h-[300px] overflow-y-auto">
|
||||
{subtasks.map(st => {
|
||||
const title = st.instruction.split('\n')[0]?.slice(0, 100) ?? '';
|
||||
return (
|
||||
<div key={st.id} className="flex items-start gap-2 py-1 text-xs">
|
||||
<span className="mt-0.5"><SubtaskStatusIcon status={st.status} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-slate-700 truncate" title={title}>
|
||||
#{st.issueNumber} {title}
|
||||
</span>
|
||||
</span>
|
||||
{st.status === 'running' && (
|
||||
<span className="text-blue-500 text-2xs font-mono flex-shrink-0">実行中</span>
|
||||
)}
|
||||
{st.status === 'succeeded' && (
|
||||
<span className="text-emerald-500 text-2xs font-mono flex-shrink-0">完了</span>
|
||||
)}
|
||||
{st.status === 'failed' && (
|
||||
<span className="text-red-500 text-2xs font-mono flex-shrink-0">失敗</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ToolCallData {
|
||||
callId: string;
|
||||
movement: string;
|
||||
name: string;
|
||||
args: string;
|
||||
result: string;
|
||||
isError: boolean;
|
||||
durationMs: number;
|
||||
cacheHit: boolean;
|
||||
}
|
||||
|
||||
export function parseToolCallComment(body: string): ToolCallData | null {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data && data.type === 'tool_call' && typeof data.name === 'string') {
|
||||
return {
|
||||
callId: data.callId ?? '',
|
||||
movement: data.movement ?? '',
|
||||
name: data.name,
|
||||
args: typeof data.args === 'string' ? data.args : JSON.stringify(data.args ?? {}),
|
||||
result: typeof data.result === 'string' ? data.result : '',
|
||||
isError: !!data.isError,
|
||||
durationMs: typeof data.durationMs === 'number' ? data.durationMs : 0,
|
||||
cacheHit: !!data.cacheHit,
|
||||
};
|
||||
}
|
||||
} catch { /* not tool_call JSON */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const sec = ms / 1000;
|
||||
if (sec < 10) return `${sec.toFixed(1)}s`;
|
||||
return `${Math.round(sec)}s`;
|
||||
}
|
||||
|
||||
const PREVIEW_LINES = 25;
|
||||
|
||||
function truncateLines(text: string, maxLines: number): { display: string; truncated: boolean } {
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= maxLines) return { display: text, truncated: false };
|
||||
return { display: lines.slice(0, maxLines).join('\n'), truncated: true };
|
||||
}
|
||||
|
||||
function summarizeArgs(name: string, argsStr: string): string {
|
||||
try {
|
||||
const args = JSON.parse(argsStr);
|
||||
if (!args || typeof args !== 'object') return '';
|
||||
// Common patterns: file_path, path, command, query, url
|
||||
const keys = ['file_path', 'path', 'command', 'cmd', 'query', 'url', 'pattern'];
|
||||
for (const k of keys) {
|
||||
if (typeof args[k] === 'string') {
|
||||
const v = args[k] as string;
|
||||
return v.length > 80 ? v.slice(0, 80) + '…' : v;
|
||||
}
|
||||
}
|
||||
// Fallback: first string value
|
||||
for (const v of Object.values(args)) {
|
||||
if (typeof v === 'string') {
|
||||
return v.length > 80 ? v.slice(0, 80) + '…' : v;
|
||||
}
|
||||
}
|
||||
// Fallback: keys
|
||||
return Object.keys(args).slice(0, 3).join(', ');
|
||||
} catch { /* ignore */ }
|
||||
return argsStr.slice(0, 80);
|
||||
}
|
||||
|
||||
interface DisplayName {
|
||||
server: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function formatToolName(raw: string): DisplayName {
|
||||
// mcp__<server>__<tool> → split server/tool for compact display
|
||||
if (raw.startsWith('mcp__')) {
|
||||
const rest = raw.slice(5);
|
||||
const idx = rest.indexOf('__');
|
||||
if (idx > 0) {
|
||||
return { server: rest.slice(0, idx), name: rest.slice(idx + 2) };
|
||||
}
|
||||
}
|
||||
return { server: null, name: raw };
|
||||
}
|
||||
|
||||
function ToolCallRow({ tc }: { tc: ToolCallData }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = summarizeArgs(tc.name, tc.args);
|
||||
const argsPreview = truncateLines(tc.args, PREVIEW_LINES);
|
||||
const resultPreview = truncateLines(tc.result, PREVIEW_LINES);
|
||||
const display = formatToolName(tc.name);
|
||||
|
||||
return (
|
||||
<li className="text-[11px] min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full text-left flex items-center gap-1.5 py-0.5 hover:bg-surface/50 rounded -mx-1 px-1 transition-colors min-w-0"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 flex-shrink-0 text-slate-400 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<span className={`flex-shrink-0 ${tc.isError ? 'text-red-500' : 'text-emerald-600'}`}>
|
||||
{tc.isError ? '✕' : '✓'}
|
||||
</span>
|
||||
{display.server && (
|
||||
<span className="font-mono text-[10px] text-purple-500 bg-purple-50 px-1 py-px rounded flex-shrink-0">
|
||||
{display.server}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono font-medium text-slate-700 flex-shrink-0">{display.name}</span>
|
||||
{summary && <span className="font-mono text-slate-500 truncate min-w-0">{summary}</span>}
|
||||
<span className="text-slate-400 tabular-nums ml-auto flex-shrink-0">
|
||||
{tc.cacheHit ? 'cache' : formatDuration(tc.durationMs)}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-5 mt-1 mb-2 space-y-1.5">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-0.5">args</div>
|
||||
<pre className="text-[10px] font-mono bg-surface/70 border border-hairline rounded px-1.5 py-1 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{argsPreview.display}
|
||||
{argsPreview.truncated && <span className="text-slate-400">{'\n…(truncated)'}</span>}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 mb-0.5">result {tc.isError && <span className="text-red-500">(error)</span>}</div>
|
||||
<pre className="text-[10px] font-mono bg-surface/70 border border-hairline rounded px-1.5 py-1 overflow-x-auto whitespace-pre-wrap break-all max-h-64">
|
||||
{resultPreview.display}
|
||||
{resultPreview.truncated && <span className="text-slate-400">{'\n…(truncated)'}</span>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
toolCalls: ToolCallData[];
|
||||
}
|
||||
|
||||
export function ToolCallsSection({ toolCalls }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (toolCalls.length === 0) return null;
|
||||
|
||||
// Single tool call: render the row directly. The "▸ 1 tool call" wrapper
|
||||
// adds no value and hides the actual tool name from the user, who has to
|
||||
// expand twice (section + row) to see it.
|
||||
if (toolCalls.length === 1) {
|
||||
return (
|
||||
<ul className="space-y-0">
|
||||
<ToolCallRow tc={toolCalls[0]!} />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const errCount = toolCalls.filter(t => t.isError).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 text-left py-1 hover:bg-surface/50 rounded -mx-1 px-1 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 text-slate-400 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<span className="text-[11px] font-medium text-slate-600">
|
||||
{toolCalls.length} tool calls
|
||||
</span>
|
||||
{errCount > 0 && (
|
||||
<span className="text-[10px] text-red-500 font-mono">({errCount} error)</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="mt-0.5 space-y-0">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallRow key={tc.callId || `${tc.name}-${tc.durationMs}-${Math.random()}`} tc={tc} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LocalTaskComment } from '../../api';
|
||||
|
||||
export function isThinkingComment(c: LocalTaskComment): boolean {
|
||||
if (c.kind !== 'progress') return false;
|
||||
try {
|
||||
const data = JSON.parse(c.body);
|
||||
return data && data.type === 'thinking';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTrailingThinking(comments: LocalTaskComment[]): boolean {
|
||||
const last = comments[comments.length - 1];
|
||||
return !!last && isThinkingComment(last);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface AttachmentDropzoneProps {
|
||||
attachments: Array<{ name: string; contentBase64: string }>;
|
||||
onFilesChange: (files: Array<{ name: string; contentBase64: string }>) => void;
|
||||
}
|
||||
|
||||
async function toBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? '');
|
||||
resolve(result.includes(',') ? result.split(',')[1]! : result);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('file read error'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function AttachmentDropzone({ attachments, onFilesChange }: AttachmentDropzoneProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const converted = await Promise.all(
|
||||
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) }))
|
||||
);
|
||||
onFilesChange([...attachments, ...converted]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-4 transition-colors ${
|
||||
dragOver ? 'border-accent bg-accent-soft' : 'border-slate-200 bg-white'
|
||||
}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={e => { e.preventDefault(); setDragOver(false); void handleFiles(e.dataTransfer.files); }}
|
||||
>
|
||||
<div className="font-bold text-[13px] text-slate-700">添付ファイル</div>
|
||||
<div className="mt-1 text-xs text-slate-400">ドラッグ&ドロップまたはファイル選択</div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="mt-2 text-xs"
|
||||
onChange={e => void handleFiles(e.target.files)}
|
||||
/>
|
||||
{attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map(a => (
|
||||
<span key={a.name} className="px-2.5 py-1 bg-slate-100 rounded-full text-2xs text-slate-600">
|
||||
{a.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
|
||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||
import { ScheduleFields } from './ScheduleFields';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
/**
|
||||
* Optional preselected piece. When set, the piece is locked (not overridden
|
||||
* by the auto-classifier) and a placeholder hint explains the assistant.
|
||||
* Used by the Help Center "AI に聞く" button to land users in the help piece.
|
||||
*/
|
||||
initialPiece?: string;
|
||||
initialBody?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder }: CreateTaskDialogProps) {
|
||||
const { data: pieces } = usePieceList();
|
||||
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs, staleTime: 5 * 60 * 1000 });
|
||||
const { data: sessionProfiles = [] } = useQuery({
|
||||
queryKey: ['browser-session-profiles'],
|
||||
queryFn: listBrowserSessionProfiles,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
||||
interface ConnectionRow { serverId: string; serverName: string; connected: boolean }
|
||||
const { data: connections } = useQuery({
|
||||
queryKey: ['mcp-connections'],
|
||||
queryFn: async (): Promise<ConnectionRow[]> => {
|
||||
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.connections ?? []) as ConnectionRow[];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const authState = useAuthState();
|
||||
const defaultVis = (authState.mode === 'authenticated' ? authState.user?.defaultVisibility : undefined) ?? 'private';
|
||||
const savedOrgId = (authState.mode === 'authenticated' ? authState.user?.defaultVisibilityOrgId : undefined) ?? null;
|
||||
const [visibility, setVisibility] = useState<Visibility>(defaultVis);
|
||||
const [visibilityScopeOrgId, setVisibilityScopeOrgId] = useState<string | null>(savedOrgId);
|
||||
|
||||
// Backfill the scope when orgs finish loading: useQuery starts with orgs=[]
|
||||
// so without this the initial render freezes scopeId=null for users without
|
||||
// a saved default, and picking 'Organization' would submit an unscoped task.
|
||||
useEffect(() => {
|
||||
if (visibilityScopeOrgId !== null) return;
|
||||
if (orgs.length === 0) return;
|
||||
setVisibilityScopeOrgId(orgs[0].orgId);
|
||||
}, [orgs, visibilityScopeOrgId]);
|
||||
const [form, setForm] = useState<CreateLocalTaskInput>({
|
||||
body: initialBody ?? '',
|
||||
piece: initialPiece ?? 'auto',
|
||||
profile: 'auto',
|
||||
outputFormat: 'markdown',
|
||||
askPolicy: 'low',
|
||||
priority: 'medium',
|
||||
});
|
||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
|
||||
const [mcpDisabled, setMcpDisabled] = useState(false);
|
||||
const [skillsDisabled, setSkillsDisabled] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
const [schedule, setSchedule] = useState({
|
||||
scheduleType: 'daily',
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
cronExpression: '',
|
||||
scheduledAt: '',
|
||||
});
|
||||
|
||||
const selectedPiece = (pieces ?? []).find(p => p.name === form.piece);
|
||||
const missingMcp = selectedPiece?.requiredMcp
|
||||
? selectedPiece.requiredMcp.filter(
|
||||
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
|
||||
)
|
||||
: [];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.body.trim()) {
|
||||
setError('依頼内容は必須です');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
if (isScheduled) {
|
||||
const res = await fetch('/api/scheduled-tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: form.body.trim().slice(0, 40),
|
||||
body: form.body.trim(),
|
||||
piece: form.piece,
|
||||
visibility,
|
||||
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
||||
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
||||
...schedule,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('スケジュール作成に失敗しました');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const options: Record<string, boolean> = {};
|
||||
if (mcpDisabled) options.mcpDisabled = true;
|
||||
if (skillsDisabled) options.skillsDisabled = true;
|
||||
const submitForm = {
|
||||
...form,
|
||||
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
|
||||
// それ以外は form.piece (詳細設定で選択した値、無指定なら 'auto') を尊重する。
|
||||
piece: initialPiece ?? form.piece,
|
||||
title: undefined,
|
||||
body: form.body.trim(),
|
||||
visibility,
|
||||
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
||||
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
};
|
||||
await onSubmit(submitForm, attachments);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
|
||||
<Dialog.Content
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
|
||||
style={{ maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
|
||||
onOpenAutoFocus={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
|
||||
{initialPiece === 'help' ? 'AI ヘルプに質問' : '新しい Task'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
|
||||
{initialPiece === 'help'
|
||||
? '使い方や設計について自由に質問してください'
|
||||
: '依頼内容を入力して実行'}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
aria-label="閉じる"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Textarea */}
|
||||
<div>
|
||||
<label className="block text-[13px] text-slate-600 mb-1.5">依頼内容</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={form.body}
|
||||
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
|
||||
placeholder={placeholder ?? (initialPiece === 'help'
|
||||
? '例: 「ユーザーフォルダの memory/ と AGENTS.md の違いは?」 / 「MCP サーバーを個人で追加するには?」 / 「自分の最近のタスクは?」'
|
||||
: '依頼内容を入力してください (Ctrl+Enter で送信)')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
||||
|
||||
{/* MCP warnings (always visible when applicable) */}
|
||||
{missingMcp.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-300 rounded text-xs text-yellow-900 space-y-2">
|
||||
<div>
|
||||
<strong>このタスクには MCP 連携が必要です:</strong> {missingMcp.join(', ')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{missingMcp.map((id) => (
|
||||
<a
|
||||
key={id}
|
||||
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
|
||||
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{id} と連携
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-2xs text-yellow-700">
|
||||
未連携のままタスクを作成すると、waiting_human 状態で停止し、連携後に自動で再開します。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings toggle + content */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(prev => !prev)}
|
||||
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{showAdvanced ? '詳細設定を隠す' : '詳細設定を開く'}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
|
||||
{/* Row 1: Piece, Profile, Priority */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">タスクタイプ</label>
|
||||
<select
|
||||
value={form.piece}
|
||||
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="auto">自動選択</option>
|
||||
{(pieces ?? []).map(p => (
|
||||
<option key={p.name} value={p.name}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">プロファイル</label>
|
||||
<select
|
||||
value={form.profile}
|
||||
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">優先度</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Output Format, Ask Policy */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">出力形式</label>
|
||||
<select
|
||||
value={form.outputFormat}
|
||||
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">質問ポリシー</label>
|
||||
<select
|
||||
value={form.askPolicy}
|
||||
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="low">low (少なめ)</option>
|
||||
<option value="high">high (積極的に質問)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: MCP disable, Skills disable checkboxes */}
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mcpDisabled}
|
||||
onChange={e => setMcpDisabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
MCP ツールを無効化 (トークン節約)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skillsDisabled}
|
||||
onChange={e => setSkillsDisabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Skills を無効化
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Browser Session (only if active profiles exist) */}
|
||||
{activeSessionProfiles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">ブラウザセッション</label>
|
||||
<select
|
||||
value={browserSessionProfileId ?? ''}
|
||||
onChange={e =>
|
||||
setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">なし</option>
|
||||
{activeSessionProfiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
エージェントがこのサイト用の保存済みログインを使ってブラウズします。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">公開範囲</label>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
|
||||
非公開
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
|
||||
組織
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
|
||||
公開
|
||||
</label>
|
||||
</div>
|
||||
{visibility === 'org' && orgs.length > 1 && (
|
||||
<select
|
||||
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
|
||||
value={visibilityScopeOrgId ?? ''}
|
||||
onChange={e => setVisibilityScopeOrgId(e.target.value)}
|
||||
>
|
||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{visibility === 'org' && orgs.length === 1 && (
|
||||
<div className="mt-1 text-2xs text-slate-500">共有先: {orgs[0].orgName}</div>
|
||||
)}
|
||||
{visibility === 'org' && orgs.length === 0 && (
|
||||
<div className="mt-1 text-2xs text-slate-400">組織を使うには Gitea でログインしてください</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule toggle + sub-form */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="schedule-toggle"
|
||||
checked={isScheduled}
|
||||
onChange={e => setIsScheduled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">定期実行</label>
|
||||
</div>
|
||||
{isScheduled && (
|
||||
<ScheduleFields schedule={schedule} onChange={setSchedule} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[13px] text-red-600">{error}</div>}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end items-center gap-2 pt-1">
|
||||
<Dialog.Close asChild>
|
||||
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
|
||||
キャンセル
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
disabled={submitting}
|
||||
onClick={() => void handleSubmit()}
|
||||
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
|
||||
>
|
||||
{submitting ? '作成中...' : isScheduled ? 'スケジュール作成' : 'Task 作成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
interface ScheduleState {
|
||||
scheduleType: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
dayOfWeek: number;
|
||||
dayOfMonth: number;
|
||||
cronExpression: string;
|
||||
scheduledAt: string;
|
||||
}
|
||||
|
||||
interface ScheduleFieldsProps {
|
||||
schedule: ScheduleState;
|
||||
onChange: (updater: (prev: ScheduleState) => ScheduleState) => void;
|
||||
}
|
||||
|
||||
export function ScheduleFields({ schedule, onChange }: ScheduleFieldsProps) {
|
||||
return (
|
||||
<div className="pl-4 border-l-2 border-blue-200 space-y-2 mt-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-1">タイプ</label>
|
||||
<select
|
||||
value={schedule.scheduleType}
|
||||
onChange={e => onChange(p => ({ ...p, scheduleType: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded-lg text-xs"
|
||||
>
|
||||
<option value="daily">毎日</option>
|
||||
<option value="weekly">毎週</option>
|
||||
<option value="monthly">毎月</option>
|
||||
<option value="cron">Cron式</option>
|
||||
<option value="once">一回のみ</option>
|
||||
</select>
|
||||
</div>
|
||||
{schedule.scheduleType !== 'cron' && schedule.scheduleType !== 'once' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-1">時刻</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={schedule.hour}
|
||||
onChange={e => onChange(p => ({ ...p, hour: Number(e.target.value) }))}
|
||||
className="w-14 px-1 py-1.5 border border-slate-200 rounded-lg text-xs text-center"
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={schedule.minute}
|
||||
onChange={e => onChange(p => ({ ...p, minute: Number(e.target.value) }))}
|
||||
className="w-14 px-1 py-1.5 border border-slate-200 rounded-lg text-xs text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{schedule.scheduleType === 'weekly' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-1">曜日</label>
|
||||
<select
|
||||
value={schedule.dayOfWeek}
|
||||
onChange={e => onChange(p => ({ ...p, dayOfWeek: Number(e.target.value) }))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded-lg text-xs"
|
||||
>
|
||||
{['日曜', '月曜', '火曜', '水曜', '木曜', '金曜', '土曜'].map((d, i) => (
|
||||
<option key={i} value={i}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{schedule.scheduleType === 'monthly' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-1">日</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={schedule.dayOfMonth}
|
||||
onChange={e => onChange(p => ({ ...p, dayOfMonth: Number(e.target.value) }))}
|
||||
className="w-20 px-2 py-1.5 border border-slate-200 rounded-lg text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{schedule.scheduleType === 'cron' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-1">Cron式</label>
|
||||
<input
|
||||
value={schedule.cronExpression}
|
||||
onChange={e => onChange(p => ({ ...p, cronExpression: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded-lg text-xs font-mono"
|
||||
placeholder="0 9 * * MON"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{schedule.scheduleType === 'once' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-600 mb-1">実行日時</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={schedule.scheduledAt}
|
||||
onChange={e => onChange(p => ({ ...p, scheduledAt: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 border border-slate-200 rounded-lg text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardWidgetKind } from '../../api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
existingSlugs: string[];
|
||||
onClose: () => void;
|
||||
onCreate: (input: { slug: string; title: string; kind: DashboardWidgetKind }) => Promise<void>;
|
||||
}
|
||||
|
||||
// Default titles per kind so the user can pick a kind and get a sensible
|
||||
// title for free. Either field can be overridden before submit.
|
||||
const KIND_TITLES: Record<DashboardWidgetKind, string> = {
|
||||
'markdown': '',
|
||||
'node-status': 'ノード状況',
|
||||
};
|
||||
|
||||
function slugify(title: string, existing: string[]): string {
|
||||
const base = title
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 32) || 'widget';
|
||||
if (!existing.includes(base)) return base;
|
||||
for (let i = 2; i < 100; i++) {
|
||||
const candidate = `${base}-${i}`.slice(0, 32);
|
||||
if (!existing.includes(candidate)) return candidate;
|
||||
}
|
||||
return `${base}-${Date.now()}`.slice(0, 32);
|
||||
}
|
||||
|
||||
export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Props) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [kind, setKind] = useState<DashboardWidgetKind>('markdown');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Effective title: explicit input wins, otherwise the kind's default
|
||||
// so the user can submit "node-status" without typing anything.
|
||||
const effectiveTitle = title.trim() || KIND_TITLES[kind];
|
||||
const canSubmit = !saving && effectiveTitle.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={() => !saving && onClose()}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-sm font-semibold">新しいウィジェット</div>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-600">
|
||||
種類
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as DashboardWidgetKind)}
|
||||
disabled={saving}
|
||||
className="border border-hairline rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="markdown">Markdown メモ</option>
|
||||
<option value="node-status">ノード状況 (NodeStatus)</option>
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={
|
||||
kind === 'node-status'
|
||||
? `タイトル(例: ${KIND_TITLES['node-status']})`
|
||||
: 'タイトル(例: メモ、ニュース)'
|
||||
}
|
||||
maxLength={64}
|
||||
className="border border-hairline rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 text-xs border border-hairline rounded hover:bg-surface-2"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const slug = slugify(effectiveTitle, existingSlugs);
|
||||
await onCreate({ slug, title: effectiveTitle, kind });
|
||||
setTitle('');
|
||||
setKind('markdown');
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
作成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import type { DashboardWidget } from '../../api';
|
||||
|
||||
interface Props {
|
||||
widget: DashboardWidget;
|
||||
onSave: (patch: { title?: string; content?: string }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function MarkdownWidget({ widget, onSave, onDelete }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftContent, setDraftContent] = useState(widget.markdownContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="relative h-full overflow-auto p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDraftContent(widget.markdownContent); setEditing(true); }}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-[11px] bg-white border border-hairline rounded hover:bg-surface-2"
|
||||
aria-label="編集"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{widget.markdownContent
|
||||
? <MarkdownText text={widget.markdownContent} />
|
||||
: <div className="text-xs text-slate-400 italic">(空の widget。✏️ で編集)</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-2 gap-2">
|
||||
<textarea
|
||||
className="flex-1 w-full border border-hairline rounded p-2 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
value={draftContent}
|
||||
onChange={(e) => setDraftContent(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ content: draftContent });
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-accent text-accent-fg text-xs rounded hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="px-3 py-1 bg-white border border-hairline text-xs rounded hover:bg-surface-2"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`"${widget.title}" を削除しますか?`)) return;
|
||||
await onDelete();
|
||||
}}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
|
||||
aria-label="削除"
|
||||
>
|
||||
🗑 削除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useNodeStatus, type NodeStatus } from '../../hooks/useNodeStatus';
|
||||
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { PetSprite } from '../pets/PetSprite';
|
||||
|
||||
/**
|
||||
* Side Info Panel widget that surfaces the BackendStatusRegistry feed:
|
||||
* for every direct worker and every proxy backend, one row with Pet
|
||||
* sprite, status icon, slots, model, throughput.
|
||||
*
|
||||
* Polling cadence matches the server-side registry tick (5s) so the
|
||||
* client cache stays roughly aligned with the cache the server is
|
||||
* already maintaining — see hooks/useNodeStatus for the rationale.
|
||||
*/
|
||||
export function NodeStatusWidget() {
|
||||
const { nodes, isLoading, isError, isUnavailable } = useNodeStatus();
|
||||
|
||||
if (isLoading) return <div className="text-xs text-slate-500 p-3">読み込み中...</div>;
|
||||
if (isUnavailable) {
|
||||
return (
|
||||
<div className="text-xs text-slate-500 p-3">
|
||||
node-status registry が未構成です。<br />
|
||||
config.yaml の provider.workers を確認してください。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) return <div className="text-xs text-red-600 p-3">取得に失敗しました</div>;
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-slate-500 p-3">
|
||||
ノードが登録されていません。<br />
|
||||
config.yaml の provider.workers を確認してください。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-2 overflow-auto h-full">
|
||||
{nodes.map((n) => (
|
||||
<NodeRow key={`${n.workerId}|${n.nodeId}`} node={n} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusEmoji(node: NodeStatus): { icon: string; label: string; color: string } {
|
||||
if (!node.online) return { icon: '⚫', label: 'offline', color: 'text-slate-500' };
|
||||
if (node.totalSlots > 0 && node.busySlots >= node.totalSlots) {
|
||||
return { icon: '🔴', label: 'full', color: 'text-rose-600' };
|
||||
}
|
||||
if (node.busySlots > 0) return { icon: '🟡', label: 'busy', color: 'text-amber-600' };
|
||||
return { icon: '🟢', label: 'idle', color: 'text-emerald-600' };
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
function NodeRow({ node }: { node: NodeStatus }) {
|
||||
// The Pet selection logic in useActivePet already prefers
|
||||
// workerPets[backendId] over workerPets[workerId]; passing nodeId as
|
||||
// the "backend" argument hands the resolver the most specific key.
|
||||
const { data: pet } = useActivePet(node.workerId, node.nodeId);
|
||||
const framesPerRow = usePetFrameAnalysis(
|
||||
pet?.spriteUrl ?? null,
|
||||
pet?.gridCols ?? null,
|
||||
pet?.gridRows ?? null,
|
||||
);
|
||||
const systemReducedMotion = usePrefersReducedMotion();
|
||||
const petReducedMotion = (pet?.settings as { reducedMotion?: boolean } | undefined)?.reducedMotion ?? false;
|
||||
const reducedMotion = petReducedMotion || systemReducedMotion;
|
||||
|
||||
const { icon, label, color } = statusEmoji(node);
|
||||
// Phase C: derive the animation state through useNodeAnimationState so
|
||||
// both this widget and the ChatPetOverlay see the same idle/running
|
||||
// decision against the registry feed. The hook reads the shared
|
||||
// useNodeStatus query (React Query dedups), so N rows here don't
|
||||
// multiply polling traffic.
|
||||
const petState = useNodeAnimationState(node.nodeId);
|
||||
const showPet = pet?.pet && pet.imageUrl;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-surface-2">
|
||||
<div className="w-8 h-8 flex-shrink-0 flex items-center justify-center">
|
||||
{showPet ? (
|
||||
<PetSprite
|
||||
name={pet.pet!.name}
|
||||
imageUrl={pet.imageUrl}
|
||||
frameWidth={pet.frameWidth}
|
||||
frameHeight={pet.frameHeight}
|
||||
gridCols={pet.gridCols}
|
||||
gridRows={pet.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state={petState}
|
||||
size={32}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-base ${color}`} aria-label={label}>{icon}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-slate-800 truncate flex items-center gap-1.5">
|
||||
{node.nodeId}
|
||||
{node.source === 'proxy' && (
|
||||
<span className="text-[9px] uppercase tracking-wide text-slate-400">via {node.workerId}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono truncate">
|
||||
{node.loadedModel ?? '-'}
|
||||
{node.lastProbeError && (
|
||||
<span className="text-rose-500 ml-1" title={node.lastProbeError}>(probe error)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end text-[11px] font-mono leading-tight">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={color}>{icon}</span>
|
||||
{node.totalSlots > 0
|
||||
? <span className="text-slate-600">{node.busySlots}/{node.totalSlots}</span>
|
||||
: <span className="text-slate-400">-</span>}
|
||||
</div>
|
||||
<span className="text-slate-400">
|
||||
{node.throughputTps != null ? `${node.throughputTps.toFixed(0)} tok/s` : ' '}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useDashboardWidgets } from '../../hooks/useDashboardWidgets';
|
||||
import { WidgetTabBar, WORKER_TAB_SLUG } from './WidgetTabBar';
|
||||
import { WorkerStatusWidget } from './WorkerStatusWidget';
|
||||
import { MarkdownWidget } from './MarkdownWidget';
|
||||
import { NodeStatusWidget } from './NodeStatusWidget';
|
||||
import { AddWidgetDialog } from './AddWidgetDialog';
|
||||
|
||||
interface Props {
|
||||
/** Controlled-active widget slug. Defaults to worker tab. */
|
||||
activeSlug?: string;
|
||||
onActiveSlugChange?: (slug: string) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function SideInfoPanel({
|
||||
activeSlug: activeSlugProp,
|
||||
onActiveSlugChange,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { widgets, create, update, remove } = useDashboardWidgets();
|
||||
const [localActive, setLocalActive] = useState<string>(WORKER_TAB_SLUG);
|
||||
const activeSlug = activeSlugProp ?? localActive;
|
||||
const setActive = onActiveSlugChange ?? setLocalActive;
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const activeWidget = widgets.find(w => w.slug === activeSlug);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-white">
|
||||
<WidgetTabBar
|
||||
widgets={widgets}
|
||||
activeSlug={activeSlug}
|
||||
onSelect={setActive}
|
||||
onAdd={() => setDialogOpen(true)}
|
||||
onDeleteWidget={async (w) => {
|
||||
if (!window.confirm(`"${w.title}" を削除しますか?`)) return;
|
||||
await remove.mutateAsync(w.id);
|
||||
if (activeSlug === w.slug) setActive(WORKER_TAB_SLUG);
|
||||
}}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{activeSlug === WORKER_TAB_SLUG && <WorkerStatusWidget />}
|
||||
{activeSlug !== WORKER_TAB_SLUG && activeWidget && activeWidget.kind === 'node-status' && (
|
||||
<NodeStatusWidget key={activeWidget.id} />
|
||||
)}
|
||||
{activeSlug !== WORKER_TAB_SLUG && activeWidget && activeWidget.kind !== 'node-status' && (
|
||||
<MarkdownWidget
|
||||
key={activeWidget.id}
|
||||
widget={activeWidget}
|
||||
onSave={async (patch) => {
|
||||
await update.mutateAsync({ id: activeWidget.id, patch });
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await remove.mutateAsync(activeWidget.id);
|
||||
setActive(WORKER_TAB_SLUG);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeSlug !== WORKER_TAB_SLUG && !activeWidget && (
|
||||
<div className="p-3 text-xs text-slate-500">ウィジェットが見つかりません</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AddWidgetDialog
|
||||
open={dialogOpen}
|
||||
existingSlugs={widgets.map(w => w.slug)}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onCreate={async (input) => {
|
||||
await create.mutateAsync(input);
|
||||
setActive(input.slug);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useSidePanelLayout } from '../../hooks/useSidePanelLayout';
|
||||
import { VerticalResizeHandle } from '../layout/VerticalResizeHandle';
|
||||
import { SideInfoPanel } from './SideInfoPanel';
|
||||
|
||||
interface Props {
|
||||
/** TaskListPanel または RailPanel を含む上半分。 */
|
||||
upper: React.ReactNode;
|
||||
activeWidgetSlug?: string;
|
||||
onActiveWidgetSlugChange?: (slug: string) => void;
|
||||
/** rail/mobile 等の狭い viewport で default を collapsed にしたい場合に指定。 */
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
let _idSeq = 0;
|
||||
|
||||
export function TaskListWithSidePanel({
|
||||
upper,
|
||||
activeWidgetSlug,
|
||||
onActiveWidgetSlugChange,
|
||||
defaultCollapsed,
|
||||
}: Props) {
|
||||
const { listHeightPct, setListHeightPct, collapsed, toggleCollapsed, resetHeight } = useSidePanelLayout();
|
||||
const initOverrideRef = useRef<boolean>(false);
|
||||
if (defaultCollapsed && !initOverrideRef.current && localStorage.getItem('dashboard.collapsed') === null) {
|
||||
initOverrideRef.current = true;
|
||||
toggleCollapsed();
|
||||
}
|
||||
const [parentId] = useState(() => `tlspl-${++_idSeq}`);
|
||||
|
||||
const upperFlex = collapsed ? '1 1 auto' : `0 0 ${listHeightPct}%`;
|
||||
const lowerFlex = collapsed ? '0 0 auto' : `0 0 ${100 - listHeightPct}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-side-panel-parent={parentId}
|
||||
className="flex flex-col h-full min-h-0 overflow-hidden"
|
||||
>
|
||||
<div style={{ flex: upperFlex, minHeight: 0 }} className="overflow-hidden">
|
||||
{upper}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<VerticalResizeHandle
|
||||
parentSelector={`[data-side-panel-parent="${parentId}"]`}
|
||||
onResize={setListHeightPct}
|
||||
onResizeEnd={setListHeightPct}
|
||||
onReset={resetHeight}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: lowerFlex, minHeight: collapsed ? 'auto' : 0 }} className="overflow-hidden border-t border-hairline">
|
||||
<SideInfoPanel
|
||||
activeSlug={activeWidgetSlug}
|
||||
onActiveSlugChange={onActiveWidgetSlugChange}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { DashboardWidget } from '../../api';
|
||||
|
||||
export const WORKER_TAB_SLUG = 'worker-status';
|
||||
|
||||
interface Props {
|
||||
widgets: DashboardWidget[];
|
||||
activeSlug: string;
|
||||
onSelect: (slug: string) => void;
|
||||
onAdd: () => void;
|
||||
/** Called with the widget slug when the user clicks the × on a tab. */
|
||||
onDeleteWidget?: (widget: DashboardWidget) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function WidgetTabBar({
|
||||
widgets,
|
||||
activeSlug,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onDeleteWidget,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 py-1 border-b border-hairline overflow-x-auto">
|
||||
<TabButton
|
||||
active={activeSlug === WORKER_TAB_SLUG}
|
||||
onClick={() => onSelect(WORKER_TAB_SLUG)}
|
||||
label="👷 Worker"
|
||||
/>
|
||||
{widgets.map((w) => (
|
||||
<TabButton
|
||||
key={w.slug}
|
||||
active={activeSlug === w.slug}
|
||||
onClick={() => onSelect(w.slug)}
|
||||
label={w.kind === 'node-status' ? `🖥️ ${w.title}` : w.title}
|
||||
onDelete={onDeleteWidget ? () => onDeleteWidget(w) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
title="ウィジェットを追加"
|
||||
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-800 hover:bg-surface-2 rounded"
|
||||
aria-label="ウィジェットを追加"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-800 hover:bg-surface-2 rounded"
|
||||
aria-label={collapsed ? '展開' : '折りたたみ'}
|
||||
>
|
||||
{collapsed ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active, onClick, label, onDelete,
|
||||
}: { active: boolean; onClick: () => void; label: string; onDelete?: () => void }) {
|
||||
// group/tab を親に付け、× は group-hover で表示。タブ自体の active 状態でも常時表示する
|
||||
// ことで、編集中のタブを誤って閉じる怖さは confirm dialog 側で吸収する。
|
||||
return (
|
||||
<div className={`relative group/tab inline-flex items-center rounded ${
|
||||
active ? 'bg-accent text-accent-fg' : 'hover:bg-surface-2'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`pl-2 ${onDelete ? 'pr-1' : 'pr-2'} py-1 text-xs whitespace-nowrap ${
|
||||
active ? 'font-semibold' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="このウィジェットを削除"
|
||||
title="削除"
|
||||
className={`mr-1 w-4 h-4 inline-flex items-center justify-center rounded text-[11px] leading-none transition-opacity ${
|
||||
active
|
||||
? 'opacity-70 hover:opacity-100 hover:bg-white/20'
|
||||
: 'opacity-0 group-hover/tab:opacity-100 hover:bg-slate-300/60'
|
||||
}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkerStatus } from '../../hooks/useWorkerStatus';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { PetSprite } from '../pets/PetSprite';
|
||||
import type { WorkerStatusBackendRow } from '../../api';
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
export function WorkerStatusWidget() {
|
||||
const { workers, isLoading, isError } = useWorkerStatus();
|
||||
|
||||
if (isLoading) return <div className="text-xs text-slate-500 p-3">読み込み中...</div>;
|
||||
if (isError) return <div className="text-xs text-red-600 p-3">取得に失敗しました</div>;
|
||||
if (workers.length === 0) {
|
||||
return <div className="text-xs text-slate-500 p-3">Worker が設定されていません</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-2 overflow-auto h-full">
|
||||
{workers.map((w) => (
|
||||
<WorkerRow
|
||||
key={w.id}
|
||||
workerId={w.id}
|
||||
name={w.name}
|
||||
roles={w.roles}
|
||||
state={w.state}
|
||||
proxy={w.proxy}
|
||||
backends={w.backends}
|
||||
busySlots={w.busySlots}
|
||||
totalSlots={w.totalSlots}
|
||||
online={w.online}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerRow({
|
||||
workerId, name, roles, state, proxy, backends, busySlots, totalSlots, online,
|
||||
}: {
|
||||
workerId: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
state: 'idle' | 'running';
|
||||
proxy: boolean;
|
||||
backends: WorkerStatusBackendRow[] | undefined;
|
||||
/** Direct workers carry slot pressure at the row level; proxy workers leave these undefined and surface per-backend pressure in `backends[]`. */
|
||||
busySlots?: number;
|
||||
totalSlots?: number;
|
||||
online?: boolean;
|
||||
}) {
|
||||
// Default expanded so the operator sees backend granularity on first
|
||||
// load. Collapse is a local-only convenience for noisy pools.
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const { data: pet } = useActivePet(workerId);
|
||||
const framesPerRow = usePetFrameAnalysis(
|
||||
pet?.spriteUrl ?? null,
|
||||
pet?.gridCols ?? null,
|
||||
pet?.gridRows ?? null,
|
||||
);
|
||||
const systemReducedMotion = usePrefersReducedMotion();
|
||||
const petReducedMotion = (pet?.settings as { reducedMotion?: boolean } | undefined)?.reducedMotion ?? false;
|
||||
const reducedMotion = petReducedMotion || systemReducedMotion;
|
||||
|
||||
const isOffline = online === false;
|
||||
const dotColor = isOffline
|
||||
? 'bg-red-400'
|
||||
: state === 'running' ? 'bg-emerald-500' : 'bg-slate-300';
|
||||
const showPet = pet?.pet && pet.imageUrl;
|
||||
const hasBackends = proxy && Array.isArray(backends) && backends.length > 0;
|
||||
const proxyAriaLabel = hasBackends ? (collapsed ? '展開する' : '折りたたむ') : undefined;
|
||||
// Slot caption: only render when the registry has produced a usable
|
||||
// totalSlots figure. Unset (= no probe row) and 0 (= probe row but
|
||||
// /slots was empty) both suppress the caption so we don't paint a
|
||||
// confusing `(busy/0)`.
|
||||
const slotsLabel = typeof totalSlots === 'number' && totalSlots > 0
|
||||
? `${busySlots ?? 0}/${totalSlots}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-surface-2">
|
||||
<div className="w-8 h-8 flex-shrink-0 flex items-center justify-center">
|
||||
{showPet ? (
|
||||
<PetSprite
|
||||
name={pet.pet!.name}
|
||||
imageUrl={pet.imageUrl}
|
||||
frameWidth={pet.frameWidth}
|
||||
frameHeight={pet.frameHeight}
|
||||
gridCols={pet.gridCols}
|
||||
gridRows={pet.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state={state}
|
||||
size={32}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
) : (
|
||||
<span className={`w-3 h-3 rounded-full ${dotColor}`} aria-label={state} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-slate-800 truncate flex items-center gap-1">
|
||||
{hasBackends && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
aria-label={proxyAriaLabel}
|
||||
aria-expanded={!collapsed}
|
||||
className="w-3 text-slate-400 leading-none"
|
||||
>
|
||||
{collapsed ? '▶' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
{proxy && (
|
||||
<span className="px-1 py-0.5 rounded text-[9px] font-medium bg-violet-50 text-violet-700 leading-none">
|
||||
proxy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{roles.length > 0 && (
|
||||
<div className="text-[10px] text-slate-500 font-mono truncate">{roles.join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] font-mono">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor}`} />
|
||||
{isOffline ? (
|
||||
<span className="text-red-600">offline</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-slate-600">{state}</span>
|
||||
{slotsLabel && (
|
||||
<span className="text-slate-400">({slotsLabel})</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasBackends && !collapsed && (
|
||||
<div className="ml-4 border-l border-slate-100 pl-2 flex flex-col gap-0.5 mb-1">
|
||||
{backends!.map((b) => (
|
||||
<BackendRow key={b.id} workerId={workerId} backend={b} reducedMotion={reducedMotion} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendRow({
|
||||
workerId, backend, reducedMotion,
|
||||
}: {
|
||||
workerId: string;
|
||||
backend: WorkerStatusBackendRow;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
// useActivePet(workerId, backendId) follows the per-Phase A priority:
|
||||
// workerPets[backend.id] → workerPets[workerId] → global default.
|
||||
// This gives the operator a per-backend pet override while still
|
||||
// falling back to the proxy-level pet when no override exists.
|
||||
const { data: pet } = useActivePet(workerId, backend.id);
|
||||
const framesPerRow = usePetFrameAnalysis(
|
||||
pet?.spriteUrl ?? null,
|
||||
pet?.gridCols ?? null,
|
||||
pet?.gridRows ?? null,
|
||||
);
|
||||
const dotColor = backend.online === false
|
||||
? 'bg-red-400'
|
||||
: backend.state === 'running'
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-slate-300';
|
||||
const showPet = pet?.pet && pet.imageUrl;
|
||||
// Slot caption: only render when the registry has a usable totalSlots
|
||||
// figure. Zero (= unprobed) renders as bare `(busy/0)` which is
|
||||
// confusing, so we suppress it until the first probe lands.
|
||||
const slotsLabel = backend.totalSlots > 0
|
||||
? `${backend.busySlots}/${backend.totalSlots}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-surface-2">
|
||||
<div className="w-6 h-6 flex-shrink-0 flex items-center justify-center">
|
||||
{showPet ? (
|
||||
<PetSprite
|
||||
name={pet.pet!.name}
|
||||
imageUrl={pet.imageUrl}
|
||||
frameWidth={pet.frameWidth}
|
||||
frameHeight={pet.frameHeight}
|
||||
gridCols={pet.gridCols}
|
||||
gridRows={pet.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state={backend.state}
|
||||
size={24}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
) : (
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor}`} aria-label={backend.state} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-slate-700 font-mono truncate">{backend.id}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] font-mono">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
|
||||
{backend.online === false ? (
|
||||
<span className="text-red-600">offline</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-slate-600">{backend.state}</span>
|
||||
{slotsLabel && (
|
||||
<span className="text-slate-400">({slotsLabel})</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
interface ContextUsageGaugeProps {
|
||||
promptTokens?: number | null;
|
||||
limitTokens?: number | null;
|
||||
jobStatus?: string;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function pickColorClass(ratio: number): string {
|
||||
if (ratio >= 0.95) return 'bg-red-500';
|
||||
if (ratio >= 0.85) return 'bg-orange-500';
|
||||
if (ratio >= 0.70) return 'bg-amber-500';
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
|
||||
function pickLabel(jobStatus: string | undefined): string {
|
||||
switch (jobStatus) {
|
||||
case 'succeeded':
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'Context usage at finish';
|
||||
case 'waiting_human':
|
||||
case 'waiting_subtasks':
|
||||
return 'Context usage (paused)';
|
||||
default:
|
||||
return 'Context usage';
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus }: ContextUsageGaugeProps) {
|
||||
if (!limitTokens || limitTokens <= 0) return null;
|
||||
|
||||
const tokens = typeof promptTokens === 'number' ? promptTokens : 0;
|
||||
const awaiting = typeof promptTokens !== 'number';
|
||||
const ratio = Math.min(1, Math.max(0, tokens / limitTokens));
|
||||
const percent = Math.round(ratio * 100);
|
||||
const colorClass = pickColorClass(ratio);
|
||||
const label = pickLabel(jobStatus);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-slate-700">{label}</span>
|
||||
<span className="text-xs text-slate-500 tabular-nums">
|
||||
{awaiting ? 'Awaiting first LLM call' : `${percent}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${colorClass} transition-[width] duration-300 ease-out`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-2xs text-slate-500 tabular-nums">
|
||||
{formatNumber(tokens)} / {formatNumber(limitTokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
|
||||
interface PrevJobInfo {
|
||||
id: string;
|
||||
pieceName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ContinueWithPieceDialogProps {
|
||||
taskId: number;
|
||||
prevJob: PrevJobInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContinueWithPieceDialog({
|
||||
taskId,
|
||||
prevJob,
|
||||
onClose,
|
||||
}: ContinueWithPieceDialogProps) {
|
||||
const [piece, setPiece] = useState<string>(prevJob.pieceName);
|
||||
const [instruction, setInstruction] = useState<string>('');
|
||||
const [resultExpanded, setResultExpanded] = useState<boolean>(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const piecesQuery = usePieceList();
|
||||
|
||||
// Lazy-fetch comments to derive the previous job's terminal output. Re-uses
|
||||
// the same queryKey as useLocalTaskDetail so we hit the in-memory cache when
|
||||
// the parent panel has the data warm.
|
||||
const commentsQuery = useQuery({
|
||||
queryKey: ['localTaskComments', taskId],
|
||||
queryFn: () => fetchLocalTaskComments(taskId),
|
||||
});
|
||||
const prevResult = (() => {
|
||||
const comments = commentsQuery.data ?? [];
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const c = comments[i];
|
||||
if (c.author === 'agent' && (c.kind === 'result' || c.kind === 'ask')) {
|
||||
return { body: c.body, kind: c.kind };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const continueMutation = useMutation({
|
||||
mutationFn: () => continueTaskWithPiece(taskId, { piece, instruction: instruction.trim() }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const submitDisabled =
|
||||
continueMutation.isPending || !piece || instruction.trim().length === 0;
|
||||
const submitError = continueMutation.isError
|
||||
? ((continueMutation.error as Error)?.message ?? 'Failed to continue')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-800">
|
||||
Task #{taskId} を別 piece で続ける
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5">
|
||||
workspace は共有されます (output/ のファイルは次の piece からも見えます)
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-5 py-4 overflow-y-auto flex-1">
|
||||
{prevResult && (
|
||||
<div className="border border-hairline rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResultExpanded(v => !v)}
|
||||
className="w-full flex justify-between items-center px-3 py-2 text-2xs font-semibold text-slate-500 uppercase tracking-wide hover:bg-surface-2 transition-colors"
|
||||
aria-expanded={resultExpanded}
|
||||
>
|
||||
<span>
|
||||
直前 piece "{prevJob.pieceName}" の{prevResult.kind === 'ask' ? '質問' : '結果'}
|
||||
</span>
|
||||
<span className="text-slate-400 normal-case font-normal">{resultExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{resultExpanded && (
|
||||
<div className="px-3 py-2 border-t border-hairline max-h-48 overflow-y-auto text-[13px]">
|
||||
<MarkdownText text={prevResult.body} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="continue-piece" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Piece <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="continue-piece"
|
||||
value={piece}
|
||||
onChange={e => setPiece(e.target.value)}
|
||||
disabled={piecesQuery.isLoading}
|
||||
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"
|
||||
>
|
||||
{(piecesQuery.data ?? []).map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.name === prevJob.pieceName ? ' (現在)' : ''}
|
||||
{p.custom ? ' [user]' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="continue-instruction" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
新しい指示 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="continue-instruction"
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
autoFocus
|
||||
rows={5}
|
||||
placeholder="例: output/manual.md を使ってサーバー foo.example.com をセットアップして"
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] resize-y focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1.5">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md text-slate-600 hover:text-slate-900 hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => continueMutation.mutate()}
|
||||
disabled={submitDisabled}
|
||||
className="px-3 py-1.5 text-xs font-semibold rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{continueMutation.isPending ? '起動中...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DetailTabId } from '../../lib/urlState';
|
||||
import { shareTask, unshareTask } from '../../api';
|
||||
|
||||
interface Tab { id: DetailTabId; label: string; }
|
||||
|
||||
interface DetailHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tabs: Tab[];
|
||||
activeTab: DetailTabId;
|
||||
/** True while the deferred content tab is still catching up to activeTab.
|
||||
* Used to render a subtle pulse on the active tab so the user knows the
|
||||
* click was registered even if the content takes a frame or two to paint. */
|
||||
tabTransitionPending?: boolean;
|
||||
onTabChange: (tab: DetailTabId) => void;
|
||||
onClose: () => void;
|
||||
detailWidth?: 'normal' | 'focused';
|
||||
onWidthToggle?: () => void;
|
||||
// 共有機能
|
||||
taskId?: number;
|
||||
shareToken?: string | null;
|
||||
onShareChange?: () => void;
|
||||
/** Status of the latest job for this task. The Continue button is shown
|
||||
* when latestJobStatus is provided and enabled only on terminal states. */
|
||||
latestJobStatus?: string | null;
|
||||
/** Click handler for the Continue button. When undefined, the button is
|
||||
* hidden entirely (e.g., shared/read-only views). */
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; shareToken: string | null; onShareChange?: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: () => shareTask(taskId),
|
||||
onSuccess: (data) => {
|
||||
const url = `${window.location.origin}${data.shareUrl}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
onShareChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
const unshareMutation = useMutation({
|
||||
mutationFn: () => unshareTask(taskId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
onShareChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Refero refresh: collapse share UI from text-button rows into compact
|
||||
// icon-only buttons. The title row was getting eaten by long Japanese
|
||||
// labels ("リンクコピー" / "共有停止") on narrow viewports; with icons
|
||||
// we get the same affordance in ~32px instead of ~80px each.
|
||||
const iconBtnBase =
|
||||
'inline-flex items-center justify-center w-7 h-7 border rounded-md transition-colors disabled:opacity-50';
|
||||
|
||||
if (!shareToken) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => shareMutation.mutate()}
|
||||
disabled={shareMutation.isPending}
|
||||
title={shareMutation.isPending ? '共有中...' : '公開リンクを発行'}
|
||||
aria-label="公開リンクを発行"
|
||||
className={`${iconBtnBase} border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface`}
|
||||
>
|
||||
{shareMutation.isPending ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="4" cy="8" r="2" />
|
||||
<circle cx="12" cy="4" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M5.7 7l4.6-2M5.7 9l4.6 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
const url = `${window.location.origin}/ui/shared/${shareToken}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title={copied ? 'コピーしました' : '共有リンクをコピー'}
|
||||
aria-label="共有リンクをコピー"
|
||||
className={`${iconBtnBase} ${copied ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface'}`}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 8.5l3 3 7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => unshareMutation.mutate()}
|
||||
disabled={unshareMutation.isPending}
|
||||
title="共有を停止"
|
||||
aria-label="共有を停止"
|
||||
className={`${iconBtnBase} border-hairline bg-white text-slate-500 hover:text-red-700 hover:border-red-200 hover:bg-red-50`}
|
||||
>
|
||||
{unshareMutation.isPending ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 5l6 6M11 5l-6 6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string | null; onClick: () => void }) {
|
||||
// Mirror the spec/backend TERMINAL list (worker maps abort outcomes to
|
||||
// 'failed', so 'aborted' is intentionally absent).
|
||||
const TERMINAL = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
|
||||
const enabled = latestJobStatus != null && TERMINAL.includes(latestJobStatus);
|
||||
const iconBtnBase =
|
||||
'inline-flex items-center justify-center w-7 h-7 border rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title={enabled ? '別 piece で続ける' : 'タスクが進行中のため続行できません'}
|
||||
aria-label="別 piece で続ける"
|
||||
className={`${iconBtnBase} border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface`}
|
||||
>
|
||||
{/* arrow → divider: 「次のフェーズへ進む」cue。FileBrowser の refresh
|
||||
(循環矢印) と区別するためフラットな skip-forward 形状を採用 */}
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 8h9" />
|
||||
<path d="M8 5l3 3-3 3" />
|
||||
<path d="M13.5 4v8" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue }: DetailHeaderProps) {
|
||||
// Mobile (< sm) hides the close button and tab bar because App.tsx
|
||||
// renders its own mobile-level top tab bar with the same controls.
|
||||
// Two close buttons / two tab bars on iPhone was visually redundant.
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-hairline bg-white px-4 pt-3 pb-3 sm:pb-0" id="detail-panel-title">
|
||||
<div className="flex items-start justify-between gap-2 mb-0 sm:mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-400">{subtitle}</div>
|
||||
<div className="font-semibold text-lg text-slate-900 mt-0.5 break-words leading-tight">{title}</div>
|
||||
</div>
|
||||
{/* Inline action cluster: width toggle + share + close. Share is
|
||||
now icon-only (32px) so it fits next to the title instead of
|
||||
occupying its own row. */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onContinue && taskId != null && (
|
||||
<ContinueButton
|
||||
latestJobStatus={latestJobStatus ?? null}
|
||||
onClick={onContinue}
|
||||
/>
|
||||
)}
|
||||
{taskId != null && (
|
||||
<ShareButton
|
||||
taskId={taskId}
|
||||
shareToken={shareToken ?? null}
|
||||
onShareChange={onShareChange}
|
||||
/>
|
||||
)}
|
||||
{onWidthToggle && detailWidth && (
|
||||
<button
|
||||
onClick={onWidthToggle}
|
||||
title={detailWidth === 'focused' ? '標準表示に戻る' : '集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)'}
|
||||
aria-label={detailWidth === 'focused' ? '標準表示に戻る' : '集中モードに切替'}
|
||||
aria-pressed={detailWidth === 'focused'}
|
||||
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-500 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
{detailWidth === 'focused' ? (
|
||||
// exit-fullscreen 様: 4 つの内向き角矢印
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4" />
|
||||
</svg>
|
||||
) : (
|
||||
// enter-fullscreen 様: 4 つの外向き角矢印
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="詳細パネルを閉じる"
|
||||
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tablist" aria-label="詳細タブ" className="hidden sm:flex gap-4 -mb-px">
|
||||
{tabs.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
const pending = active && tabTransitionPending;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
||||
active
|
||||
? 'border-accent text-slate-900 font-semibold'
|
||||
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{pending && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-2.5 h-2.5 border-2 border-accent border-t-transparent rounded-full animate-spin"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useDeferredValue } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { LocalTask, LocalFileEntry, SubtaskActivity, Visibility, fetchMyOrgs, updateLocalTask } from '../../api';
|
||||
import { relativeTime } from '../../lib/utils';
|
||||
import { DetailTabId } from '../../lib/urlState';
|
||||
import { DetailHeader } from './DetailHeader';
|
||||
import { ContinueWithPieceDialog } from './ContinueWithPieceDialog';
|
||||
import { SkeletonDetailPanel } from '../shared/Skeleton';
|
||||
import { OverviewTab } from './tabs/OverviewTab';
|
||||
import { ProgressTab } from './tabs/ProgressTab';
|
||||
import { FilesTab } from './tabs/FilesTab';
|
||||
import { TraceTab } from './tabs/TraceTab';
|
||||
import { BrowserTab } from './tabs/BrowserTab';
|
||||
import { ConsoleTab } from './tabs/ConsoleTab';
|
||||
import { BrowserSessionPanel } from '../browser/BrowserSessionPanel';
|
||||
import type { ConsoleStatus } from '../../lib/ssh-console-types';
|
||||
import { useAuthState } from '../../App';
|
||||
import type { SubtaskFilePreviewHandler } from './tabs/SubtasksPanel';
|
||||
|
||||
interface LocalDetailPanelProps {
|
||||
task: LocalTask | null;
|
||||
taskId: number;
|
||||
section: 'workspace' | 'input' | 'output' | 'logs';
|
||||
currentPath: string;
|
||||
entries: LocalFileEntry[];
|
||||
pathSegments: string[];
|
||||
loading: boolean;
|
||||
detailTab: DetailTabId;
|
||||
detailWidth: 'normal' | 'focused';
|
||||
showWidthToggle: boolean;
|
||||
onTabChange: (tab: DetailTabId) => void;
|
||||
onWidthToggle: () => void;
|
||||
onClose: () => void;
|
||||
onDelete?: () => Promise<void>;
|
||||
onSectionChange: (section: 'workspace' | 'input' | 'output' | 'logs') => void;
|
||||
onNavigate: (path: string) => void;
|
||||
onPreview: (path: string, name: string) => void;
|
||||
onViewFullLog: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
subtaskActivities?: SubtaskActivity[];
|
||||
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
|
||||
shareToken?: string | null;
|
||||
onShareChange?: () => void;
|
||||
}
|
||||
|
||||
const LOCAL_TABS: Array<{ id: DetailTabId; label: string }> = [
|
||||
{ id: 'overview', label: '概要' },
|
||||
{ id: 'activity', label: '進捗' },
|
||||
{ id: 'files', label: 'ファイル' },
|
||||
{ id: 'trace', label: 'トレース' },
|
||||
{ id: 'browser', label: 'ブラウザ' },
|
||||
{ id: 'ssh', label: 'SSH' },
|
||||
];
|
||||
|
||||
export function LocalDetailPanel({
|
||||
task, taskId, section, currentPath, entries, pathSegments,
|
||||
loading, detailTab, detailWidth, showWidthToggle,
|
||||
onTabChange, onWidthToggle, onClose, onDelete, onSectionChange, onNavigate, onPreview, onViewFullLog,
|
||||
onRefresh, isRefreshing, subtaskActivities, onSubtaskFilePreview,
|
||||
shareToken, onShareChange,
|
||||
}: LocalDetailPanelProps) {
|
||||
// Deferred tab id for content rendering. The tab indicator (DetailHeader)
|
||||
// uses `detailTab` (immediate) so the underline jumps on click. The heavy
|
||||
// content area below uses `deferredDetailTab` so expensive panels
|
||||
// (ProgressTab / TraceTab) don't block the click → indicator paint.
|
||||
// When detailTab !== deferredDetailTab we know a transition is in flight.
|
||||
const deferredDetailTab = useDeferredValue(detailTab);
|
||||
const tabTransitionPending = detailTab !== deferredDetailTab;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [continueOpen, setContinueOpen] = useState(false);
|
||||
const [editingVisibility, setEditingVisibility] = useState(false);
|
||||
const [savingVisibility, setSavingVisibility] = useState(false);
|
||||
const [editVisibility, setEditVisibility] = useState<Visibility>('private');
|
||||
const [editScopeOrgId, setEditScopeOrgId] = useState<string | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const authState = useAuthState();
|
||||
const currentUserId = authState.mode === 'authenticated' ? authState.user.id : null;
|
||||
const currentUserRole = authState.mode === 'authenticated' ? authState.user.role : null;
|
||||
const canEditVisibility = task
|
||||
? (currentUserRole === 'admin' || (currentUserId !== null && task.ownerId === currentUserId))
|
||||
: false;
|
||||
const { data: orgs = [] } = useQuery({
|
||||
queryKey: ['my-orgs'],
|
||||
queryFn: fetchMyOrgs,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: editingVisibility,
|
||||
});
|
||||
|
||||
// SSH console tab visibility: show whenever an active console session exists for this task.
|
||||
// (Piece-level pre-show via latestJob.allowedTools is not currently exposed by the API; this
|
||||
// fallback covers the real case where the AI has actually opened a session.)
|
||||
const { data: consoleStatus } = useQuery<ConsoleStatus>({
|
||||
queryKey: ['console-status', task?.id],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/tasks/${task!.id}/console/status`);
|
||||
return r.ok ? r.json() : { active: false };
|
||||
},
|
||||
enabled: !!task,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const showSshTab = consoleStatus?.active === true;
|
||||
const visibleTabs = LOCAL_TABS.filter((t) => t.id !== 'ssh' || showSshTab);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!task) return;
|
||||
setEditVisibility((task.visibility as Visibility) ?? 'private');
|
||||
setEditScopeOrgId(task.visibilityScopeOrgId ?? null);
|
||||
setEditError(null);
|
||||
setEditingVisibility(true);
|
||||
};
|
||||
|
||||
const handleSaveVisibility = async () => {
|
||||
if (!task) return;
|
||||
setEditError(null);
|
||||
setSavingVisibility(true);
|
||||
try {
|
||||
await updateLocalTask(task.id, {
|
||||
visibility: editVisibility,
|
||||
visibilityScopeOrgId: editVisibility === 'org' ? editScopeOrgId : null,
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
|
||||
setEditingVisibility(false);
|
||||
} catch (err) {
|
||||
setEditError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSavingVisibility(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!onDelete) return;
|
||||
if (!window.confirm('このタスクを削除しますか?この操作は取り消せません。')) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const jobStatus = task?.latestJob?.status;
|
||||
const isActiveJob = jobStatus === 'running' || jobStatus === 'dispatching';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-surface">
|
||||
<DetailHeader
|
||||
title={`Task #${taskId}`}
|
||||
subtitle="ローカルワークスペース"
|
||||
tabs={visibleTabs}
|
||||
activeTab={detailTab}
|
||||
tabTransitionPending={tabTransitionPending}
|
||||
onTabChange={onTabChange}
|
||||
onClose={onClose}
|
||||
detailWidth={detailWidth}
|
||||
onWidthToggle={showWidthToggle ? onWidthToggle : undefined}
|
||||
taskId={taskId}
|
||||
shareToken={shareToken}
|
||||
onShareChange={onShareChange}
|
||||
latestJobStatus={task?.latestJob?.status ?? null}
|
||||
onContinue={task?.latestJob ? () => setContinueOpen(true) : undefined}
|
||||
/>
|
||||
{continueOpen && task?.latestJob && (
|
||||
<ContinueWithPieceDialog
|
||||
taskId={taskId}
|
||||
prevJob={{
|
||||
id: task.latestJob.id,
|
||||
// task.pieceName tracks last-piece-wins after each Continue, so
|
||||
// it equals the latest job's piece.
|
||||
pieceName: task.pieceName,
|
||||
status: task.latestJob.status,
|
||||
}}
|
||||
onClose={() => setContinueOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={`flex-1 min-h-0 p-3 ${detailTab === 'ssh' ? 'overflow-hidden flex flex-col' : 'overflow-y-auto'}`}>
|
||||
{loading && !task && <SkeletonDetailPanel />}
|
||||
{task && (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 text-2xs text-slate-500 flex-wrap">
|
||||
<span>作成者: <b>{task.ownerName ?? 'system'}</b></span>
|
||||
<span>·</span>
|
||||
<span>{relativeTime(task.createdAt)}</span>
|
||||
{task.visibility === 'private' && <span>· 🔒 非公開</span>}
|
||||
{task.visibility === 'org' && <span>· 🏢 {task.visibilityScopeOrgName ?? 'org'}</span>}
|
||||
{task.visibility === 'public' && <span>· 🌐 公開</span>}
|
||||
{canEditVisibility && !editingVisibility && (
|
||||
<button
|
||||
className="ml-2 underline text-slate-500 hover:text-slate-700"
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
変更
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editingVisibility && (
|
||||
<div className="mb-3 p-2.5 border border-hairline rounded-md bg-white text-xs">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={editVisibility === 'private'}
|
||||
onChange={() => setEditVisibility('private')}
|
||||
/>
|
||||
🔒 非公開
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={editVisibility === 'org'}
|
||||
onChange={() => {
|
||||
setEditVisibility('org');
|
||||
if (!editScopeOrgId && orgs.length > 0) setEditScopeOrgId(orgs[0].orgId);
|
||||
}}
|
||||
disabled={orgs.length === 0}
|
||||
/>
|
||||
🏢 組織
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={editVisibility === 'public'}
|
||||
onChange={() => setEditVisibility('public')}
|
||||
/>
|
||||
🌐 公開
|
||||
</label>
|
||||
</div>
|
||||
{editVisibility === 'org' && orgs.length > 1 && (
|
||||
<select
|
||||
className="mt-2 px-2 h-7 border border-hairline rounded-md text-xs bg-white focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
value={editScopeOrgId ?? ''}
|
||||
onChange={e => setEditScopeOrgId(e.target.value)}
|
||||
>
|
||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{editVisibility === 'org' && orgs.length === 1 && (
|
||||
<div className="mt-1 text-2xs text-slate-500">共有先: {orgs[0].orgName}</div>
|
||||
)}
|
||||
{editVisibility === 'org' && orgs.length === 0 && (
|
||||
<div className="mt-1 text-2xs text-slate-400">組織を使うには Gitea でログインしてください</div>
|
||||
)}
|
||||
{editError && <div className="mt-1 text-2xs text-red-600">{editError}</div>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
disabled={savingVisibility}
|
||||
onClick={() => void handleSaveVisibility()}
|
||||
className="px-3 h-7 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep transition-colors"
|
||||
>
|
||||
{savingVisibility ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
disabled={savingVisibility}
|
||||
onClick={() => { setEditingVisibility(false); setEditError(null); }}
|
||||
className="px-3 h-7 border border-hairline rounded-md text-xs text-slate-600 hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{task?.latestJob?.status === 'waiting_human' && task?.latestJob?.waitReason === 'browser_login' && (
|
||||
<BrowserSessionPanel />
|
||||
)}
|
||||
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} />}
|
||||
{deferredDetailTab === 'activity' && <ProgressTab task={task} onViewFullLog={onViewFullLog} subtaskActivities={subtaskActivities} />}
|
||||
{deferredDetailTab === 'files' && <FilesTab section={section} currentPath={currentPath} entries={entries} pathSegments={pathSegments} taskId={taskId} onSectionChange={onSectionChange} onNavigate={onNavigate} onPreview={onPreview} onRefresh={onRefresh} isRefreshing={isRefreshing} />}
|
||||
{deferredDetailTab === 'trace' && <TraceTab taskId={taskId} />}
|
||||
{deferredDetailTab === 'browser' && <BrowserTab taskId={taskId} />}
|
||||
{deferredDetailTab === 'ssh' && <ConsoleTab taskId={taskId} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!loading && task && (
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-white px-3 py-2.5">
|
||||
<div className="flex gap-2 items-center">
|
||||
{onDelete && !isActiveJob ? (
|
||||
<button
|
||||
disabled={deleting}
|
||||
onClick={handleDelete}
|
||||
className="px-3 h-7 bg-white border border-red-200 text-red-700 rounded-md text-xs font-medium disabled:opacity-50 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
{deleting ? '削除中...' : '削除'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getLatestReflectionForTask } from '../../api';
|
||||
|
||||
interface ReflectionBadgeProps {
|
||||
taskId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a "🧠 Learned N things [+ piece edit]" pill when the most recent
|
||||
* reflection for this task applied changes. Hidden when:
|
||||
* - no reflection exists yet
|
||||
* - outcome is 'abstained' or 'failed'
|
||||
* - no memory changes AND no piece edit
|
||||
*
|
||||
* Clicking navigates to Settings > Memory & Learning (?page=settings§ion=memory-learning)
|
||||
* anchored at the snapshot via a hash fragment.
|
||||
*/
|
||||
export function ReflectionBadge({ taskId }: ReflectionBadgeProps) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['reflection-for-task', taskId],
|
||||
queryFn: () => getLatestReflectionForTask(taskId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
if (data.outcome === 'abstained' || data.outcome === 'failed') return null;
|
||||
|
||||
const n = data.memoryChanges ?? 0;
|
||||
if (n === 0 && !data.pieceEdited) return null;
|
||||
|
||||
const label = data.pieceEdited
|
||||
? `Learned ${n} ${n === 1 ? 'thing' : 'things'} + piece edit`
|
||||
: `Learned ${n} ${n === 1 ? 'thing' : 'things'}`;
|
||||
|
||||
// Navigate to Settings > Memory & Learning, anchored at the snapshot.
|
||||
// The app uses query-string-based URL state (no react-router), so we build
|
||||
// the URL directly. The hash lets MemoryLearningForm scroll to the snapshot
|
||||
// when the section loads.
|
||||
const href = `?page=settings§ion=memory-learning#snapshot-${data.snapshotId}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-xs text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
🧠 {label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { POLLING } from '../../../lib/constants.js';
|
||||
import { usePictureInPicture } from '../../../lib/usePictureInPicture.js';
|
||||
import { PipButton } from '../../browser/PipButton.js';
|
||||
import { SaveRecordingButton } from '../../browser/SaveRecordingButton.js';
|
||||
|
||||
interface TaskSessionInfo {
|
||||
available: boolean;
|
||||
reason?: 'novnc_not_installed';
|
||||
sessionId?: string;
|
||||
novncPath?: string;
|
||||
display?: string;
|
||||
state?: 'ready' | 'user_interactive' | 'agent_controlled';
|
||||
lockedByJobId?: string | null;
|
||||
createdAt?: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
function useTaskSession(taskId: number) {
|
||||
return useQuery<TaskSessionInfo>({
|
||||
queryKey: ['task-session', taskId],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<TaskSessionInfo>;
|
||||
},
|
||||
refetchInterval: POLLING.FAST,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
function useReleaseSession(taskId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const r = await fetch(`/api/local/browser/sessions/task-session/${taskId}/release`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: ['task-session', taskId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 詳細の "Browser" タブ。BrowseWeb / InteractiveBrowse がそのタスクで
|
||||
* noVNC session を起動していれば iframe で埋め込み、ユーザーが直接ブラウザを
|
||||
* 操作できる。可視性チェックは API 層 (GET /task-session/:taskId) が行うので
|
||||
* ここではタブ自体を全員に見せて構わない (見えないユーザーには available:false が返る)。
|
||||
*/
|
||||
export function BrowserTab({ taskId }: { taskId: number }) {
|
||||
const { data, isLoading, isError, error } = useTaskSession(taskId);
|
||||
const release = useReleaseSession(taskId);
|
||||
const pip = usePictureInPicture(data?.novncPath ?? null, `noVNC — Task #${taskId}`);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||
読み込み中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
<div className="p-4 text-sm text-red-700">
|
||||
ブラウザセッション情報の取得に失敗しました: {msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.available) {
|
||||
if (data?.reason === 'novnc_not_installed') {
|
||||
return (
|
||||
<div className="bg-white border border-amber-300 rounded-md p-6 text-sm text-slate-700">
|
||||
<p className="font-medium text-amber-800 mb-2">noVNC の Web 配布物 (vnc.html) が配置されていません</p>
|
||||
<p className="text-xs leading-relaxed mb-2">
|
||||
このタスクのブラウザセッションは存在しますが、noVNC の HTML/JS 一式が
|
||||
<code className="mx-1 px-1 rounded bg-slate-100 font-mono text-2xs">vendor/noVNC/</code>
|
||||
に無いため iframe を表示できません。
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed mb-2">
|
||||
以下のいずれかの方法でセットアップしてください:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs leading-relaxed space-y-1">
|
||||
<li>bare metal / dev 環境: <code className="px-1 rounded bg-slate-100 font-mono text-2xs">scripts/setup-novnc.sh</code> を実行</li>
|
||||
<li>Docker: 最新の Dockerfile (noVNC tarball を builder で展開) で再ビルド</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="bg-white border border-hairline rounded-md p-6 text-center text-sm text-slate-600">
|
||||
<p className="font-medium text-slate-800 mb-1">このタスクのブラウザセッションは現在アクティブではありません</p>
|
||||
<p className="text-xs leading-relaxed">
|
||||
BrowseWeb / InteractiveBrowse を含むジョブが実行中のときに、このタブから
|
||||
noVNC でブラウザを操作できます (5 秒ポーリング中)。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-hairline rounded-md overflow-hidden flex flex-col" style={{ minHeight: '480px' }}>
|
||||
<div className="flex items-center justify-between border-b border-hairline px-3 py-2 text-2xs text-slate-500 gap-2">
|
||||
<span className="truncate">
|
||||
state: <span className="font-mono text-slate-700">{data.state ?? '-'}</span>
|
||||
{data.lockedByJobId && <> · job: <span className="font-mono">{data.lockedByJobId}</span></>}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<PipButton pip={pip} />
|
||||
<SaveRecordingButton taskId={taskId} />
|
||||
<a
|
||||
href={data.novncPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-2xs text-accent hover:underline"
|
||||
>
|
||||
新しいタブで開く ↗
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm('このタスクのブラウザセッションを終了します。よろしいですか?')) {
|
||||
release.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={release.isPending}
|
||||
className="px-2 py-1 rounded-md text-2xs border border-hairline bg-white hover:bg-surface text-slate-700 disabled:opacity-50"
|
||||
title="セッションを destroy する。次回 BrowseWeb 実行時に再生成される"
|
||||
>
|
||||
{release.isPending ? '終了中…' : 'セッション終了'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{pip.isOpen ? (
|
||||
<div
|
||||
className="flex-1 w-full flex items-center justify-center bg-slate-50 text-xs text-slate-500"
|
||||
style={{ minHeight: '480px' }}
|
||||
>
|
||||
PiP ウィンドウで表示中。閉じるとここに戻ります。
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={data.novncPath}
|
||||
title={`Task #${taskId} browser session`}
|
||||
className="flex-1 w-full border-0"
|
||||
style={{ minHeight: '480px' }}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useConsoleSession } from '../../../hooks/useConsoleSession';
|
||||
import type { ConsoleStatus } from '../../../lib/ssh-console-types';
|
||||
import { TerminalView, type TerminalViewHandle } from './console/TerminalView';
|
||||
import { ConsoleHeader } from './console/ConsoleHeader';
|
||||
import { MobileKeyboardBar } from './console/MobileKeyboardBar';
|
||||
import { ScrollToBottomButton } from './console/ScrollToBottomButton';
|
||||
import { useViewportNarrow } from '../../layout/TopBar';
|
||||
|
||||
export function ConsoleTab({ taskId }: { taskId: number }) {
|
||||
const { data: status } = useQuery<ConsoleStatus>({
|
||||
queryKey: ['console-status', taskId],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`/api/local/tasks/${taskId}/console/status`);
|
||||
return r.ok ? r.json() : { active: false };
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const session = useConsoleSession(taskId);
|
||||
const terminalRef = useRef<TerminalViewHandle>(null);
|
||||
// 768px = Tailwind md breakpoint. Below this we consider the user to be on
|
||||
// a phone/tablet without a physical keyboard, so the on-screen keyboard bar
|
||||
// and scroll-to-bottom FAB become useful.
|
||||
const compactMode = useViewportNarrow(768);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<ConsoleHeader state={session.state} status={status ?? null} />
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<TerminalView ref={terminalRef} session={session} />
|
||||
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
|
||||
</div>
|
||||
{compactMode && <MobileKeyboardBar session={session} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LocalFileEntry } from '../../../api';
|
||||
import { FileBrowser } from '../../files/FileBrowser';
|
||||
|
||||
interface FilesTabProps {
|
||||
section: 'workspace' | 'input' | 'output' | 'logs';
|
||||
currentPath: string;
|
||||
entries: LocalFileEntry[];
|
||||
pathSegments: string[];
|
||||
taskId?: number;
|
||||
onSectionChange: (section: 'workspace' | 'input' | 'output' | 'logs') => void;
|
||||
onNavigate: (path: string) => void;
|
||||
onPreview: (path: string, name: string) => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function FilesTab(props: FilesTabProps) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<FileBrowser {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { LinkifiedText } from '../../../lib/linkified-text';
|
||||
|
||||
interface OutputTabProps {
|
||||
outputPreviewName: string;
|
||||
outputPreviewContent: string;
|
||||
onViewFull: () => void;
|
||||
}
|
||||
|
||||
export function OutputTab({ outputPreviewName, outputPreviewContent, onViewFull }: OutputTabProps) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="font-bold text-[13px] text-slate-800">成果物プレビュー</div>
|
||||
{outputPreviewName && (
|
||||
<button onClick={onViewFull} className="text-2xs text-blue-600 font-bold hover:underline">全文</button>
|
||||
)}
|
||||
</div>
|
||||
{outputPreviewName ? (
|
||||
<>
|
||||
<div className="text-2xs text-slate-400 mb-2 font-mono">{outputPreviewName}</div>
|
||||
{/* LinkifiedText turns inline `output/foo.md` references into
|
||||
clickable anchors that the OutputPreviewProvider opens in
|
||||
the preview pane. Plain `<pre>` rendering otherwise. */}
|
||||
<LinkifiedText
|
||||
as="pre"
|
||||
className="text-xs whitespace-pre-wrap bg-slate-50 rounded-xl p-3 min-h-[260px] max-h-[540px] overflow-auto border border-slate-100"
|
||||
text={outputPreviewContent.slice(0, 12000)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[13px] text-slate-500">まだ成果物が生成されていません。</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api';
|
||||
import { StatusBadge } from '../../shared/StatusBadge';
|
||||
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
|
||||
import { ContextUsageGauge } from '../ContextUsageGauge';
|
||||
import { ReflectionBadge } from '../ReflectionBadge';
|
||||
|
||||
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった'];
|
||||
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
|
||||
|
||||
function FeedbackPanel({ task }: { task: LocalTask }) {
|
||||
const qc = useQueryClient();
|
||||
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
|
||||
const hasFeedback = !!task.feedbackRating;
|
||||
|
||||
const [rating, setRating] = useState<'good' | 'bad' | null>(task.feedbackRating ?? null);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(task.feedbackTags ?? []);
|
||||
const [comment, setComment] = useState(task.feedbackComment ?? '');
|
||||
const [editing, setEditing] = useState(!hasFeedback);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (fb: { rating: 'good' | 'bad'; tags: string[]; comment?: string }) =>
|
||||
putFeedback(task.id, fb),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
|
||||
setEditing(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isComplete) return null;
|
||||
|
||||
const tags = rating === 'good' ? GOOD_TAGS : rating === 'bad' ? BAD_TAGS : [];
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!rating) return;
|
||||
mutation.mutate({ rating, tags: selectedTags, comment: comment || undefined });
|
||||
};
|
||||
const handleRatingClick = (r: 'good' | 'bad') => {
|
||||
setRating(r);
|
||||
setSelectedTags([]);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
if (!editing && hasFeedback) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-700">フィードバック</span>
|
||||
<span className={`text-lg ${task.feedbackRating === 'good' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{task.feedbackRating === 'good' ? '👍' : '👎'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
変更
|
||||
</button>
|
||||
</div>
|
||||
{task.feedbackTags && task.feedbackTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{task.feedbackTags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{task.feedbackComment && (
|
||||
<div className="mt-2 text-xs text-slate-500">{task.feedbackComment}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="text-sm font-semibold text-slate-700 mb-2">フィードバック</div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => handleRatingClick('good')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border transition-colors ${
|
||||
rating === 'good' ? 'bg-green-50 border-green-300 text-green-700' : 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
👍 良かった
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRatingClick('bad')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border transition-colors ${
|
||||
rating === 'bad' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
👎 改善が必要
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rating && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`px-2 py-0.5 rounded-full text-2xs border transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-accent-soft border-accent text-accent'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="コメント(任意)"
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-xs border border-slate-200 rounded-lg resize-none focus:outline-none focus:ring-1 focus:ring-accent-ring mb-2"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{hasFeedback && (
|
||||
<button
|
||||
onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); }}
|
||||
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={mutation.isPending}
|
||||
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? '送信中...' : '送信'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mission Brief card. Per-task pinned memo with goal / done / open /
|
||||
* clarifications. The LLM updates these via the MissionUpdate tool;
|
||||
* the user can edit them here to anchor or correct the agent. Always
|
||||
* shown so the user can guide the agent before the conversation drifts.
|
||||
*/
|
||||
const MISSION_FIELDS: Array<{
|
||||
key: keyof MissionBrief;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
emptyHint: string;
|
||||
}> = [
|
||||
{ key: 'goal', label: '目標', placeholder: 'このタスクの本質的な目標 (Markdown 可)', emptyHint: '未設定 — エージェントが最初に書きます' },
|
||||
{ key: 'done', label: '完了', placeholder: '完了したマイルストーン (Markdown 箇条書き推奨)', emptyHint: 'まだ何も完了していません' },
|
||||
{ key: 'open', label: '残タスク', placeholder: '残っている作業 / ブロッカー', emptyHint: '残タスク未記入' },
|
||||
{ key: 'clarifications', label: '補足・制約', placeholder: '途中で追加された制約・補足', emptyHint: '補足なし' },
|
||||
];
|
||||
|
||||
const EMPTY_MISSION: MissionBrief = { goal: '', done: '', open: '', clarifications: '' };
|
||||
|
||||
function MissionCard({ task }: { task: LocalTask }) {
|
||||
const qc = useQueryClient();
|
||||
const current = task.missionBrief ?? EMPTY_MISSION;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<MissionBrief>(current);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Keep draft in sync with server-side updates (e.g. LLM writes via
|
||||
// MissionUpdate while we're not editing). Don't clobber an active edit.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(task.missionBrief ?? EMPTY_MISSION);
|
||||
}, [task.missionBrief, editing]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateMissionBrief(task.id, draft),
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save mission brief');
|
||||
},
|
||||
});
|
||||
|
||||
const isEmpty = !current.goal && !current.done && !current.open && !current.clarifications;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-hairline rounded-md p-3.5">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 text-slate-500" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 2v12M3 2h7l-1 2 1 2H3" />
|
||||
</svg>
|
||||
<span className="section-label">Mission Brief</span>
|
||||
<span className="text-[10px] text-slate-400">— 固定メモ</span>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDraft(current); setEditing(true); setError(null); }}
|
||||
className="px-2 h-7 text-2xs font-medium border border-hairline bg-white text-slate-700 hover:bg-surface rounded-md transition-colors"
|
||||
>
|
||||
編集
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{MISSION_FIELDS.map(({ key, label, placeholder }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-1">{label}</label>
|
||||
<textarea
|
||||
value={draft[key] ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
rows={key === 'goal' ? 2 : 3}
|
||||
className="w-full px-2.5 py-1.5 text-xs border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent transition-shadow font-mono leading-snug"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className="text-2xs text-red-600">{error}</div>}
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setError(null); setDraft(current); }}
|
||||
disabled={mutation.isPending}
|
||||
className="px-3 h-7 text-xs rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="text-xs text-slate-500 leading-relaxed">
|
||||
まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、
|
||||
手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{MISSION_FIELDS.map(({ key, label, emptyHint }) => {
|
||||
const value = current[key];
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-0.5">{label}</div>
|
||||
{value ? (
|
||||
<div className="text-xs text-slate-800 whitespace-pre-wrap leading-snug font-mono">{value}</div>
|
||||
) : (
|
||||
<div className="text-2xs text-slate-400 italic">{emptyHint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
task: LocalTask;
|
||||
subtaskActivities?: SubtaskActivity[];
|
||||
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
|
||||
}
|
||||
|
||||
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: OverviewTabProps) {
|
||||
const status = task.latestJob?.status ?? 'queued';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="text-lg font-extrabold text-slate-900">{task.title}</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.priority}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-[13px] text-slate-600 whitespace-pre-wrap leading-relaxed">{task.body || '(no body)'}</div>
|
||||
</div>
|
||||
|
||||
<MissionCard task={task} />
|
||||
|
||||
<ContextUsageGauge
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
jobStatus={task.latestJob?.status}
|
||||
/>
|
||||
|
||||
<FeedbackPanel task={task} />
|
||||
|
||||
<ReflectionBadge taskId={task.id} />
|
||||
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<SubtasksPanel
|
||||
taskId={task.id}
|
||||
subtasks={task.subtasks}
|
||||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||
subtaskActivities={subtaskActivities}
|
||||
onFilePreview={onSubtaskFilePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { LocalTask, SubtaskActivity } from '../../../api';
|
||||
import { parseActivityLog } from '../../../lib/utils';
|
||||
import { useLocalActivityLog } from '../../../hooks/useTaskDetail';
|
||||
import { ActivityTimeline } from '../../activity/ActivityTimeline';
|
||||
import { SubtaskActivitySection } from './SubtaskActivitySection';
|
||||
|
||||
interface ProgressTabProps {
|
||||
task: LocalTask;
|
||||
onViewFullLog: () => void;
|
||||
subtaskActivities?: SubtaskActivity[];
|
||||
}
|
||||
|
||||
export function ProgressTab({ task, onViewFullLog, subtaskActivities }: ProgressTabProps) {
|
||||
const hasSubtasks = subtaskActivities && subtaskActivities.length > 0;
|
||||
const activityLogQuery = useLocalActivityLog(task.id, true);
|
||||
const activityLog = activityLogQuery.data ?? '';
|
||||
const activityEvents = parseActivityLog(activityLog);
|
||||
const logLoading = activityLogQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="font-bold text-[13px] text-slate-800">実行 Timeline</div>
|
||||
<div className="text-2xs text-slate-400">{activityEvents.length} 件</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-3">
|
||||
{task.latestJob?.currentMovement ? `現在: ${task.latestJob.currentMovement}` : '現在の movement は取得待ちです'}
|
||||
{task.latestJob?.currentActivity && ['running', 'dispatching'].includes(task.latestJob?.status ?? '') && (
|
||||
<div className="text-2xs text-slate-400 mt-0.5 font-mono truncate">
|
||||
{task.latestJob.currentActivity}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ActivityTimeline
|
||||
events={activityEvents}
|
||||
emptyLabel={logLoading ? '読み込み中...' : 'まだ進行情報がありません。'}
|
||||
/>
|
||||
</div>
|
||||
{hasSubtasks && <SubtaskActivitySection subtaskActivities={subtaskActivities!} />}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="font-bold text-[13px] text-slate-800">Raw activity.log</div>
|
||||
<button onClick={onViewFullLog} className="text-2xs text-blue-600 font-bold hover:underline">全文</button>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap bg-slate-900 text-slate-100 rounded-xl p-3 min-h-[260px] max-h-[520px] overflow-auto font-mono">
|
||||
{logLoading && !activityLog
|
||||
? '(activity.log を読み込み中...)'
|
||||
: (activityLog || '(activity.log がまだありません)').slice(-12000)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SubtaskActivity } from '../../../api';
|
||||
import { statusTone, formatStatusLabel, parseActivityLog } from '../../../lib/utils';
|
||||
import { ActivityTimeline } from '../../activity/ActivityTimeline';
|
||||
|
||||
interface SubtaskActivitySectionProps {
|
||||
subtaskActivities: SubtaskActivity[];
|
||||
}
|
||||
|
||||
function SubtaskActivitySummary({ activity }: { activity: SubtaskActivity }) {
|
||||
const tone = statusTone(activity.status);
|
||||
const events = parseActivityLog(activity.activityLog);
|
||||
const isActive = ['running', 'waiting_human', 'waiting_subtasks'].includes(activity.status);
|
||||
const isDone = activity.status === 'succeeded' || activity.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="border border-slate-100 rounded-lg p-2.5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={{ background: tone.bg, color: tone.fg }}
|
||||
>
|
||||
{formatStatusLabel(activity.status)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-700">
|
||||
#{activity.issueNumber}
|
||||
</span>
|
||||
{activity.currentMovement && isActive && (
|
||||
<span className="text-2xs text-slate-400 font-mono">
|
||||
{activity.currentMovement}
|
||||
{activity.currentActivity && (
|
||||
<span className="ml-1 text-slate-300">/ {activity.currentActivity}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{isDone && (
|
||||
<span className="text-2xs text-slate-400">
|
||||
{events.length} events
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && events.length > 0 && (
|
||||
<div className="ml-2 mt-1">
|
||||
<ActivityTimeline events={events} emptyLabel="" limit={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtaskActivitySection({ subtaskActivities }: SubtaskActivitySectionProps) {
|
||||
if (subtaskActivities.length === 0) return null;
|
||||
|
||||
const completed = subtaskActivities.filter(
|
||||
s => s.status === 'succeeded' || s.status === 'failed' || s.status === 'cancelled',
|
||||
).length;
|
||||
const total = subtaskActivities.length;
|
||||
const progressPct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-[13px] font-bold text-slate-800">サブタスク進捗</div>
|
||||
<div className="text-xs text-slate-500">{completed}/{total} 完了</div>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-1.5 mb-4">
|
||||
<div className="bg-accent h-1.5 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{subtaskActivities.map(activity => (
|
||||
<SubtaskActivitySummary key={activity.jobId} activity={activity} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { POLLING } from '../../../lib/constants.js';
|
||||
import { SubtaskInfo, SubtaskActivity, SubtaskFiles, fetchSubtaskFiles, subtaskFileRawUrl, fetchSubtaskActivity } from '../../../api';
|
||||
import { statusTone, formatStatusLabel, parseActivityLog, isPreviewable } from '../../../lib/utils';
|
||||
import { ActivityTimeline } from '../../activity/ActivityTimeline';
|
||||
import { LinkifiedText } from '../../../lib/linkified-text';
|
||||
import { OutputPreviewProvider } from '../../../lib/output-preview-context';
|
||||
import { stripOutputPrefix } from '../../../lib/output-path-detect';
|
||||
|
||||
export type SubtaskFilePreviewHandler = (taskId: number, jobId: string, category: string, filePath: string) => void;
|
||||
|
||||
interface SubtasksPanelProps {
|
||||
taskId: number;
|
||||
subtasks: SubtaskInfo[];
|
||||
subtaskCount: number;
|
||||
subtaskCompleted: number;
|
||||
subtaskActivities?: SubtaskActivity[];
|
||||
onFilePreview?: SubtaskFilePreviewHandler;
|
||||
}
|
||||
|
||||
interface SubtaskCardProps {
|
||||
taskId: number;
|
||||
subtask: SubtaskInfo;
|
||||
activity?: SubtaskActivity;
|
||||
onFilePreview?: SubtaskFilePreviewHandler;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['running', 'waiting_human', 'waiting_subtasks']);
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
output: '出力ファイル',
|
||||
logs: 'ログ',
|
||||
input: '入力ファイル',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ['output', 'logs', 'input'];
|
||||
|
||||
function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler }) {
|
||||
const label = CATEGORY_LABELS[category] ?? category;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">{label}</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{files.map(filePath => {
|
||||
const previewable = isPreviewable(filePath);
|
||||
return (
|
||||
<li key={filePath} className="flex items-center gap-1.5">
|
||||
{previewable && onFilePreview ? (
|
||||
<button
|
||||
onClick={() => onFilePreview(taskId, jobId, category, filePath)}
|
||||
className="text-xs text-blue-600 hover:underline break-all text-left"
|
||||
>
|
||||
{filePath}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={subtaskFileRawUrl(taskId, jobId, `${category}/${filePath}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline break-all"
|
||||
>
|
||||
{filePath}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tone = statusTone(subtask.status);
|
||||
const title = subtask.instruction.split('\n')[0]?.slice(0, 100) ?? '';
|
||||
const isActive = ACTIVE_STATUSES.has(subtask.status);
|
||||
|
||||
const { data: activityLog } = useQuery({
|
||||
queryKey: ['subtaskActivity', taskId, subtask.id],
|
||||
queryFn: () => fetchSubtaskActivity(taskId, subtask.id),
|
||||
refetchInterval: POLLING.FAST,
|
||||
enabled: expanded && isActive,
|
||||
});
|
||||
|
||||
const displayLog = expanded && !isActive ? (activity?.activityLog ?? '') : (activityLog ?? '');
|
||||
const activityEvents = expanded ? parseActivityLog(displayLog) : [];
|
||||
|
||||
const { data: subtaskFiles, isLoading: filesLoading } = useQuery({
|
||||
queryKey: ['subtask-files', taskId, subtask.id],
|
||||
queryFn: () => fetchSubtaskFiles(taskId, subtask.id),
|
||||
enabled: expanded,
|
||||
refetchInterval: isActive ? POLLING.MEDIUM : false,
|
||||
});
|
||||
|
||||
const currentMovement = activity?.currentMovement;
|
||||
const categories = subtaskFiles?.categories ?? {};
|
||||
const hasFiles = Object.values(categories).some(f => f.length > 0);
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg bg-white overflow-hidden">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2.5 flex items-start gap-2 hover:bg-slate-50 transition-colors"
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 mt-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={{ background: tone.bg, color: tone.fg }}
|
||||
>
|
||||
{formatStatusLabel(subtask.status)}
|
||||
</span>
|
||||
<span className="text-[13px] text-slate-800 font-medium leading-snug min-w-0 truncate flex-1">
|
||||
#{subtask.issueNumber} {title}
|
||||
</span>
|
||||
{subtask.children && subtask.children.length > 0 && (
|
||||
<span className="flex-shrink-0 text-[10px] text-indigo-500 font-medium">
|
||||
({subtask.childCompleted ?? 0}/{subtask.childCount ?? subtask.children.length})
|
||||
</span>
|
||||
)}
|
||||
{currentMovement && isActive && (
|
||||
<span className="flex-shrink-0 text-2xs text-slate-400 font-mono">
|
||||
{currentMovement}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-shrink-0 ml-auto text-slate-400 text-xs">
|
||||
{expanded ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
// Subtask-scoped preview context: any `output/...` link inside
|
||||
// this card opens the SUBTASK's workspace file (not the main
|
||||
// task's). Wrapping just the expanded body keeps the outer
|
||||
// (main task) provider in charge of everything else.
|
||||
<OutputPreviewProvider
|
||||
openOutputPath={(matchedPath) => {
|
||||
if (!onFilePreview) return;
|
||||
const relative = stripOutputPrefix(matchedPath);
|
||||
onFilePreview(taskId, subtask.id, 'output', relative);
|
||||
}}
|
||||
>
|
||||
<div className="px-3 pb-3 border-t border-slate-100">
|
||||
<LinkifiedText
|
||||
as="pre"
|
||||
className="mt-2 text-xs text-slate-600 whitespace-pre-wrap leading-relaxed font-sans"
|
||||
text={subtask.instruction}
|
||||
/>
|
||||
|
||||
{activityEvents.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-2xs font-semibold text-slate-500 mb-1">Activity</div>
|
||||
<ActivityTimeline
|
||||
events={activityEvents}
|
||||
emptyLabel=""
|
||||
limit={isActive ? 5 : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filesLoading && <div className="mt-3 text-xs text-slate-400">ファイル読み込み中...</div>}
|
||||
{hasFiles && (
|
||||
<div className="mt-3">
|
||||
<div className="text-2xs font-semibold text-slate-500 mb-1">ファイル</div>
|
||||
{CATEGORY_ORDER.map(cat =>
|
||||
categories[cat] && categories[cat].length > 0 ? (
|
||||
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} />
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subtask.children && subtask.children.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-2xs font-semibold text-slate-500 mb-1">
|
||||
子タスク ({subtask.childCompleted ?? 0}/{subtask.childCount ?? subtask.children.length} 完了)
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 ml-2 border-l-2 border-indigo-100 pl-2">
|
||||
{subtask.children.map(child => (
|
||||
<SubtaskCard key={child.id} taskId={taskId} subtask={child} onFilePreview={onFilePreview} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OutputPreviewProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtasksPanel({ taskId, subtasks, subtaskCount, subtaskCompleted, subtaskActivities, onFilePreview }: SubtasksPanelProps) {
|
||||
const progressPct = subtaskCount > 0 ? Math.round((subtaskCompleted / subtaskCount) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-bold text-slate-800">サブタスク</div>
|
||||
<div className="text-xs text-slate-500">{subtaskCompleted}/{subtaskCount} 完了</div>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-1.5 mb-4">
|
||||
<div className="bg-accent h-1.5 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{subtasks.map(subtask => (
|
||||
<SubtaskCard key={subtask.id} taskId={taskId} subtask={subtask} activity={subtaskActivities?.find(a => a.jobId === subtask.id)} onFilePreview={onFilePreview} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { LocalTaskComment } from '../../../api';
|
||||
import { MarkdownText } from '../../../lib/markdown-text';
|
||||
|
||||
// Comment kinds rendered here:
|
||||
// `request` / `comment` (user), `progress` / `result` / `ask` (agent),
|
||||
// `handoff` (system marker for /continue) → rendered as a horizontal
|
||||
// divider instead of a card.
|
||||
export function TimelineTab({ comments }: { comments: LocalTaskComment[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{comments.map(c => {
|
||||
if (c.kind === 'handoff') {
|
||||
return (
|
||||
<div key={c.id} className="flex items-center gap-2 my-2">
|
||||
<div className="flex-1 border-t border-slate-300" />
|
||||
<div className="text-2xs text-slate-500 font-medium px-2 whitespace-nowrap">{c.body}</div>
|
||||
<div className="flex-1 border-t border-slate-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={c.id} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<div className="text-xs font-bold text-slate-700">{c.author}</div>
|
||||
<div className="text-2xs text-slate-400">{new Date(c.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-2xs text-slate-400 mb-1.5">{c.kind}</div>
|
||||
<MarkdownText text={c.body} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{comments.length === 0 && <div className="text-[13px] text-slate-500">コメントなし</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchLocalFileContent } from '../../../api';
|
||||
|
||||
// Mirror of `src/progress/event-log.ts` EventBase. Kept as a duplicate
|
||||
// here because the Vite UI build is a separate project from the engine.
|
||||
interface TraceEvent {
|
||||
v: 1;
|
||||
ts: string;
|
||||
seq: number;
|
||||
eventId: string;
|
||||
runId: string;
|
||||
parentEventId?: string;
|
||||
correlationId?: string;
|
||||
llmToolCallId?: string;
|
||||
movement?: string;
|
||||
iteration?: number;
|
||||
kind: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface ParseSummary {
|
||||
events: TraceEvent[];
|
||||
skipped: number;
|
||||
unknownVersion: number;
|
||||
}
|
||||
|
||||
function parseEventsJsonl(raw: string): ParseSummary {
|
||||
const out: TraceEvent[] = [];
|
||||
let skipped = 0;
|
||||
let unknownVersion = 0;
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.v !== 1) {
|
||||
unknownVersion++;
|
||||
continue;
|
||||
}
|
||||
if (typeof obj.kind !== 'string' || typeof obj.seq !== 'number' || typeof obj.eventId !== 'string') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
out.push(obj as unknown as TraceEvent);
|
||||
}
|
||||
return { events: out, skipped, unknownVersion };
|
||||
}
|
||||
|
||||
// Categorize for filter chips and color coding.
|
||||
const CATEGORIES: Array<{ id: string; label: string; kinds: string[]; tone: string }> = [
|
||||
{ id: 'run', label: 'Run', kinds: ['run_start', 'run_complete'], tone: 'bg-surface-2 text-slate-700 border-hairline' },
|
||||
{ id: 'movement', label: 'Movement', kinds: ['movement_start', 'movement_complete', 'transition', 'complete'], tone: 'bg-blue-50 text-blue-800 border-blue-100' },
|
||||
{ id: 'tool', label: 'Tool', kinds: ['tool_call', 'tool_result'], tone: 'bg-white text-slate-700 border-hairline' },
|
||||
{ id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100' },
|
||||
{ id: 'cache', label: 'Cache', kinds: ['cache_set', 'cache_hit', 'cache_invalidate'], tone: 'bg-amber-50 text-amber-800 border-amber-100' },
|
||||
{ id: 'memory', label: 'Memory', kinds: ['memory_invalidate', 'memory_update_call', 'memory_handoff_write', 'memory_handoff_read', 'memory_delta_write', 'memory_delta_absorb', 'memory_snapshot_written', 'memory_snapshot_failed'], tone: 'bg-emerald-50 text-emerald-800 border-emerald-100' },
|
||||
{ id: 'watchdog', label: 'Watchdog', kinds: ['watchdog_fire', 'followup_detected'], tone: 'bg-red-50 text-red-800 border-red-100' },
|
||||
{ id: 'context', label: 'Context', kinds: ['context_action'], tone: 'bg-violet-50 text-violet-800 border-violet-100' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Color a duration bar by magnitude. Bars are inline next to tool_result /
|
||||
* llm_call_end rows so users can scan a timeline and spot the long tail at
|
||||
* a glance — XPostDetail taking 3 min stands out as red, a 200ms Read fades
|
||||
* to almost nothing.
|
||||
*/
|
||||
function durationBarStyle(ms: number): { widthPct: number; tone: string } {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return { widthPct: 0, tone: 'bg-slate-200' };
|
||||
// Log scale: 100ms = 10%, 1s = 30%, 10s = 60%, 100s = 90%, >180s = 100%.
|
||||
const widthPct = Math.min(100, Math.max(4, Math.log10(ms) * 22 - 22));
|
||||
const tone = ms >= 60_000 ? 'bg-red-400'
|
||||
: ms >= 10_000 ? 'bg-orange-400'
|
||||
: ms >= 2_000 ? 'bg-amber-400'
|
||||
: ms >= 500 ? 'bg-emerald-400'
|
||||
: 'bg-slate-300';
|
||||
return { widthPct, tone };
|
||||
}
|
||||
|
||||
function formatDurationLabel(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '?';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const sec = Math.round(ms / 1000);
|
||||
return `${Math.floor(sec / 60)}m${(sec % 60).toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
function toneFor(kind: string): string {
|
||||
for (const c of CATEGORIES) if (c.kinds.includes(kind)) return c.tone;
|
||||
return 'bg-white text-slate-600 border-slate-200';
|
||||
}
|
||||
|
||||
function categoryFor(kind: string): string {
|
||||
for (const c of CATEGORIES) if (c.kinds.includes(kind)) return c.id;
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function summarizePayload(event: TraceEvent): string {
|
||||
const p = event.payload as Record<string, unknown> | null;
|
||||
if (!p) return '';
|
||||
switch (event.kind) {
|
||||
case 'tool_call': {
|
||||
const args = p.args as Record<string, unknown> | undefined;
|
||||
const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['url'] ?? args?.['pattern'];
|
||||
return `${String(p.tool ?? '?')}${filePath ? ` ${filePath}` : ''}`;
|
||||
}
|
||||
case 'tool_result':
|
||||
return `${String(p.tool ?? '?')} ${p.isError ? '⚠ error' : 'ok'}${p.cacheHit ? ' (cached)' : ''} ${formatDurationLabel(Number(p.durationMs ?? 0))}`;
|
||||
case 'llm_call_start':
|
||||
return `iter=${p.iteration ?? '?'} msgs=${p.messageCount ?? '?'}`;
|
||||
case 'llm_call_end': {
|
||||
const tokens = (typeof p.promptTokens === 'number' && typeof p.completionTokens === 'number')
|
||||
? ` in=${p.promptTokens} out=${p.completionTokens}`
|
||||
: '';
|
||||
const shape = (p.toolCalls as number) > 0 ? ` tools=${p.toolCalls}`
|
||||
: (p.textChars as number) > 0 ? ` text=${p.textChars}c`
|
||||
: '';
|
||||
return `${formatDurationLabel(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`;
|
||||
}
|
||||
case 'cache_set':
|
||||
return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`;
|
||||
case 'cache_hit':
|
||||
return `${String(p.tool ?? '?')} from ${String(p.sourceMovement ?? '?')} (${p.ageMs ?? '?'}ms ago)`;
|
||||
case 'cache_invalidate':
|
||||
case 'memory_invalidate':
|
||||
return `${String(p.trigger ?? '')} → ${p.entriesEvicted ?? 0} entries`;
|
||||
case 'memory_update_call': {
|
||||
const counts = p.counts as Record<string, number> | null;
|
||||
if (!counts) return p.empty ? 'empty payload' : '';
|
||||
const parts: string[] = [];
|
||||
if (counts.factsAdded) parts.push(`facts +${counts.factsAdded}`);
|
||||
if (counts.factsMerged) parts.push(`facts merged ${counts.factsMerged}`);
|
||||
if (counts.decisionsAdded) parts.push(`decisions +${counts.decisionsAdded}`);
|
||||
if (counts.openQuestionsAdded) parts.push(`open_questions +${counts.openQuestionsAdded}`);
|
||||
if (counts.doNotRepeatAdded) parts.push(`do_not_repeat +${counts.doNotRepeatAdded}`);
|
||||
return parts.join(', ') || 'no changes';
|
||||
}
|
||||
case 'memory_handoff_write':
|
||||
return p.skipped ? `skipped: ${p.reason}` : `→ child #${p.subtaskIndex ?? '?'} (${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
|
||||
case 'memory_handoff_read':
|
||||
return `from parent ${String(p.parentJobId ?? '?')}`;
|
||||
case 'memory_delta_write':
|
||||
return p.skipped ? `skipped: ${p.reason}` : `${p.childStatus} ${p.partial ? '(partial) ' : ''}(${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
|
||||
case 'memory_delta_absorb':
|
||||
return `${String(p.outcome ?? '?')}${p.childJobId ? ` ← ${p.childJobId}` : ''}`;
|
||||
case 'memory_snapshot_written': {
|
||||
const parts: string[] = [];
|
||||
if (typeof p.facts === 'number') parts.push(`${p.facts}f`);
|
||||
if (typeof p.decisions === 'number') parts.push(`${p.decisions}d`);
|
||||
if (typeof p.openQuestions === 'number') parts.push(`${p.openQuestions}q`);
|
||||
const counts = parts.length ? ` (${parts.join('/')})` : '';
|
||||
const sizeKb = typeof p.bytes === 'number' ? ` ${(p.bytes / 1024).toFixed(1)}KB` : '';
|
||||
return `${String(p.status ?? '?')} → ${String(p.path ?? '?')}${counts}${sizeKb}`;
|
||||
}
|
||||
case 'memory_snapshot_failed':
|
||||
return `${String(p.status ?? '?')} write failed: ${String(p.error ?? '?')}`;
|
||||
case 'watchdog_fire':
|
||||
return `${String(p.kind2 ?? '')} at iter=${p.iteration ?? '?'}`;
|
||||
case 'followup_detected':
|
||||
return `movement=${String(p.movementName ?? '?')}`;
|
||||
case 'context_action':
|
||||
return `${String(p.type ?? '?')} ratio=${typeof p.ratio === 'number' ? (p.ratio * 100).toFixed(0) + '%' : '?'}`;
|
||||
case 'transition':
|
||||
return `→ ${String(p.nextStep ?? '?')}`;
|
||||
case 'complete':
|
||||
return `${String(p.status ?? '?')}`;
|
||||
case 'movement_start':
|
||||
return `visit ${p.visitCount ?? '?'}/${p.maxVisits ?? '?'}`;
|
||||
case 'movement_complete':
|
||||
return `→ ${String(p.next ?? '?')}`;
|
||||
case 'run_start':
|
||||
return `piece=${String(p.pieceName ?? '?')}`;
|
||||
case 'run_complete': {
|
||||
const cancel = p.cancel as { phase?: string; movement?: string } | undefined;
|
||||
const cancelInfo = cancel?.phase ? ` cancel:${cancel.phase}@${cancel.movement ?? '?'}` : '';
|
||||
const snapshot = p.memorySnapshotPath ? ` snapshot:${String(p.memorySnapshotPath).replace(/^logs\//, '')}` : '';
|
||||
return `${String(p.status ?? '?')}${p.abortReason ? ` (${p.abortReason})` : ''}${cancelInfo}${snapshot}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface TraceTabProps {
|
||||
taskId: number;
|
||||
}
|
||||
|
||||
export function TraceTab({ taskId }: TraceTabProps) {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [enabledCategories, setEnabledCategories] = useState<Set<string>>(
|
||||
new Set(CATEGORIES.map((c) => c.id).concat(['other'])),
|
||||
);
|
||||
const [movementFilter, setMovementFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['trace-events', taskId, refreshKey],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await fetchLocalFileContent(taskId, 'logs', 'events.jsonl');
|
||||
} catch (err) {
|
||||
// events.jsonl が存在しない初回タスクなどは空扱い
|
||||
if (err instanceof Error && /not.*found|404/i.test(err.message)) return '';
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
refetchInterval: 5000, // 自動 5 秒ポーリング
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!data) return { events: [], skipped: 0, unknownVersion: 0 };
|
||||
return parseEventsJsonl(data);
|
||||
}, [data]);
|
||||
|
||||
const movements = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const e of summary.events) if (e.movement) set.add(e.movement);
|
||||
return ['all', ...Array.from(set).sort()];
|
||||
}, [summary.events]);
|
||||
|
||||
/**
|
||||
* Per-tool aggregation: total wall-clock time spent + call count, sorted
|
||||
* by total time descending. Surfaces "XPostDetail consumed 12 min over
|
||||
* 4 calls" at a glance — the kind of thing buried in 500-line event
|
||||
* lists otherwise. Only counts non-cache hits so cached results don't
|
||||
* dilute the signal.
|
||||
*/
|
||||
const toolTimings = useMemo(() => {
|
||||
const stats = new Map<string, { totalMs: number; count: number; errors: number; maxMs: number }>();
|
||||
let llmTotalMs = 0;
|
||||
let llmCount = 0;
|
||||
for (const e of summary.events) {
|
||||
const p = e.payload as Record<string, unknown> | null;
|
||||
if (!p) continue;
|
||||
if (e.kind === 'tool_result' && !p.cacheHit) {
|
||||
const tool = String(p.tool ?? '?');
|
||||
const ms = Number(p.durationMs ?? 0);
|
||||
const cur = stats.get(tool) ?? { totalMs: 0, count: 0, errors: 0, maxMs: 0 };
|
||||
cur.totalMs += ms;
|
||||
cur.count += 1;
|
||||
if (p.isError) cur.errors += 1;
|
||||
if (ms > cur.maxMs) cur.maxMs = ms;
|
||||
stats.set(tool, cur);
|
||||
}
|
||||
if (e.kind === 'llm_call_end') {
|
||||
llmTotalMs += Number(p.durationMs ?? 0);
|
||||
llmCount += 1;
|
||||
}
|
||||
}
|
||||
const tools = Array.from(stats.entries())
|
||||
.map(([tool, s]) => ({ tool, ...s }))
|
||||
.sort((a, b) => b.totalMs - a.totalMs);
|
||||
return { tools, llm: { totalMs: llmTotalMs, count: llmCount } };
|
||||
}, [summary.events]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return summary.events.filter((e) => {
|
||||
const cat = categoryFor(e.kind);
|
||||
if (!enabledCategories.has(cat)) return false;
|
||||
if (movementFilter !== 'all' && e.movement !== movementFilter) return false;
|
||||
if (term) {
|
||||
const haystack = `${e.kind} ${e.movement ?? ''} ${summarizePayload(e)} ${JSON.stringify(e.payload)}`.toLowerCase();
|
||||
if (!haystack.includes(term)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [summary.events, enabledCategories, movementFilter, search]);
|
||||
|
||||
// Group consecutive events by correlationId so tool_call ↔ tool_result are
|
||||
// visually paired without manual interaction.
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Array<{ correlationId?: string; events: TraceEvent[] }> = [];
|
||||
for (const e of filtered) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (e.correlationId && last && last.correlationId === e.correlationId) {
|
||||
last.events.push(e);
|
||||
} else {
|
||||
groups.push({ correlationId: e.correlationId, events: [e] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
// Auto-collapse expansion state when raw data is reloaded from polling.
|
||||
useEffect(() => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const id of prev) {
|
||||
if (summary.events.some((e) => e.eventId === id)) next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [summary.events]);
|
||||
|
||||
function toggleCategory(id: string): void {
|
||||
setEnabledCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpanded(eventId: string): void {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(eventId)) next.delete(eventId);
|
||||
else next.add(eventId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-[13px] text-slate-500 p-4">読み込み中...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-[13px] text-red-600 p-4">エラー: {String(error)}</div>;
|
||||
}
|
||||
|
||||
if (summary.events.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-slate-500 p-4 leading-relaxed">
|
||||
<div className="section-label mb-1.5">no trace yet</div>
|
||||
events.jsonl がまだ存在しません。タスクが少なくとも一度実行されると、engine 内部動作のトレースがここに表示されます。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Filter bar — sticky so it stays visible while scrolling. */}
|
||||
<div className="sticky top-0 z-10 -mx-3 px-3 -mt-3 pt-3 pb-2 bg-surface/95 backdrop-blur border-b border-hairline">
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{CATEGORIES.map((c) => {
|
||||
const on = enabledCategories.has(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggleCategory(c.id)}
|
||||
className={`h-6 px-2 text-[10px] font-medium border rounded transition-colors ${
|
||||
on ? c.tone : 'bg-white text-slate-400 border-hairline hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => toggleCategory('other')}
|
||||
className={`h-6 px-2 text-[10px] font-medium border rounded transition-colors ${
|
||||
enabledCategories.has('other')
|
||||
? 'bg-white text-slate-700 border-hairline'
|
||||
: 'bg-white text-slate-400 border-hairline-soft hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<select
|
||||
value={movementFilter}
|
||||
onChange={(e) => setMovementFilter(e.target.value)}
|
||||
className="h-7 text-2xs border border-hairline rounded-md px-2 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
{movements.map((m) => (
|
||||
<option key={m} value={m}>{m === 'all' ? 'all movements' : m}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5 bg-white border border-hairline rounded-md h-7 px-2 focus-within:ring-2 focus-within:ring-accent-ring">
|
||||
<svg aria-hidden="true" className="w-3 h-3 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="search kind, movement, payload..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="text-2xs flex-1 min-w-0 bg-transparent outline-none text-slate-900 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="h-7 w-7 flex items-center justify-center text-xs border border-hairline rounded-md text-slate-500 bg-white hover:bg-surface transition-colors"
|
||||
title="手動更新(自動 5 秒ごとにも更新されます)"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono tabular-nums mt-1.5">
|
||||
{filtered.length} / {summary.events.length} events
|
||||
{summary.skipped > 0 && <span className="ml-2 text-amber-600">⚠ {summary.skipped} skipped</span>}
|
||||
{summary.unknownVersion > 0 && <span className="ml-2 text-amber-600">⚠ {summary.unknownVersion} unknown version</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool / LLM time aggregation — surfaces "what ate the wall-clock". */}
|
||||
{(toolTimings.tools.length > 0 || toolTimings.llm.count > 0) && (
|
||||
<div className="border border-hairline rounded-md p-2 bg-white">
|
||||
<div className="section-label mb-1.5">time by source</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{toolTimings.llm.count > 0 && (() => {
|
||||
const bar = durationBarStyle(toolTimings.llm.totalMs);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-2xs font-mono">
|
||||
<span className="min-w-[14ch] text-indigo-700 shrink-0">llm × {toolTimings.llm.count}</span>
|
||||
<div className="flex-1 min-w-0 h-2 bg-slate-100 rounded relative overflow-hidden">
|
||||
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
|
||||
</div>
|
||||
<span className="min-w-[6ch] text-right tabular-nums text-slate-700 shrink-0">
|
||||
{formatDurationLabel(toolTimings.llm.totalMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{toolTimings.tools.map(({ tool, totalMs, count, errors, maxMs }) => {
|
||||
const bar = durationBarStyle(totalMs);
|
||||
return (
|
||||
<div key={tool} className="flex items-center gap-2 text-2xs font-mono">
|
||||
<span className={`min-w-[14ch] shrink-0 truncate ${errors > 0 ? 'text-red-700' : 'text-slate-700'}`} title={tool}>
|
||||
{tool} × {count}
|
||||
{errors > 0 ? <span className="text-red-600"> ⚠{errors}</span> : null}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 h-2 bg-slate-100 rounded relative overflow-hidden">
|
||||
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
|
||||
</div>
|
||||
<span className="min-w-[6ch] text-right tabular-nums text-slate-700 shrink-0" title={`max ${formatDurationLabel(maxMs)}`}>
|
||||
{formatDurationLabel(totalMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1.5">
|
||||
総時間 (cache hit 除外、max は個別呼び出しの最大値)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event list */}
|
||||
<div className="flex flex-col">
|
||||
{grouped.map((group, gi) => {
|
||||
const grouped_ = group.correlationId && group.events.length > 1;
|
||||
return (
|
||||
<div key={gi} className={grouped_ ? 'border-l-2 border-hairline pl-2 my-0.5' : ''}>
|
||||
{group.events.map((e) => {
|
||||
const open = expanded.has(e.eventId);
|
||||
const tone = toneFor(e.kind);
|
||||
return (
|
||||
<div key={e.eventId} className={`border rounded-md text-xs mb-1 ${tone}`}>
|
||||
<button
|
||||
onClick={() => toggleExpanded(e.eventId)}
|
||||
className="w-full text-left px-2 py-1.5 flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-[10px] text-slate-500 shrink-0 w-[12ch] tabular-nums">
|
||||
{new Date(e.ts).toLocaleTimeString(undefined, { hour12: false })}
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-2xs shrink-0 min-w-[14ch]">{e.kind}</span>
|
||||
{e.movement && (
|
||||
<span className="font-mono text-[10px] text-slate-500 shrink-0">
|
||||
{e.movement}{typeof e.iteration === 'number' ? `:${e.iteration}` : ''}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 truncate font-mono text-2xs">{summarizePayload(e)}</span>
|
||||
{(() => {
|
||||
// Inline magnitude bar for any event carrying a durationMs.
|
||||
// Visual sort: a 3-minute fetch jumps off the screen even
|
||||
// when scrolled past, so you don't need to read every row.
|
||||
const p = e.payload as Record<string, unknown> | null;
|
||||
const ms = (e.kind === 'tool_result' || e.kind === 'llm_call_end') && p
|
||||
? Number(p.durationMs ?? 0)
|
||||
: 0;
|
||||
if (ms <= 0) return null;
|
||||
const bar = durationBarStyle(ms);
|
||||
return (
|
||||
<div className="w-[60px] h-1.5 bg-slate-100 rounded shrink-0 overflow-hidden" title={`${ms}ms`}>
|
||||
<div className={`${bar.tone} h-full`} style={{ width: `${bar.widthPct}%` }} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<span className="text-slate-400 shrink-0 text-[10px]">{open ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-current/15 px-2 py-1.5 bg-white/60 font-mono text-2xs whitespace-pre overflow-x-auto leading-relaxed">
|
||||
{JSON.stringify(e, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ConnState } from '../../../../hooks/useConsoleSession';
|
||||
import type { ConsoleStatus } from '../../../../lib/ssh-console-types';
|
||||
|
||||
function fmtElapsed(ms: number): string {
|
||||
const total = Math.floor(ms / 1000);
|
||||
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function ConsoleHeader({ state, status }: { state: ConnState; status: ConsoleStatus | null }) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
if (state.kind === 'no_session') {
|
||||
return <div className="px-3 py-2 text-sm text-slate-500">No active console — AI will open one when needed.</div>;
|
||||
}
|
||||
if (state.kind === 'connecting' || state.kind === 'replaying') {
|
||||
return <div className="px-3 py-2 text-sm text-amber-600">{state.kind === 'connecting' ? 'Connecting…' : 'Restoring scrollback…'}</div>;
|
||||
}
|
||||
if (state.kind === 'disconnected') {
|
||||
return <div className="px-3 py-2 text-sm text-red-700">Disconnected ({state.reason ?? 'unknown'}).</div>;
|
||||
}
|
||||
const startedAt = status?.started_at ? new Date(status.started_at).getTime() : now;
|
||||
const lastAt = status?.last_activity_at ? new Date(status.last_activity_at).getTime() : now;
|
||||
return (
|
||||
<div className="px-3 py-2 text-sm text-slate-700 border-b border-slate-200 flex items-center gap-3">
|
||||
<span className="text-green-600">● Connected</span>
|
||||
<span className="text-slate-500">conn {status?.connection_id ?? '—'}</span>
|
||||
<span className="text-slate-500">uptime {fmtElapsed(now - startedAt)}</span>
|
||||
<span className="text-slate-500">idle {fmtElapsed(now - lastAt)}</span>
|
||||
{!state.canWrite && <span className="ml-auto rounded bg-slate-100 px-2 py-0.5 text-xs">viewer (read-only)</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ConsoleSessionApi } from '../../../../hooks/useConsoleSession';
|
||||
import { KEY_BYTES, type KeyId } from './keys';
|
||||
|
||||
interface Props {
|
||||
session: ConsoleSessionApi;
|
||||
}
|
||||
|
||||
const BUTTONS: Array<{ id: KeyId; label: string; ariaLabel: string }> = [
|
||||
{ id: 'esc', label: 'Esc', ariaLabel: 'Esc' },
|
||||
{ id: 'tab', label: 'Tab', ariaLabel: 'Tab' },
|
||||
{ id: 'arrow-left', label: '←', ariaLabel: '左' },
|
||||
{ id: 'arrow-down', label: '↓', ariaLabel: '下' },
|
||||
{ id: 'arrow-up', label: '↑', ariaLabel: '上' },
|
||||
{ id: 'arrow-right', label: '→', ariaLabel: '右' },
|
||||
{ id: 'ctrl-c', label: '^C', ariaLabel: 'Ctrl+C' },
|
||||
];
|
||||
|
||||
export function MobileKeyboardBar({ session }: Props) {
|
||||
const handleKey = (id: KeyId) => {
|
||||
session.send(KEY_BYTES[id]);
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) session.send(text);
|
||||
} catch {
|
||||
/* clipboard API blocked or empty; silent no-op */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="ターミナルキーボード補助"
|
||||
className="flex gap-px bg-slate-900 border-t border-slate-700 px-1 flex-shrink-0"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
{BUTTONS.map((btn) => (
|
||||
<button
|
||||
key={btn.id}
|
||||
type="button"
|
||||
aria-label={btn.ariaLabel}
|
||||
onClick={() => handleKey(btn.id)}
|
||||
className="h-11 flex-1 flex items-center justify-center text-sm font-mono text-slate-200 bg-slate-800 active:bg-slate-700 transition-colors rounded-sm"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ペースト"
|
||||
onClick={handlePaste}
|
||||
className="h-11 flex-1 flex items-center justify-center text-base text-slate-200 bg-slate-800 active:bg-slate-700 transition-colors rounded-sm"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState, type RefObject } from 'react';
|
||||
import type { TerminalViewHandle } from './TerminalView';
|
||||
|
||||
interface Props {
|
||||
terminalRef: RefObject<TerminalViewHandle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the terminal handle to detect scroll-up state and shows a FAB
|
||||
* that scrolls the buffer to the bottom on tap. Polling at 500ms is
|
||||
* cheaper than wiring an xterm onScroll forward through the handle and
|
||||
* accurate enough for human-facing UX.
|
||||
*/
|
||||
export function ScrollToBottomButton({ terminalRef }: Props) {
|
||||
const [scrolledUp, setScrolledUp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setScrolledUp(terminalRef.current?.isScrolledUp() ?? false);
|
||||
}, 500);
|
||||
return () => window.clearInterval(id);
|
||||
}, [terminalRef]);
|
||||
|
||||
if (!scrolledUp) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="最新へスクロール"
|
||||
onClick={() => terminalRef.current?.scrollToBottom()}
|
||||
className="absolute bottom-3 right-3 z-10 w-11 h-11 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center active:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<polyline points="19 12 12 19 5 12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import type { ConsoleSessionApi } from '../../../../hooks/useConsoleSession';
|
||||
|
||||
export interface TerminalViewHandle {
|
||||
scrollToBottom: () => void;
|
||||
isScrolledUp: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm.js wrapper. The xterm instance is created **once** on mount and
|
||||
* disposed on unmount — never re-created when `session` identity changes.
|
||||
* Re-creating xterm would clear the screen, so we keep the latest session
|
||||
* reference in a ref and read from it inside the long-lived event handlers.
|
||||
*
|
||||
* Fit strategy: ResizeObserver on the container is the source of truth.
|
||||
*/
|
||||
export const TerminalView = forwardRef<TerminalViewHandle, { session: ConsoleSessionApi }>(
|
||||
function TerminalView({ session }, ref) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const sessionRef = useRef(session);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
sessionRef.current = session;
|
||||
}, [session]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottom: () => termRef.current?.scrollToBottom(),
|
||||
isScrolledUp: () => {
|
||||
const t = termRef.current;
|
||||
if (!t) return false;
|
||||
return t.buffer.active.viewportY < t.buffer.active.length - t.rows;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
theme: {
|
||||
background: '#0b1020',
|
||||
selectionBackground: '#3b82f6',
|
||||
selectionForeground: '#fff',
|
||||
},
|
||||
scrollback: 5000,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(el);
|
||||
|
||||
const tryFit = () => {
|
||||
if (el.clientWidth < 4 || el.clientHeight < 4) return;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* swallow; ResizeObserver will retry */
|
||||
}
|
||||
};
|
||||
|
||||
const offData = term.onData((d) => sessionRef.current.send(d));
|
||||
const offResize = term.onResize(({ cols, rows }) => sessionRef.current.sendResize(cols, rows));
|
||||
const unsubOutput = sessionRef.current.onOutput((bytes) => term.write(bytes));
|
||||
const offSelection = term.onSelectionChange(() => {
|
||||
const text = term.getSelection();
|
||||
if (!text) return;
|
||||
// Silent failure on non-HTTPS or when clipboard permission is denied.
|
||||
// Don't await — fire-and-forget; selection events fire frequently.
|
||||
navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => tryFit());
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', tryFit);
|
||||
|
||||
requestAnimationFrame(tryFit);
|
||||
|
||||
return () => {
|
||||
offData.dispose();
|
||||
offResize.dispose();
|
||||
offSelection.dispose();
|
||||
unsubOutput();
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', tryFit);
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full bg-[#0b1020]" />;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { KEY_BYTES } from './keys';
|
||||
|
||||
describe('KEY_BYTES', () => {
|
||||
it('Esc は ESC 文字 (0x1b)', () => {
|
||||
expect(KEY_BYTES.esc).toBe('\x1b');
|
||||
});
|
||||
|
||||
it('Tab は HT (0x09)', () => {
|
||||
expect(KEY_BYTES.tab).toBe('\t');
|
||||
});
|
||||
|
||||
it('矢印は CSI シーケンス', () => {
|
||||
expect(KEY_BYTES['arrow-up']).toBe('\x1b[A');
|
||||
expect(KEY_BYTES['arrow-down']).toBe('\x1b[B');
|
||||
expect(KEY_BYTES['arrow-right']).toBe('\x1b[C');
|
||||
expect(KEY_BYTES['arrow-left']).toBe('\x1b[D');
|
||||
});
|
||||
|
||||
it('Ctrl+C は ETX (0x03)', () => {
|
||||
expect(KEY_BYTES['ctrl-c']).toBe('\x03');
|
||||
});
|
||||
|
||||
it('KEY_BYTES は 7 個のキーを持つ (paste は外部 API)', () => {
|
||||
expect(Object.keys(KEY_BYTES)).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const KEY_BYTES = {
|
||||
esc: '\x1b',
|
||||
tab: '\t',
|
||||
'arrow-left': '\x1b[D',
|
||||
'arrow-down': '\x1b[B',
|
||||
'arrow-up': '\x1b[A',
|
||||
'arrow-right': '\x1b[C',
|
||||
'ctrl-c': '\x03',
|
||||
} as const;
|
||||
|
||||
export type KeyId = keyof typeof KEY_BYTES;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AmazonData } from './types';
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
const full = Math.floor(rating);
|
||||
const half = rating - full >= 0.5;
|
||||
const stars: string[] = [];
|
||||
for (let i = 0; i < full; i++) stars.push('\u2605');
|
||||
if (half) stars.push('\u2606');
|
||||
return <span className="text-amber-400" style={{ fontSize: 10 }}>{stars.join('')} {rating.toFixed(1)}</span>;
|
||||
}
|
||||
|
||||
export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExpand: () => void }) {
|
||||
const { query, products } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 my-2 not-prose" style={{ maxWidth: 600 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm">🛒</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>Amazon 検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{products.length}件</span>
|
||||
</div>
|
||||
|
||||
{/* Horizontal scroll cards */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{products.slice(0, 5).map((p) => (
|
||||
<a
|
||||
key={p.asin}
|
||||
href={p.productUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-white border border-slate-200 rounded-lg p-2 cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all flex-shrink-0 no-underline"
|
||||
style={{ minWidth: 160, maxWidth: 160 }}
|
||||
>
|
||||
<div className="w-full h-20 bg-slate-100 rounded flex items-center justify-center mb-2 overflow-hidden">
|
||||
{p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt={p.title} className="max-h-full max-w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-2xl">💾</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-semibold text-slate-800 leading-tight mb-1 line-clamp-2" style={{ fontSize: 11 }}>
|
||||
{p.title}
|
||||
</div>
|
||||
{p.price && (
|
||||
<div className="font-bold text-red-600" style={{ fontSize: 13 }}>{p.price}</div>
|
||||
)}
|
||||
{p.rating != null && (
|
||||
<StarRating rating={p.rating} />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expand button */}
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="text-blue-500 hover:text-blue-700 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 詳細を表示
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { AmazonData } from './types';
|
||||
|
||||
function StarRating({ rating, reviewCount }: { rating: number; reviewCount?: number }) {
|
||||
const full = Math.floor(rating);
|
||||
const half = rating - full >= 0.5;
|
||||
const stars: string[] = [];
|
||||
for (let i = 0; i < full; i++) stars.push('\u2605');
|
||||
if (half) stars.push('\u2606');
|
||||
return (
|
||||
<span className="text-amber-400 text-sm">
|
||||
{stars.join('')} {rating.toFixed(1)}
|
||||
{reviewCount != null && <span className="text-slate-400 text-xs ml-1">({reviewCount.toLocaleString()}件)</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmazonProductsDetail({ data }: { data: AmazonData }) {
|
||||
const { query, products } = data;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
🛒 Amazon 検索結果: 「{query}」
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{products.map((p, i) => (
|
||||
<div key={p.asin} className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex gap-4 flex-col sm:flex-row">
|
||||
{/* Product image */}
|
||||
<div className="w-full sm:w-40 h-40 bg-slate-50 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt={p.title} className="max-h-full max-w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-4xl">💾</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-slate-400 mb-1" style={{ fontSize: 13 }}>#{i + 1}</div>
|
||||
<h3 className="text-sm font-semibold text-slate-800 leading-snug mb-2">{p.title}</h3>
|
||||
|
||||
{p.price && (
|
||||
<div className="text-xl font-bold text-red-600 mb-1">{p.price}</div>
|
||||
)}
|
||||
|
||||
{p.rating != null && (
|
||||
<div className="mb-2">
|
||||
<StarRating rating={p.rating} reviewCount={p.reviewCount} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-slate-400 mb-3">ASIN: {p.asin}</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<a
|
||||
href={p.productUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-amber-400 hover:bg-amber-500 text-slate-900 text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
Amazon で見る
|
||||
</a>
|
||||
<a
|
||||
href={p.keepaDetailUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
Keepa で見る
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keepa price graph */}
|
||||
<div className="mt-4 bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-2">📈 価格推移 (Keepa)</div>
|
||||
<img
|
||||
src={p.keepaGraphUrl}
|
||||
alt={`${p.title} 価格推移`}
|
||||
className="w-full rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { StructuredBlock, AmazonData, MapData, XPostData, YouTubeData } from './types';
|
||||
import { AmazonProductsCard } from './AmazonProductsCard';
|
||||
import { AmazonProductsDetail } from './AmazonProductsDetail';
|
||||
import { MapPlacesCard } from './MapPlacesCard';
|
||||
import { MapPlacesDetail } from './MapPlacesDetail';
|
||||
import { XPostsCard } from './XPostsCard';
|
||||
import { XPostsDetail } from './XPostsDetail';
|
||||
import { YouTubeVideosCard } from './YouTubeVideosCard';
|
||||
import { YouTubeVideosDetail } from './YouTubeVideosDetail';
|
||||
import { EmbedModal } from './EmbedModal';
|
||||
|
||||
async function fetchStructuredBlock(taskId: number, refId: string): Promise<StructuredBlock> {
|
||||
const res = await fetch(`/api/local/tasks/${taskId}/files/raw?section=logs&path=structured/${refId}.json`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch embed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function EmbedBlock({ refId, taskId }: { refId: string; taskId: number }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['embed', taskId, refId],
|
||||
queryFn: () => fetchStructuredBlock(taskId, refId),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 my-2 max-w-[600px] animate-pulse">
|
||||
<div className="h-4 bg-slate-200 rounded w-48 mb-3" />
|
||||
<div className="flex gap-3">
|
||||
<div className="w-40 h-24 bg-slate-200 rounded" />
|
||||
<div className="w-40 h-24 bg-slate-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.type === 'amazon_products' && (
|
||||
<AmazonProductsCard
|
||||
data={data.data as AmazonData}
|
||||
onExpand={() => setModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{data.type === 'map_places' && (
|
||||
<MapPlacesCard
|
||||
data={data.data as MapData}
|
||||
onExpand={() => setModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{data.type === 'x_posts' && (
|
||||
<XPostsCard
|
||||
data={data.data as XPostData}
|
||||
onExpand={() => setModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{data.type === 'youtube_videos' && (
|
||||
<YouTubeVideosCard
|
||||
data={data.data as YouTubeData}
|
||||
onExpand={() => setModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EmbedModal open={modalOpen} onClose={() => setModalOpen(false)}>
|
||||
{data.type === 'amazon_products' && (
|
||||
<AmazonProductsDetail data={data.data as AmazonData} />
|
||||
)}
|
||||
{data.type === 'map_places' && (
|
||||
<MapPlacesDetail data={data.data as MapData} />
|
||||
)}
|
||||
{data.type === 'x_posts' && (
|
||||
<XPostsDetail data={data.data as XPostData} />
|
||||
)}
|
||||
{data.type === 'youtube_videos' && (
|
||||
<YouTubeVideosDetail data={data.data as YouTubeData} />
|
||||
)}
|
||||
</EmbedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface EmbedModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}
|
||||
}, [open, handleKeyDown]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
{/* Modal content */}
|
||||
<div
|
||||
className="
|
||||
relative bg-white overflow-y-auto
|
||||
w-full h-full
|
||||
sm:w-auto sm:h-auto sm:max-w-[720px] sm:max-h-[85vh] sm:min-w-[400px]
|
||||
sm:rounded-2xl sm:shadow-2xl sm:m-4
|
||||
"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="
|
||||
sticky top-0 float-right z-10
|
||||
m-3 w-8 h-8
|
||||
flex items-center justify-center
|
||||
bg-slate-100 hover:bg-slate-200
|
||||
rounded-full text-slate-500 hover:text-slate-700
|
||||
transition-colors cursor-pointer border-none text-lg
|
||||
"
|
||||
aria-label="閉じる"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MapData } from './types';
|
||||
|
||||
export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: () => void }) {
|
||||
const { query, places } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 my-2 not-prose" style={{ maxWidth: 600 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm">📍</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>地図検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{places.length}件</span>
|
||||
</div>
|
||||
|
||||
{/* Place list */}
|
||||
<div className="space-y-1.5">
|
||||
{places.slice(0, 5).map((p, i) => (
|
||||
<div
|
||||
key={`${p.lat}-${p.lon}`}
|
||||
className="flex items-start gap-2 bg-white border border-slate-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-slate-400 font-mono flex-shrink-0" style={{ fontSize: 11 }}>{i + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-slate-800 truncate" style={{ fontSize: 12 }}>{p.name}</div>
|
||||
<div className="text-slate-400 truncate" style={{ fontSize: 11 }}>{p.address}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expand button */}
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="text-blue-500 hover:text-blue-700 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 地図で表示
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { MapData } from './types';
|
||||
|
||||
// Leaflet CDN を動的にロードする
|
||||
let leafletLoaded = false;
|
||||
let leafletLoadPromise: Promise<void> | null = null;
|
||||
|
||||
function loadLeaflet(): Promise<void> {
|
||||
if (leafletLoaded) return Promise.resolve();
|
||||
if (leafletLoadPromise) return leafletLoadPromise;
|
||||
|
||||
leafletLoadPromise = new Promise<void>((resolve, reject) => {
|
||||
// CSS
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/[email protected]/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// JS
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/[email protected]/dist/leaflet.js';
|
||||
script.onload = () => {
|
||||
leafletLoaded = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error('Failed to load Leaflet'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return leafletLoadPromise;
|
||||
}
|
||||
|
||||
declare const L: typeof import('leaflet');
|
||||
|
||||
export function MapPlacesDetail({ data }: { data: MapData }) {
|
||||
const { query, places } = data;
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<import('leaflet').Map | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || places.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
loadLeaflet().then(() => {
|
||||
if (cancelled || !mapRef.current) return;
|
||||
|
||||
// 既存のマップがあれば破棄
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
}
|
||||
|
||||
const center = { lat: places[0]!.lat, lon: places[0]!.lon };
|
||||
const map = L.map(mapRef.current).setView([center.lat, center.lon], 13);
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const markers = places.map((p) =>
|
||||
L.marker([p.lat, p.lon])
|
||||
.addTo(map)
|
||||
.bindPopup(`<b>${p.name}</b><br>${p.address}`),
|
||||
);
|
||||
|
||||
if (markers.length > 1) {
|
||||
const group = L.featureGroup(markers);
|
||||
map.fitBounds(group.getBounds().pad(0.15));
|
||||
}
|
||||
|
||||
// モーダルが開いた直後はサイズが確定していない場合がある
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [places]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
📍 地図検索結果: 「{query}」
|
||||
</h2>
|
||||
|
||||
{/* Leaflet map */}
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{ height: 400 }}
|
||||
className="w-full rounded-xl overflow-hidden border border-slate-200 mb-6"
|
||||
/>
|
||||
|
||||
{/* Place list */}
|
||||
<div className="space-y-4">
|
||||
{places.map((p, i) => (
|
||||
<div key={`${p.lat}-${p.lon}`} className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-slate-400 mb-1" style={{ fontSize: 13 }}>#{i + 1}</div>
|
||||
<h3 className="text-sm font-semibold text-slate-800 leading-snug mb-2">{p.name}</h3>
|
||||
|
||||
<div className="text-xs text-slate-600 space-y-1 mb-3">
|
||||
<div>📍 {p.address}</div>
|
||||
<div>📌 {p.lat.toFixed(6)}, {p.lon.toFixed(6)}</div>
|
||||
{p.type && <div>🏷 {p.type}</div>}
|
||||
{p.details && <div>💬 {p.details}</div>}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={p.mapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
OpenStreetMap で開く
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { XPostData } from './types';
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () => void }) {
|
||||
const { query, posts } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 my-2 not-prose" style={{ maxWidth: 600 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-bold text-slate-800" style={{ fontSize: 14 }}>𝕏</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>X 検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{posts.length}件</span>
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
<div className="space-y-1.5">
|
||||
{posts.slice(0, 5).map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={p.postUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 bg-white border border-slate-200 rounded-lg px-3 py-2 no-underline hover:border-blue-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<img
|
||||
src={p.authorImageUrl}
|
||||
alt={p.authorScreenName}
|
||||
className="rounded-full flex-shrink-0"
|
||||
style={{ width: 24, height: 24 }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold text-slate-800 truncate" style={{ fontSize: 12 }}>{p.authorName}</span>
|
||||
<span className="text-slate-400 flex-shrink-0" style={{ fontSize: 11 }}>@{p.authorScreenName}</span>
|
||||
</div>
|
||||
<div className="text-slate-600 truncate" style={{ fontSize: 11 }}>{p.text.replace(/\n/g, ' ')}</div>
|
||||
<div className="flex gap-3 mt-0.5 text-slate-400" style={{ fontSize: 10 }}>
|
||||
<span>♥ {formatNumber(p.likes)}</span>
|
||||
<span>🔁 {formatNumber(p.retweets)}</span>
|
||||
<span>👁 {formatNumber(p.views)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expand button */}
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="text-blue-500 hover:text-blue-700 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 詳細を表示
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { XPostData } from './types';
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('ja-JP', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function XPostsDetail({ data }: { data: XPostData }) {
|
||||
const { query, posts } = data;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
<span style={{ fontSize: 20 }}>𝕏</span> X 検索結果: 「{query}」
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map((p) => (
|
||||
<div key={p.id} className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
{/* Author header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<img
|
||||
src={p.authorImageUrl}
|
||||
alt={p.authorScreenName}
|
||||
className="rounded-full flex-shrink-0"
|
||||
style={{ width: 40, height: 40 }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-slate-800" style={{ fontSize: 14 }}>{p.authorName}</div>
|
||||
<div className="text-slate-400" style={{ fontSize: 12 }}>@{p.authorScreenName}</div>
|
||||
</div>
|
||||
<div className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post text */}
|
||||
<div className="text-sm text-slate-700 leading-relaxed whitespace-pre-wrap mb-3">
|
||||
{p.text}
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="flex gap-4 text-slate-400 mb-3" style={{ fontSize: 12 }}>
|
||||
<span title="いいね">♥ {formatNumber(p.likes)}</span>
|
||||
<span title="リポスト">🔁 {formatNumber(p.retweets)}</span>
|
||||
<span title="返信">💬 {formatNumber(p.replies)}</span>
|
||||
<span title="表示">👁 {formatNumber(p.views)}</span>
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
<a
|
||||
href={p.postUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-900 hover:bg-slate-700 text-white text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
X で見る
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { YouTubeData } from './types';
|
||||
|
||||
export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExpand: () => void }) {
|
||||
const { query, videos } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 my-2 not-prose" style={{ maxWidth: 600 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm">▶</span>
|
||||
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>YouTube 検索結果: 「{query}」</span>
|
||||
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{videos.length}件</span>
|
||||
</div>
|
||||
|
||||
{/* Horizontal scroll thumbnails */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{videos.slice(0, 5).map((v) => (
|
||||
<a
|
||||
key={v.videoId}
|
||||
href={v.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-white border border-slate-200 rounded-lg overflow-hidden cursor-pointer hover:border-red-300 hover:shadow-sm transition-all flex-shrink-0 no-underline"
|
||||
style={{ minWidth: 200, maxWidth: 200 }}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={v.thumbnailUrl}
|
||||
alt={v.title}
|
||||
className="w-full object-cover"
|
||||
style={{ height: 112 }}
|
||||
loading="lazy"
|
||||
/>
|
||||
{v.duration && (
|
||||
<span
|
||||
className="absolute bottom-1 right-1 bg-black/80 text-white px-1 rounded"
|
||||
style={{ fontSize: 10 }}
|
||||
>
|
||||
{v.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="font-semibold text-slate-800 leading-tight mb-1 line-clamp-2" style={{ fontSize: 11 }}>
|
||||
{v.title}
|
||||
</div>
|
||||
<div className="text-slate-400 truncate" style={{ fontSize: 10 }}>
|
||||
{v.channelName}
|
||||
</div>
|
||||
{v.viewCount && (
|
||||
<div className="text-slate-400" style={{ fontSize: 10 }}>{v.viewCount}</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expand button */}
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="text-blue-500 hover:text-blue-700 cursor-pointer bg-transparent border-none"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
▼ 詳細を表示
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { YouTubeData } from './types';
|
||||
|
||||
export function YouTubeVideosDetail({ data }: { data: YouTubeData }) {
|
||||
const { query, videos } = data;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
||||
▶ YouTube 検索結果: 「{query}」
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{videos.map((v, i) => (
|
||||
<div key={v.videoId} className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex gap-4 flex-col sm:flex-row">
|
||||
{/* Thumbnail */}
|
||||
<a
|
||||
href={v.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative flex-shrink-0 no-underline"
|
||||
>
|
||||
<img
|
||||
src={v.thumbnailUrl}
|
||||
alt={v.title}
|
||||
className="rounded-lg object-cover"
|
||||
style={{ width: 240, height: 135 }}
|
||||
loading="lazy"
|
||||
/>
|
||||
{v.duration && (
|
||||
<span
|
||||
className="absolute bottom-2 right-2 bg-black/80 text-white px-1.5 py-0.5 rounded"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{v.duration}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-slate-400 mb-1" style={{ fontSize: 13 }}>#{i + 1}</div>
|
||||
<h3 className="text-sm font-semibold text-slate-800 leading-snug mb-2">{v.title}</h3>
|
||||
<div className="text-xs text-slate-500 mb-1">{v.channelName}</div>
|
||||
<div className="flex gap-3 text-xs text-slate-400 mb-2">
|
||||
{v.viewCount && <span>👁 {v.viewCount}</span>}
|
||||
{v.publishedAt && <span>📅 {v.publishedAt}</span>}
|
||||
</div>
|
||||
{v.description && (
|
||||
<div className="text-xs text-slate-500 leading-relaxed mb-3">{v.description}</div>
|
||||
)}
|
||||
<a
|
||||
href={v.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-semibold rounded-lg no-underline transition-colors"
|
||||
>
|
||||
YouTube で見る
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type BlockType = 'amazon_products' | 'map_places' | 'x_posts' | 'youtube_videos';
|
||||
|
||||
export interface StructuredBlock {
|
||||
refId: string;
|
||||
type: BlockType;
|
||||
title: string;
|
||||
data: AmazonData | MapData | XPostData | YouTubeData;
|
||||
}
|
||||
|
||||
export interface AmazonData {
|
||||
query: string;
|
||||
products: AmazonProduct[];
|
||||
}
|
||||
|
||||
export interface AmazonProduct {
|
||||
asin: string;
|
||||
title: string;
|
||||
price?: string;
|
||||
rating?: number;
|
||||
reviewCount?: number;
|
||||
imageUrl?: string;
|
||||
productUrl: string;
|
||||
keepaGraphUrl: string;
|
||||
keepaDetailUrl: string;
|
||||
}
|
||||
|
||||
export interface MapPlaceItem {
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
type: string;
|
||||
details: string;
|
||||
mapUrl: string;
|
||||
}
|
||||
|
||||
export interface MapData {
|
||||
query: string;
|
||||
places: MapPlaceItem[];
|
||||
}
|
||||
|
||||
export interface XPostItem {
|
||||
id: string;
|
||||
text: string;
|
||||
authorName: string;
|
||||
authorScreenName: string;
|
||||
authorImageUrl: string;
|
||||
likes: number;
|
||||
retweets: number;
|
||||
replies: number;
|
||||
views: number;
|
||||
createdAt: string;
|
||||
postUrl: string;
|
||||
}
|
||||
|
||||
export interface XPostData {
|
||||
query: string;
|
||||
posts: XPostItem[];
|
||||
}
|
||||
|
||||
export interface YouTubeVideoItem {
|
||||
videoId: string;
|
||||
title: string;
|
||||
channelName: string;
|
||||
thumbnailUrl: string;
|
||||
videoUrl: string;
|
||||
viewCount: string;
|
||||
publishedAt: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface YouTubeData {
|
||||
query: string;
|
||||
videos: YouTubeVideoItem[];
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
|
||||
import { isPreviewable, formatFileDate } from '../../lib/utils';
|
||||
|
||||
interface FileBrowserProps {
|
||||
section: 'workspace' | 'input' | 'output' | 'logs';
|
||||
currentPath: string;
|
||||
entries: LocalFileEntry[];
|
||||
pathSegments: string[];
|
||||
taskId?: number;
|
||||
onSectionChange: (section: 'workspace' | 'input' | 'output' | 'logs') => void;
|
||||
onNavigate: (path: string) => void;
|
||||
onPreview: (path: string, name: string) => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
type FileSort = 'name' | 'newest';
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: FileSort; label: string }> = [
|
||||
{ value: 'name', label: '名前順' },
|
||||
{ value: 'newest', label: '新しい順' },
|
||||
];
|
||||
|
||||
function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSort) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const current = SORT_OPTIONS.find(o => o.value === sort) ?? SORT_OPTIONS[0];
|
||||
|
||||
const handleSelect = (value: FileSort) => {
|
||||
onChange(value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={`並び順: ${current.label}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={`並び順: ${current.label}`}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:text-slate-900 hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-[calc(100%+6px)] z-10 bg-white border border-hairline rounded-md shadow min-w-[140px] p-1">
|
||||
{SORT_OPTIONS.map(o => {
|
||||
const selected = sort === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(o.value)}
|
||||
className={`flex items-center justify-between w-full px-2.5 py-1.5 rounded text-xs text-left transition-colors ${
|
||||
selected
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 font-medium hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
{selected && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: LocalFileEntry[], mode: FileSort): LocalFileEntry[] {
|
||||
const dirs = entries.filter(e => e.kind === 'directory');
|
||||
const files = entries.filter(e => e.kind !== 'directory');
|
||||
const sortFn = mode === 'newest'
|
||||
? (a: LocalFileEntry, b: LocalFileEntry) => {
|
||||
// Files: by modifiedAt desc. Directories: same when timestamps exist,
|
||||
// else fall back to name so the order is stable.
|
||||
const at = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
|
||||
const bt = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
|
||||
if (at !== bt) return bt - at;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
: (a: LocalFileEntry, b: LocalFileEntry) => a.name.localeCompare(b.name);
|
||||
return [...dirs.sort(sortFn), ...files.sort(sortFn)];
|
||||
}
|
||||
|
||||
export function FileBrowser({
|
||||
section,
|
||||
currentPath,
|
||||
entries,
|
||||
pathSegments,
|
||||
taskId,
|
||||
onSectionChange,
|
||||
onNavigate,
|
||||
onPreview,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: FileBrowserProps) {
|
||||
const SECTIONS = ['workspace', 'input', 'output', 'logs'] as const;
|
||||
const [sort, setSort] = useState<FileSort>('name');
|
||||
const sortedEntries = useMemo(() => sortEntries(entries, sort), [entries, sort]);
|
||||
|
||||
// Icon-only action buttons. Replaces the wider "Preview" / "DL" / "Open"
|
||||
// text buttons that were squeezing long filenames before. Sized 32px for
|
||||
// finger-friendly tap targets on iPhone (still compact on desktop).
|
||||
const iconBtn = 'w-8 h-8 flex items-center justify-center rounded-md border border-hairline bg-white text-slate-500 hover:text-slate-900 hover:bg-surface transition-colors';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { onSectionChange(s); onNavigate(''); }}
|
||||
className={`px-2 h-7 rounded text-2xs font-medium border transition-colors ${
|
||||
section === s
|
||||
? 'border-accent/60 bg-accent-soft text-accent font-semibold'
|
||||
: 'border-hairline bg-white text-slate-600 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={`ml-auto ${iconBtn} disabled:opacity-50`}
|
||||
title="ファイル一覧を更新"
|
||||
aria-label="ファイル一覧を更新"
|
||||
>
|
||||
<svg className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
|
||||
<path d="M12 2v3h-3M4 14v-3h3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-2xs text-slate-500 font-mono break-all min-w-0 flex-1 pt-1">
|
||||
/{section}{currentPath ? `/${currentPath}` : ''}
|
||||
</div>
|
||||
<FileSortMenu sort={sort} onChange={setSort} />
|
||||
</div>
|
||||
|
||||
{pathSegments.length > 0 && (
|
||||
<button
|
||||
onClick={() => onNavigate(pathSegments.slice(0, -1).join('/'))}
|
||||
className="self-start inline-flex items-center gap-1 px-2 h-7 rounded border border-hairline bg-white text-2xs text-slate-600 hover:bg-surface transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 4l-4 4 4 4M6 8h6" />
|
||||
</svg>
|
||||
Up
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{sortedEntries.map(entry => (
|
||||
<div
|
||||
key={`${entry.kind}:${entry.path}`}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-white border border-hairline hover:bg-surface transition-colors"
|
||||
>
|
||||
<span className="text-slate-400 flex-shrink-0" aria-hidden="true">
|
||||
{entry.kind === 'directory' ? (
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
||||
<path d="M9 2v4.5h4.5" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
|
||||
{entry.kind === 'file' && entry.modifiedAt && (
|
||||
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{entry.kind === 'directory' ? (
|
||||
<button
|
||||
onClick={() => onNavigate(entry.path)}
|
||||
className={iconBtn}
|
||||
title="Open folder"
|
||||
aria-label="Open folder"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{isPreviewable(entry.name) && (
|
||||
<button
|
||||
onClick={() => onPreview(entry.path, entry.name)}
|
||||
className={iconBtn}
|
||||
title="Preview"
|
||||
aria-label="Preview"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1.5 8s2.5-5 6.5-5 6.5 5 6.5 5-2.5 5-6.5 5-6.5-5-6.5-5z" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{taskId != null && (
|
||||
<a
|
||||
href={getLocalFileRawUrl(taskId, section, entry.path)}
|
||||
download={entry.name}
|
||||
className={iconBtn}
|
||||
title="Download"
|
||||
aria-label="Download"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<div className="text-xs text-slate-500 px-1 py-2">ファイルなし</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marked, Renderer } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import mermaid from 'mermaid';
|
||||
import hljs from 'highlight.js';
|
||||
import { updateLocalFileContent } from '../../api';
|
||||
import { EmbedBlock } from '../embed/EmbedBlock';
|
||||
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||
|
||||
// --- MDXG outline helpers ---
|
||||
interface OutlineEntry { depth: 1 | 2 | 3; text: string; slug: string; }
|
||||
|
||||
function slugify(text: string): string {
|
||||
const cleaned = text
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/[^\w\--ヿ一-鿿]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned || 'section';
|
||||
}
|
||||
|
||||
function buildSlugger() {
|
||||
const counts = new Map<string, number>();
|
||||
return (text: string): string => {
|
||||
const base = slugify(text);
|
||||
const n = counts.get(base) ?? 0;
|
||||
counts.set(base, n + 1);
|
||||
return n === 0 ? base : `${base}-${n}`;
|
||||
};
|
||||
}
|
||||
|
||||
function extractOutline(content: string, parser: Marked): OutlineEntry[] {
|
||||
const tokens = parser.lexer(content);
|
||||
const headings: OutlineEntry[] = [];
|
||||
const slugger = buildSlugger();
|
||||
for (const t of tokens) {
|
||||
if (t.type === 'heading') {
|
||||
const depth = (t as { depth?: number }).depth;
|
||||
const text = (t as { text?: string }).text ?? '';
|
||||
if (depth === 1 || depth === 2 || depth === 3) {
|
||||
headings.push({ depth, text, slug: slugger(text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
name: string;
|
||||
content: string;
|
||||
imageSrc: string;
|
||||
/** Markdown 内の相対パス画像を解決するためのベース URL (省略可) */
|
||||
markdownImageBaseUrl?: string;
|
||||
onClose: () => void;
|
||||
taskId?: number;
|
||||
section?: string;
|
||||
filePath?: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
// --- CSV ---
|
||||
function renderCsv(csv: string) {
|
||||
const rows = csv.trim().split(/\r?\n/).map(r => r.split(','));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<tbody>
|
||||
{rows.slice(0, 120).map((r, i) => (
|
||||
<tr key={i}>
|
||||
{r.slice(0, 20).map((c, j) => (
|
||||
<td key={j} className={`border border-slate-200 px-2 py-1 ${i === 0 ? 'bg-slate-100 font-bold' : 'bg-white'}`}>
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Markdown (marked) ---
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string }): Renderer {
|
||||
const { imageBaseUrl, slugger } = opts;
|
||||
const renderer = new Renderer();
|
||||
renderer.link = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
// Markdown link whose destination is an `output/...` workspace
|
||||
// path: route the click through OutputPreviewProvider's
|
||||
// delegation instead of opening a new tab. Visible text is the
|
||||
// markdown label.
|
||||
const isOutputHref = OUTPUT_PATH_REGEX.test(href);
|
||||
OUTPUT_PATH_REGEX.lastIndex = 0;
|
||||
if (isOutputHref) {
|
||||
return `<a class="output-path-link" data-output-path="${href.replace(/"/g, '"')}"${titleAttr} role="button" tabindex="0">${text}</a>`;
|
||||
}
|
||||
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
};
|
||||
// Bare `output/...` paths in paragraph / list / blockquote text.
|
||||
// Marked passes pre-escaped HTML strings here, so the linkifier
|
||||
// runs on safe content.
|
||||
//
|
||||
// CRITICAL: when the text token has nested inline children (strong,
|
||||
// em, codespan, link), `tokens` is set and we MUST defer to
|
||||
// parser.parseInline. Otherwise the inline formatting is silently
|
||||
// dropped — `- **bold** \`code\`` would render as literal `**bold**
|
||||
// \`code\`` instead of formatted.
|
||||
renderer.text = function ({ tokens, text }: { tokens?: unknown[]; text: string }) {
|
||||
if (tokens && tokens.length > 0) {
|
||||
const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } };
|
||||
return self.parser.parseInline(tokens);
|
||||
}
|
||||
return linkifyOutputPathsInEscapedHtml(text);
|
||||
};
|
||||
// Inline `output/foo.md` in single-backtick spans. Fenced code
|
||||
// blocks go through `renderer.code` (below) which deliberately
|
||||
// doesn't linkify — fenced code is usually a copy-paste sample,
|
||||
// not a real reference.
|
||||
renderer.codespan = function ({ text }: { text: string }) {
|
||||
return `<code>${linkifyOutputPathsInEscapedHtml(text)}</code>`;
|
||||
};
|
||||
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
|
||||
if (lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
let highlighted: string;
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(text).value;
|
||||
}
|
||||
} catch {
|
||||
highlighted = escapeHtml(text);
|
||||
}
|
||||
const langLabel = lang ? `<span class="mdxg-lang">${escapeHtml(lang)}</span>` : '';
|
||||
const langClass = lang ? `language-${escapeHtml(lang)}` : '';
|
||||
return `<pre>${langLabel}<button class="mdxg-copy" data-copy="1" type="button" aria-label="copy">copy</button><code class="hljs ${langClass}">${highlighted}</code></pre>`;
|
||||
};
|
||||
if (slugger) {
|
||||
renderer.heading = function ({ tokens, depth, text }: { tokens: unknown[]; depth: number; text: string }) {
|
||||
const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } };
|
||||
const inner = self.parser.parseInline(tokens);
|
||||
if (depth > 3) {
|
||||
return `<h${depth}>${inner}</h${depth}>`;
|
||||
}
|
||||
const slug = slugger(text);
|
||||
return `<h${depth} id="${slug}"><a class="mdxg-anchor" href="#${slug}" aria-hidden="true">#</a>${inner}</h${depth}>`;
|
||||
};
|
||||
}
|
||||
if (imageBaseUrl) {
|
||||
renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
|
||||
let resolvedHref = href;
|
||||
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
|
||||
const cleanPath = href.replace(/^\.\//, '');
|
||||
resolvedHref = `${imageBaseUrl}${encodeURIComponent(cleanPath)}`;
|
||||
}
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
|
||||
};
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/** embed マーカーでコンテンツを分割する */
|
||||
const EMBED_SPLIT_RE = /\[\[embed:([\w-]+)\]\]/g;
|
||||
|
||||
interface ContentSegment {
|
||||
type: 'markdown' | 'embed';
|
||||
value: string; // markdown: テキスト, embed: refId
|
||||
}
|
||||
|
||||
function splitContentByEmbeds(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = EMBED_SPLIT_RE.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'markdown', value: content.slice(lastIndex, match.index) });
|
||||
}
|
||||
segments.push({ type: 'embed', value: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({ type: 'markdown', value: content.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** 単一の Markdown 断片をレンダリングする内部コンポーネント */
|
||||
function MarkdownSegment({ html }: { html: string }): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
mermaid.run({ nodes: ref.current.querySelectorAll('.mermaid') }).catch(() => {});
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
return <div ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
const DOMPURIFY_CONFIG = {
|
||||
// `data-output-path`, `role`, `tabindex` added for the output-path
|
||||
// linkifier — defaults already permit `data-*`, but being explicit
|
||||
// guards against future config tightening, same as MarkdownText.
|
||||
ADD_ATTR: ['data-copy', 'aria-hidden', 'aria-label', 'id', 'target', 'rel', 'data-output-path', 'role', 'tabindex'] as string[],
|
||||
};
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
content: string;
|
||||
imageBaseUrl?: string;
|
||||
taskId?: number;
|
||||
/** true で目次サイドバー + リーダースタイル (MDXG) を有効化。チャット吹き出し等では false 推奨。 */
|
||||
showOutline?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element {
|
||||
const truncated = content.slice(0, 100000);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSlug, setActiveSlug] = useState<string>('');
|
||||
|
||||
// 目次抽出 (showOutline=true のときのみ)
|
||||
const outline = useMemo<OutlineEntry[]>(() => {
|
||||
if (!showOutline) return [];
|
||||
try {
|
||||
const parser = new Marked({ gfm: true });
|
||||
return extractOutline(truncated, parser);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [truncated, showOutline]);
|
||||
|
||||
// HTML 生成
|
||||
const segments = useMemo(() => {
|
||||
EMBED_SPLIT_RE.lastIndex = 0;
|
||||
const slugger = showOutline ? buildSlugger() : undefined;
|
||||
const renderer = buildMdRenderer({ imageBaseUrl, slugger });
|
||||
const parser = new Marked({ gfm: true, renderer });
|
||||
|
||||
const hasEmbed = taskId != null && EMBED_SPLIT_RE.test(truncated);
|
||||
EMBED_SPLIT_RE.lastIndex = 0;
|
||||
if (!hasEmbed) {
|
||||
const html = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
return [{ type: 'markdown' as const, html }];
|
||||
}
|
||||
return splitContentByEmbeds(truncated).map(seg => {
|
||||
if (seg.type === 'embed') return { type: 'embed' as const, refId: seg.value };
|
||||
const html = DOMPurify.sanitize(parser.parse(seg.value, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
return { type: 'markdown' as const, html };
|
||||
});
|
||||
}, [truncated, imageBaseUrl, taskId, showOutline]);
|
||||
|
||||
// コピーボタン + アンカーリンクのイベント delegation
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest<HTMLButtonElement>('button.mdxg-copy');
|
||||
if (btn) {
|
||||
const pre = btn.closest('pre');
|
||||
const code = pre?.querySelector('code');
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.textContent ?? '').then(() => {
|
||||
btn.classList.add('copied');
|
||||
const original = btn.textContent;
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = original ?? 'copy';
|
||||
}, 1200);
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const anchor = target.closest<HTMLAnchorElement>('a.mdxg-anchor');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
const id = anchor.getAttribute('href')?.slice(1);
|
||||
if (id) {
|
||||
root.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
root.addEventListener('click', handler);
|
||||
return () => root.removeEventListener('click', handler);
|
||||
}, [segments]);
|
||||
|
||||
// scroll-spy: 表示中の見出しを active に
|
||||
useEffect(() => {
|
||||
if (!showOutline || outline.length === 0) return;
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const targets = Array.from(root.querySelectorAll<HTMLElement>('h1[id], h2[id], h3[id]'));
|
||||
if (targets.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
const visible = entries.filter(e => e.isIntersecting);
|
||||
if (visible.length > 0) {
|
||||
visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
setActiveSlug(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -70% 0px', threshold: 0 }
|
||||
);
|
||||
targets.forEach(t => observer.observe(t));
|
||||
if (!activeSlug && targets[0]) setActiveSlug(targets[0].id);
|
||||
return () => observer.disconnect();
|
||||
}, [segments, showOutline, outline.length]);
|
||||
|
||||
const handleOutlineClick = (slug: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const root = containerRef.current;
|
||||
root?.querySelector(`#${CSS.escape(slug)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const content_el = (
|
||||
<div ref={containerRef} className={`${showOutline ? 'prose prose-slate max-w-none mdxg-reader' : 'prose prose-sm max-w-none'} min-w-0 break-words [&_a]:[overflow-wrap:anywhere] [&_a]:break-all [&_code]:[overflow-wrap:anywhere] [&_pre]:max-w-full [&_pre]:overflow-x-auto`}>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'embed') {
|
||||
return <EmbedBlock key={`embed-${seg.refId}-${i}`} refId={seg.refId} taskId={taskId!} />;
|
||||
}
|
||||
return <MarkdownSegment key={`md-${i}`} html={seg.html} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showOutline || outline.length < 2) {
|
||||
return content_el;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-start">
|
||||
<aside className="mdxg-outline hidden md:block flex-shrink-0 sticky top-0 max-h-[68vh] overflow-y-auto pr-2 border-r border-hairline" style={{ width: '200px' }}>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide px-2 py-1.5">目次</div>
|
||||
<nav>
|
||||
{outline.map(h => (
|
||||
<a
|
||||
key={h.slug}
|
||||
href={`#${h.slug}`}
|
||||
onClick={handleOutlineClick(h.slug)}
|
||||
className={`depth-${h.depth} ${activeSlug === h.slug ? 'active' : ''}`}
|
||||
title={h.text}
|
||||
>
|
||||
{h.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0">{content_el}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Print / PDF helpers ---
|
||||
const PRINT_STYLE = `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
|
||||
color: #1e293b;
|
||||
line-height: 1.7;
|
||||
max-width: 760px;
|
||||
margin: 32px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { color: #0f172a; font-weight: 600; margin-top: 1.6em; margin-bottom: 0.5em; line-height: 1.3; }
|
||||
h1 { font-size: 1.9em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.2em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
h4 { font-size: 1.05em; }
|
||||
p, ul, ol, blockquote { margin: 0.7em 0; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
li { margin: 0.2em 0; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a.mdxg-anchor { display: none; }
|
||||
a.output-path-link { color: #2563eb; cursor: text; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #f1f5f9; padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }
|
||||
pre { background: #f8fafc; color: #0f172a; padding: 14px 16px; border-radius: 6px; overflow-x: auto; font-size: 0.85em; border: 1px solid #e2e8f0; position: relative; white-space: pre-wrap; word-break: break-word; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; font-size: 1em; }
|
||||
pre .mdxg-copy, pre .mdxg-lang { display: none; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.9em; }
|
||||
th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #f8fafc; font-weight: 600; }
|
||||
blockquote { border-left: 3px solid #cbd5e1; margin: 1em 0; padding: 0.3em 1em; color: #475569; background: #f8fafc; }
|
||||
img { max-width: 100%; height: auto; display: block; margin: 0.5em 0; }
|
||||
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 1.5em 0; }
|
||||
.mermaid-rendered { margin: 1em 0; text-align: center; }
|
||||
.mermaid-rendered svg { max-width: 100%; height: auto; }
|
||||
.embed-placeholder { border: 1px dashed #cbd5e1; background: #f8fafc; color: #64748b; padding: 8px 12px; border-radius: 6px; margin: 0.7em 0; font-size: 0.85em; }
|
||||
/* hljs minimal light theme */
|
||||
.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
|
||||
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #d73a49; }
|
||||
.hljs-function .hljs-keyword { color: #d73a49; }
|
||||
.hljs-subst { color: #24292e; }
|
||||
.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-template-variable { color: #032f62; }
|
||||
.hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #6f42c1; }
|
||||
.hljs-number, .hljs-meta { color: #005cc5; }
|
||||
.hljs-built_in, .hljs-builtin-name, .hljs-class .hljs-title { color: #e36209; }
|
||||
.hljs-deletion { color: #b31d28; background: #ffeef0; }
|
||||
.hljs-regexp, .hljs-link { color: #032f62; }
|
||||
@media print {
|
||||
body { margin: 0; padding: 12mm; max-width: 100%; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a.output-path-link { color: inherit; }
|
||||
pre { background: #f8fafc !important; border: 1px solid #e2e8f0; }
|
||||
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; }
|
||||
pre, table, blockquote, .mermaid-rendered, img { page-break-inside: avoid; break-inside: avoid; }
|
||||
}
|
||||
`;
|
||||
|
||||
const EMBED_MARKER_RE = /\[\[embed:([\w-]+)\]\]/g;
|
||||
|
||||
async function buildPrintHtml(content: string, opts: { title: string; imageBaseUrl?: string }): Promise<string> {
|
||||
const truncated = content.slice(0, 100000);
|
||||
const slugger = buildSlugger();
|
||||
const absoluteImageBaseUrl = opts.imageBaseUrl
|
||||
? (opts.imageBaseUrl.startsWith('http') ? opts.imageBaseUrl : `${window.location.origin}${opts.imageBaseUrl}`)
|
||||
: undefined;
|
||||
const renderer = buildMdRenderer({ imageBaseUrl: absoluteImageBaseUrl, slugger });
|
||||
const parser = new Marked({ gfm: true, renderer });
|
||||
const renderedHtml = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
|
||||
// Replace [[embed:xxx]] markers with placeholders BEFORE parsing into DOM
|
||||
// (marked passes them through as literal text inside paragraphs).
|
||||
EMBED_MARKER_RE.lastIndex = 0;
|
||||
const withEmbedPlaceholders = renderedHtml.replace(
|
||||
EMBED_MARKER_RE,
|
||||
(_, refId) => `<div class="embed-placeholder">📎 Embedded: <code>${escapeHtml(String(refId))}</code></div>`,
|
||||
);
|
||||
|
||||
// Parse to a temporary DOM so we can swap mermaid <pre> blocks for rendered SVG.
|
||||
const doc = new DOMParser().parseFromString(`<body>${withEmbedPlaceholders}</body>`, 'text/html');
|
||||
const mermaidBlocks = Array.from(doc.body.querySelectorAll<HTMLPreElement>('pre.mermaid'));
|
||||
for (let i = 0; i < mermaidBlocks.length; i++) {
|
||||
const block = mermaidBlocks[i];
|
||||
const source = (block.textContent ?? '').trim();
|
||||
if (!source) continue;
|
||||
try {
|
||||
const id = `mermaid-print-${Date.now()}-${i}`;
|
||||
const { svg } = await mermaid.render(id, source);
|
||||
const wrapper = doc.createElement('div');
|
||||
wrapper.className = 'mermaid-rendered';
|
||||
wrapper.innerHTML = svg;
|
||||
block.replaceWith(wrapper);
|
||||
} catch {
|
||||
// Leave the original <pre> in place if render fails.
|
||||
}
|
||||
}
|
||||
|
||||
const bodyHtml = doc.body.innerHTML;
|
||||
const safeTitle = escapeHtml(opts.title);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${safeTitle}</title>
|
||||
<style>${PRINT_STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
<script>
|
||||
(function () {
|
||||
function triggerPrint() {
|
||||
try { window.focus(); } catch (e) {}
|
||||
setTimeout(function () { window.print(); }, 150);
|
||||
}
|
||||
function waitForImages(done) {
|
||||
var imgs = Array.prototype.slice.call(document.images);
|
||||
if (imgs.length === 0) { done(); return; }
|
||||
var remaining = imgs.length;
|
||||
var settled = false;
|
||||
function finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
done();
|
||||
}
|
||||
imgs.forEach(function (img) {
|
||||
if (img.complete) {
|
||||
remaining--;
|
||||
if (remaining === 0) finish();
|
||||
return;
|
||||
}
|
||||
var onDone = function () {
|
||||
remaining--;
|
||||
if (remaining === 0) finish();
|
||||
};
|
||||
img.addEventListener('load', onDone, { once: true });
|
||||
img.addEventListener('error', onDone, { once: true });
|
||||
});
|
||||
setTimeout(finish, 3000);
|
||||
}
|
||||
function ready() { waitForImages(triggerPrint); }
|
||||
if (document.readyState === 'complete') ready();
|
||||
else window.addEventListener('load', ready);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// --- JSONL ---
|
||||
function badgeClass(color: 'green' | 'orange' | 'red' | 'gray'): string {
|
||||
if (color === 'green') return 'bg-green-100 text-green-800';
|
||||
if (color === 'orange') return 'bg-amber-100 text-amber-800';
|
||||
if (color === 'red') return 'bg-red-100 text-red-800';
|
||||
return 'bg-slate-100 text-slate-600';
|
||||
}
|
||||
|
||||
function outcomeColor(value: string): 'green' | 'orange' | 'red' | 'gray' {
|
||||
if (value === 'success') return 'green';
|
||||
if (value === 'ssrf_blocked' || value === 'pdf_blocked' || value === 'binary_blocked') return 'orange';
|
||||
if (value === 'error' || value === 'http_error' || value === 'invalid_url') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
function formatCell(key: string, value: unknown): ReactNode {
|
||||
if (value === null || value === undefined) return <span className="text-slate-300">—</span>;
|
||||
|
||||
if (key === 'url' && typeof value === 'string') {
|
||||
return (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 underline break-all max-w-[300px] block">
|
||||
{value.length > 60 ? `${value.slice(0, 60)}…` : value}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'timestamp' && typeof value === 'string') {
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch { return String(value); }
|
||||
}
|
||||
|
||||
if (key === 'outcome' && typeof value === 'string') {
|
||||
const color = outcomeColor(value);
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{value}</span>;
|
||||
}
|
||||
|
||||
if (key === 'status') {
|
||||
if (typeof value === 'string') {
|
||||
const color: 'green' | 'red' | 'gray' = value === 'success' ? 'green' : value === 'error' ? 'red' : 'gray';
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{value}</span>;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
const color: 'green' | 'red' | 'gray' = value >= 200 && value < 300 ? 'green' : value >= 400 ? 'red' : 'gray';
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'exitCode') {
|
||||
const color: 'green' | 'red' = value === 0 ? 'green' : 'red';
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) return value.join(' ');
|
||||
|
||||
const str = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return str.length > 80 ? `${str.slice(0, 80)}…` : str;
|
||||
}
|
||||
|
||||
function renderJsonl(content: string): JSX.Element {
|
||||
const lines = content.trim().split('\n').filter(Boolean).slice(0, 1000);
|
||||
const records: Record<string, unknown>[] = [];
|
||||
for (const line of lines) {
|
||||
try { records.push(JSON.parse(line) as Record<string, unknown>); }
|
||||
catch { /* skip invalid lines */ }
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return <p className="text-slate-400 text-sm">表示できるレコードがありません</p>;
|
||||
}
|
||||
|
||||
const columns = [...new Set(records.flatMap(r => Object.keys(r)))];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-100">
|
||||
{columns.map(col => (
|
||||
<th key={col} className="px-3 py-2 text-left text-xs font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}>
|
||||
{columns.map(col => (
|
||||
<td key={col} className="px-3 py-1.5 border-b border-slate-100 align-top">
|
||||
{formatCell(col, record[col])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- FilePreview ---
|
||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [currentContent, setCurrentContent] = useState(content);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
const canEdit = editable && taskId != null && section && filePath;
|
||||
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (printing) return;
|
||||
setPrinting(true);
|
||||
setError('');
|
||||
try {
|
||||
const html = await buildPrintHtml(currentContent, { title: name, imageBaseUrl: markdownImageBaseUrl });
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) {
|
||||
setError('印刷ウィンドウを開けませんでした。ポップアップブロックを解除してください。');
|
||||
return;
|
||||
}
|
||||
win.document.open();
|
||||
win.document.write(html);
|
||||
win.document.close();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '印刷の準備に失敗しました');
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!taskId || !section || !filePath) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await updateLocalFileContent(taskId, section, filePath, editContent);
|
||||
setCurrentContent(editContent);
|
||||
setMode('view');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const body = (() => {
|
||||
if (mode === 'edit') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
className="w-full min-h-[60vh] font-mono text-xs p-3 border border-hairline rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-accent-ring transition-shadow"
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-red-600 text-xs">{error}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => { setMode('view'); setError(''); }}
|
||||
className="px-3 h-8 text-xs rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 h-8 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// view mode
|
||||
if (imageSrc) {
|
||||
if (/\.html?$/i.test(name)) {
|
||||
return (
|
||||
<iframe
|
||||
src={imageSrc}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="w-full rounded-lg border-0"
|
||||
style={{ height: '72vh' }}
|
||||
title={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (/\.pdf$/i.test(name)) {
|
||||
return (
|
||||
<embed
|
||||
src={imageSrc}
|
||||
type="application/pdf"
|
||||
className="w-full rounded-lg"
|
||||
style={{ height: '72vh' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<img src={imageSrc} alt={name} className="max-w-full max-h-[72vh] object-contain rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (/\.(md|markdown)$/i.test(name)) return <MarkdownPreview content={currentContent} imageBaseUrl={markdownImageBaseUrl} showOutline taskId={taskId} />;
|
||||
if (/\.csv$/i.test(name)) return renderCsv(currentContent);
|
||||
if (/\.jsonl$/i.test(name)) return renderJsonl(currentContent);
|
||||
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
|
||||
})();
|
||||
|
||||
const isMarkdown = /\.(md|markdown)$/i.test(name);
|
||||
const modalWidth = isMarkdown ? 'min(1400px, 96vw)' : 'min(1000px, 94vw)';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-md border border-hairline shadow-md flex flex-col overflow-hidden"
|
||||
style={{ width: modalWidth, maxHeight: 'min(90vh, calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px))' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-white z-10 gap-2">
|
||||
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && mode === 'view' && (
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={printing}
|
||||
title="ブラウザの印刷ダイアログから PDF として保存または印刷"
|
||||
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 5V2h8v3M4 11H2.5A1.5 1.5 0 0 1 1 9.5v-3A1.5 1.5 0 0 1 2.5 5h11A1.5 1.5 0 0 1 15 6.5v3a1.5 1.5 0 0 1-1.5 1.5H12M4 9.5h8v4.5H4z" />
|
||||
</svg>
|
||||
{printing ? '準備中...' : 'PDF / 印刷'}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && mode === 'view' && (
|
||||
<button
|
||||
onClick={() => { setEditContent(currentContent); setMode('edit'); }}
|
||||
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11.5 2.5l2 2L5 13l-2.5.5L3 11l8.5-8.5z" />
|
||||
</svg>
|
||||
編集
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors"
|
||||
aria-label="プレビューを閉じる"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
{mode === 'view' && error && (
|
||||
<div className="mb-2 px-3 py-2 bg-red-50 border border-red-200 text-red-700 text-xs rounded">{error}</div>
|
||||
)}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
|
||||
export interface NavItem {
|
||||
id: PageId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
visibleNav: NavItem[];
|
||||
currentPage: PageId;
|
||||
onNavigate: (page: PageId) => void;
|
||||
appName: string;
|
||||
logoUrl: string | null;
|
||||
returnFocusRef?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
const ICON_PROPS = {
|
||||
width: 22,
|
||||
height: 22,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.7,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
'aria-hidden': true,
|
||||
};
|
||||
|
||||
const NAV_ICONS: Record<PageId, ReactNode> = {
|
||||
tasks: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<line x1="8" y1="6" x2="20" y2="6" />
|
||||
<line x1="8" y1="12" x2="20" y2="12" />
|
||||
<line x1="8" y1="18" x2="20" y2="18" />
|
||||
<circle cx="4" cy="6" r="1.4" />
|
||||
<circle cx="4" cy="12" r="1.4" />
|
||||
<circle cx="4" cy="18" r="1.4" />
|
||||
</svg>
|
||||
),
|
||||
schedules: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polyline points="12 7 12 12 15 14" />
|
||||
</svg>
|
||||
),
|
||||
pieces: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M19 11h-4V7a2 2 0 0 0-4 0H7a2 2 0 0 0-2 2v4h4a2 2 0 0 1 0 4H5v4a2 2 0 0 0 2 2h4v-2a2 2 0 0 1 4 0v2h4a2 2 0 0 0 2-2v-4a2 2 0 0 1 0-4Z" />
|
||||
</svg>
|
||||
),
|
||||
captcha: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.9" />
|
||||
<path d="M16 3.1a4 4 0 0 1 0 7.8" />
|
||||
</svg>
|
||||
),
|
||||
help: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12" y2="17.01" />
|
||||
</svg>
|
||||
),
|
||||
userfolder: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function NavDrawer({
|
||||
open,
|
||||
onClose,
|
||||
visibleNav,
|
||||
currentPage,
|
||||
onNavigate,
|
||||
appName,
|
||||
logoUrl,
|
||||
returnFocusRef,
|
||||
}: NavDrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
(firstItemRef.current ?? panelRef.current)?.focus();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
returnFocusRef?.current?.focus();
|
||||
};
|
||||
}, [open, returnFocusRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.documentElement.style.overflow;
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.documentElement.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const onPanelKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !panelRef.current) return;
|
||||
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
id="nav-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ナビゲーション"
|
||||
aria-hidden={!open}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onPanelKeyDown}
|
||||
{...(!open && { inert: '' })}
|
||||
className={`fixed left-0 top-0 bottom-0 z-50 w-[min(280px,80vw)] bg-white shadow-xl flex flex-col motion-safe:transition-transform duration-200 ease-out ${
|
||||
open ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-hairline">
|
||||
<img
|
||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt=""
|
||||
className="h-6 w-auto max-w-[120px] object-contain"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-semibold tracking-tight text-slate-900 truncate">
|
||||
{appName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 flex-shrink-0">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2" aria-label="メインナビゲーション">
|
||||
{visibleNav.map((item, idx) => {
|
||||
const active = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
ref={idx === 0 ? firstItemRef : undefined}
|
||||
onClick={() => {
|
||||
onNavigate(item.id);
|
||||
onClose();
|
||||
}}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`w-full h-12 px-4 flex items-center gap-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-ring ${
|
||||
active
|
||||
? 'font-semibold text-accent bg-accent-soft'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// ui/src/components/layout/ResizeHandle.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ResizeHandleProps {
|
||||
/** drag 中に呼ばれる。新しい chatPx を渡す。ref-based で React 再 render しない想定。 */
|
||||
onResize: (chatPx: number) => void;
|
||||
/** drag 終了時に 1 度だけ呼ばれる。localStorage 保存用。 */
|
||||
onResizeEnd: (chatPx: number) => void;
|
||||
/** ダブルクリックでリセット。 */
|
||||
onReset: () => void;
|
||||
railPx: number;
|
||||
minChatPx: number;
|
||||
minWorkspacePx: number;
|
||||
handlePx: number;
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
onReset,
|
||||
railPx,
|
||||
minChatPx,
|
||||
minWorkspacePx,
|
||||
handlePx,
|
||||
}: ResizeHandleProps) {
|
||||
// latest-ref pattern: callback が render 毎に新しくなっても useEffect の
|
||||
// listener を付け直さずに済む。これが無いと drag 中に listener が外れる。
|
||||
const onResizeRef = useRef(onResize);
|
||||
const onResizeEndRef = useRef(onResizeEnd);
|
||||
onResizeRef.current = onResize;
|
||||
onResizeEndRef.current = onResizeEnd;
|
||||
|
||||
const draggingRef = useRef(false);
|
||||
const lastChatPxRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const grid = document.querySelector<HTMLElement>('[data-focused-grid="1"]');
|
||||
if (!grid) return;
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const maxChatPx = rect.width - railPx - handlePx - minWorkspacePx;
|
||||
const raw = e.clientX - rect.left - railPx;
|
||||
const chatPx = Math.max(minChatPx, Math.min(maxChatPx, raw));
|
||||
lastChatPxRef.current = chatPx;
|
||||
onResizeRef.current(chatPx);
|
||||
};
|
||||
const handleUp = () => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
if (lastChatPxRef.current !== null) {
|
||||
onResizeEndRef.current(lastChatPxRef.current);
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
window.addEventListener('pointercancel', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
window.removeEventListener('pointercancel', handleUp);
|
||||
};
|
||||
}, [railPx, handlePx, minChatPx, minWorkspacePx]);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Chat と Workspace の幅を調整"
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={onReset}
|
||||
className="cursor-col-resize bg-transparent hover:bg-slate-300/60 transition-colors flex items-stretch group"
|
||||
style={{ width: handlePx, touchAction: 'none' }}
|
||||
>
|
||||
<div className="w-px bg-hairline mx-auto group-hover:bg-slate-500/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
import type { AuthUser } from '../../App';
|
||||
|
||||
interface TopBarProps {
|
||||
currentPage: PageId;
|
||||
onNavigate: (page: PageId) => void;
|
||||
isAdmin?: boolean;
|
||||
authEnabled?: boolean;
|
||||
user?: AuthUser | null;
|
||||
appName?: string;
|
||||
logoUrl?: string | null;
|
||||
onOpenDrawer: () => void;
|
||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
navDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
|
||||
{ id: 'help', label: 'ヘルプ', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'userfolder', label: 'ユーザーフォルダ', adminOnly: false, requiresAuth: false },
|
||||
];
|
||||
|
||||
export function estimateCollapseThreshold(navCount: number): number {
|
||||
return 430 + navCount * 78 + 60;
|
||||
}
|
||||
|
||||
export function useViewportNarrow(threshold: number): boolean {
|
||||
const [narrow, setNarrow] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.innerWidth < threshold : false,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const update = () => setNarrow(window.innerWidth < threshold);
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [threshold]);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
|
||||
return NAV_ITEMS.filter(item => {
|
||||
if (item.adminOnly && !isAdmin) return false;
|
||||
if (item.requiresAuth && !authEnabled) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
|
||||
const visible = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
return useViewportNarrow(estimateCollapseThreshold(visible.length));
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
isAdmin = true,
|
||||
authEnabled = false,
|
||||
user = null,
|
||||
appName = 'MAESTRO',
|
||||
logoUrl = null,
|
||||
onOpenDrawer,
|
||||
hamburgerButtonRef,
|
||||
navDrawerOpen = false,
|
||||
}: TopBarProps) {
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 bg-white border-b border-hairline px-4 flex items-center"
|
||||
style={{
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
|
||||
<div className="flex items-center gap-4 min-w-0 self-stretch">
|
||||
{compactMode && (
|
||||
<button
|
||||
ref={hamburgerButtonRef}
|
||||
type="button"
|
||||
onClick={onOpenDrawer}
|
||||
aria-label="メニューを開く"
|
||||
aria-expanded={navDrawerOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="nav-drawer"
|
||||
className="-ml-2 flex items-center justify-center w-11 h-11 rounded-md text-slate-700 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring transition-colors"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt=""
|
||||
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
|
||||
/>
|
||||
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
|
||||
{appName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
|
||||
{!compactMode && (
|
||||
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label="メインナビゲーション">
|
||||
{visibleNav.map(item => {
|
||||
const active = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
active
|
||||
? 'font-semibold text-slate-900 border-accent'
|
||||
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{user && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.name ?? user.email}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-surface-2 text-slate-700 flex items-center justify-center text-2xs font-semibold uppercase">
|
||||
{(user.name ?? user.email).charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-slate-600 hidden md:inline max-w-[120px] truncate">
|
||||
{user.name ?? user.email}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||
>
|
||||
ログアウト
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** drag 中に呼ばれる。upperPct (0..100) を渡す。 */
|
||||
onResize: (upperPct: number) => void;
|
||||
/** drag 終了時に 1 度だけ呼ばれる。localStorage 保存用。 */
|
||||
onResizeEnd: (upperPct: number) => void;
|
||||
/** double-click でリセット。 */
|
||||
onReset?: () => void;
|
||||
/** 上下のパネルを含む親要素を識別する data-* selector。 */
|
||||
parentSelector: string;
|
||||
minUpperPct?: number;
|
||||
minLowerPct?: number;
|
||||
}
|
||||
|
||||
export function VerticalResizeHandle({
|
||||
onResize, onResizeEnd, onReset, parentSelector,
|
||||
minUpperPct = 20, minLowerPct = 15,
|
||||
}: Props) {
|
||||
const onResizeRef = useRef(onResize);
|
||||
const onResizeEndRef = useRef(onResizeEnd);
|
||||
onResizeRef.current = onResize;
|
||||
onResizeEndRef.current = onResizeEnd;
|
||||
|
||||
const draggingRef = useRef(false);
|
||||
const lastPctRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const parent = document.querySelector<HTMLElement>(parentSelector);
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const raw = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
const clamped = Math.max(minUpperPct, Math.min(100 - minLowerPct, raw));
|
||||
lastPctRef.current = clamped;
|
||||
onResizeRef.current(clamped);
|
||||
};
|
||||
const handleUp = () => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
if (lastPctRef.current !== null) onResizeEndRef.current(lastPctRef.current);
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
window.addEventListener('pointercancel', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
window.removeEventListener('pointercancel', handleUp);
|
||||
};
|
||||
}, [parentSelector, minUpperPct, minLowerPct]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="タスクリストと情報パネルの高さを調整"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}}
|
||||
onDoubleClick={onReset}
|
||||
className="cursor-row-resize bg-transparent hover:bg-slate-300/60 transition-colors flex justify-center group"
|
||||
style={{ height: 6, touchAction: 'none' }}
|
||||
>
|
||||
<div className="h-px self-center bg-hairline w-full group-hover:bg-slate-500/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { COLUMN_LIST, COLUMN_LABELS, SortMode, StatusColumn } from '../../lib/urlState';
|
||||
|
||||
interface FilterBarProps {
|
||||
selectedStatus: 'all' | StatusColumn;
|
||||
sortMode: SortMode;
|
||||
searchQuery: string;
|
||||
counts: Record<string, number>;
|
||||
totalCount: number;
|
||||
onStatusChange: (status: 'all' | StatusColumn) => void;
|
||||
onSortChange: (sort: SortMode) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
|
||||
{ value: 'updated', label: '新しい順' },
|
||||
{ value: 'status', label: 'ステータス順' },
|
||||
{ value: 'title', label: 'タイトル順' },
|
||||
];
|
||||
|
||||
function SortMenu({
|
||||
sortMode,
|
||||
onSortChange,
|
||||
}: {
|
||||
sortMode: SortMode;
|
||||
onSortChange: (sort: SortMode) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const current = SORT_OPTIONS.find(o => o.value === sortMode) ?? SORT_OPTIONS[0];
|
||||
|
||||
const handleSelect = (value: SortMode) => {
|
||||
onSortChange(value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={`並び順: ${current.label}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={`並び順: ${current.label}`}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-[calc(100%+6px)] z-10 bg-white border border-hairline rounded-md shadow min-w-[160px] p-1">
|
||||
{SORT_OPTIONS.map(o => {
|
||||
const selected = sortMode === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(o.value)}
|
||||
className={`flex items-center justify-between w-full px-2.5 py-1.5 rounded text-xs text-left transition-colors ${
|
||||
selected
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 font-medium hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
{selected && (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
selectedStatus,
|
||||
sortMode,
|
||||
searchQuery,
|
||||
counts,
|
||||
totalCount,
|
||||
onStatusChange,
|
||||
onSortChange,
|
||||
onSearchChange,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pb-3 border-b border-hairline">
|
||||
<div className="flex items-center gap-1.5 bg-white border border-hairline rounded-md pl-2.5 pr-1 h-8">
|
||||
<svg aria-hidden="true" className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
aria-label="検索"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder="検索..."
|
||||
className="flex-1 bg-transparent border-0 outline-none text-[13px] text-slate-900 placeholder:text-slate-400 min-w-0"
|
||||
/>
|
||||
<div aria-hidden="true" className="w-px h-4 bg-hairline flex-shrink-0" />
|
||||
<SortMenu sortMode={sortMode} onSortChange={onSortChange} />
|
||||
</div>
|
||||
|
||||
<div role="tablist" aria-label="ステータスフィルター" className="flex gap-1 overflow-x-auto pb-1 scrollbar-none">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={selectedStatus === 'all'}
|
||||
onClick={() => onStatusChange('all')}
|
||||
className={`flex-shrink-0 px-2 h-7 rounded text-2xs font-medium border transition-colors whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
selectedStatus === 'all'
|
||||
? 'border-accent/60 bg-accent-soft text-accent font-semibold'
|
||||
: 'border-hairline bg-white text-slate-600 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
すべて <span className="text-slate-400 ml-0.5 font-mono tabular-nums">{totalCount}</span>
|
||||
</button>
|
||||
{COLUMN_LIST.map(status => (
|
||||
<button
|
||||
key={status}
|
||||
role="tab"
|
||||
aria-selected={selectedStatus === status}
|
||||
onClick={() => onStatusChange(status)}
|
||||
className={`flex-shrink-0 px-2 h-7 rounded text-2xs font-medium border transition-colors whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
selectedStatus === status
|
||||
? 'border-accent/60 bg-accent-soft text-accent font-semibold'
|
||||
: 'border-hairline bg-white text-slate-600 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{COLUMN_LABELS[status]} <span className="text-slate-400 ml-0.5 font-mono tabular-nums">{counts[status] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { LocalTask } from '../../api';
|
||||
|
||||
interface RailPanelProps {
|
||||
tasks: LocalTask[];
|
||||
activeTaskId: number | null;
|
||||
onSelectTask: (id: number) => void;
|
||||
onOpenCreate: () => void;
|
||||
onExitFocused: () => void;
|
||||
}
|
||||
|
||||
function statusDotClass(status: string | undefined): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'dispatching':
|
||||
return 'bg-accent animate-pulse';
|
||||
case 'queued':
|
||||
case 'retry':
|
||||
return 'bg-slate-400';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'bg-red-500';
|
||||
case 'succeeded':
|
||||
return 'bg-green-500';
|
||||
case 'waiting_human':
|
||||
case 'waiting_subtasks':
|
||||
return 'bg-amber-500';
|
||||
default:
|
||||
return 'bg-slate-300';
|
||||
}
|
||||
}
|
||||
|
||||
export function RailPanel({
|
||||
tasks,
|
||||
activeTaskId,
|
||||
onSelectTask,
|
||||
onOpenCreate,
|
||||
onExitFocused,
|
||||
}: RailPanelProps) {
|
||||
// bg/border は App.tsx の grid cell 側に持たせる (list mode と同じ責務分割)。
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<button
|
||||
onClick={onOpenCreate}
|
||||
title="新規タスクを作成"
|
||||
aria-label="新規タスクを作成"
|
||||
className="flex-shrink-0 flex items-center justify-center h-10 border-b border-hairline hover:bg-surface transition-colors text-slate-600"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M8 3v10M3 8h10" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{tasks.map(task => {
|
||||
const status = task.latestJob?.status;
|
||||
const isActive = task.id === activeTaskId;
|
||||
const idLabel = String(task.id).slice(-2);
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
title={task.title || `Task #${task.id}`}
|
||||
className={`relative w-full flex items-center justify-center h-10 border-b border-hairline transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent/10 text-accent ring-1 ring-inset ring-accent/40'
|
||||
: 'text-slate-600 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-1 left-1 w-2 h-2 rounded-full ${statusDotClass(status)}`} />
|
||||
<span className="font-mono text-[11px]">{idLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={onExitFocused}
|
||||
title="リスト表示に戻る"
|
||||
aria-label="標準表示に戻る"
|
||||
className="flex-shrink-0 flex items-center justify-center h-10 border-t border-hairline hover:bg-surface transition-colors text-slate-500"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M3 6l5 5 5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { memo } from 'react';
|
||||
import { LocalTask } from '../../api';
|
||||
import { relativeTime, statusTone, formatStatusLabel } from '../../lib/utils';
|
||||
|
||||
interface LocalTaskListItemProps {
|
||||
task: LocalTask;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const LocalTaskListItem = memo(function LocalTaskListItem({ task, active, onClick }: LocalTaskListItemProps) {
|
||||
const status = task.latestJob?.status ?? 'queued';
|
||||
const tone = statusTone(status);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-md border transition-colors ${
|
||||
active
|
||||
? 'border-accent/60 bg-accent-soft'
|
||||
: 'border-hairline bg-white hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] font-mono text-slate-400 tabular-nums">#{task.id}</span>
|
||||
<span className="text-[13px] font-semibold text-slate-900 truncate">{task.title}</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1.5">
|
||||
{task.subtaskCount != null && task.subtaskCount > 0 && (
|
||||
<span className="text-[10px] font-mono text-slate-400 tabular-nums">
|
||||
{task.subtaskCompleted ?? 0}/{task.subtaskCount}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium border"
|
||||
style={{ background: tone.bg, color: tone.fg, borderColor: 'transparent' }}
|
||||
>
|
||||
{formatStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-slate-500 truncate leading-snug">
|
||||
{task.body.length > 80 ? `${task.body.slice(0, 80)}…` : task.body}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[10px]">
|
||||
<span className="font-mono text-slate-400 tabular-nums">{relativeTime(task.updatedAt)}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
{task.ownerId ? (
|
||||
<span className="text-slate-600">{task.ownerName ?? 'user'}</span>
|
||||
) : (
|
||||
<span className="text-slate-400">system</span>
|
||||
)}
|
||||
{task.visibility === 'private' && (
|
||||
<span className="px-1 rounded text-[10px] font-medium bg-amber-50 text-amber-700 border border-amber-100" title="Private">private</span>
|
||||
)}
|
||||
{task.visibility === 'org' && (
|
||||
<span className="px-1 rounded text-[10px] font-medium bg-blue-50 text-blue-700 border border-blue-100" title={`Shared with ${task.visibilityScopeOrgName ?? 'org'}`}>
|
||||
{task.visibilityScopeOrgName ?? 'org'}
|
||||
</span>
|
||||
)}
|
||||
{task.visibility === 'public' && (
|
||||
<span className="px-1 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-100" title="Public">public</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { LocalTask } from '../../api';
|
||||
import { matchText } from '../../lib/utils';
|
||||
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { LocalTaskListItem } from './TaskListItem';
|
||||
import { RailPanel } from './RailPanel';
|
||||
|
||||
interface TaskListPanelProps {
|
||||
localTasks: LocalTask[];
|
||||
selectedStatus: 'all' | StatusColumn;
|
||||
sortMode: SortMode;
|
||||
searchQuery: string;
|
||||
activeTaskId: number | null;
|
||||
onStatusChange: (status: 'all' | StatusColumn) => void;
|
||||
onSortChange: (sort: SortMode) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
onSelectTask: (id: number) => void;
|
||||
onOpenCreate: () => void;
|
||||
/** 'rail' 時は RailPanel を render する。default 'list'。 */
|
||||
mode?: 'list' | 'rail';
|
||||
/** rail mode 時の「リストに戻る」ボタンで呼ばれる。 */
|
||||
onExitFocused?: () => void;
|
||||
}
|
||||
|
||||
export function TaskListPanel({
|
||||
localTasks,
|
||||
selectedStatus,
|
||||
sortMode,
|
||||
searchQuery,
|
||||
activeTaskId,
|
||||
onStatusChange,
|
||||
onSortChange,
|
||||
onSearchChange,
|
||||
onSelectTask,
|
||||
onOpenCreate,
|
||||
mode = 'list',
|
||||
onExitFocused,
|
||||
}: TaskListPanelProps) {
|
||||
if (mode === 'rail') {
|
||||
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
||||
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
||||
return acc;
|
||||
}, {} as Record<string, LocalTask[]>);
|
||||
const allRail = Object.values(localColumnsRail).flat();
|
||||
const baseRail = selectedStatus === 'all' ? allRail : localColumnsRail[selectedStatus] ?? [];
|
||||
const filteredRail = baseRail.filter(t =>
|
||||
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery),
|
||||
);
|
||||
return (
|
||||
<RailPanel
|
||||
tasks={filteredRail}
|
||||
activeTaskId={activeTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
onOpenCreate={onOpenCreate}
|
||||
onExitFocused={onExitFocused ?? (() => {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const localColumns: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
||||
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
||||
return acc;
|
||||
}, {} as Record<string, LocalTask[]>);
|
||||
|
||||
const allLocalTasks = Object.values(localColumns).flat();
|
||||
|
||||
const baseList: LocalTask[] =
|
||||
selectedStatus === 'all' ? allLocalTasks : localColumns[selectedStatus] ?? [];
|
||||
|
||||
const filtered = baseList.filter(t =>
|
||||
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery)
|
||||
).sort((a, b) => {
|
||||
if (sortMode === 'title') {
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
if (sortMode === 'status') {
|
||||
const aStatus = a.latestJob?.status ?? 'queued';
|
||||
const bStatus = b.latestJob?.status ?? 'queued';
|
||||
return aStatus.localeCompare(bStatus);
|
||||
}
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of COLUMN_LIST) {
|
||||
counts[s] = localColumns[s]?.length ?? 0;
|
||||
}
|
||||
const totalCount = allLocalTasks.length;
|
||||
const runningCount = counts.running ?? 0;
|
||||
const waitingCount = (counts.waiting_human ?? 0) + (counts.waiting_subtasks ?? 0);
|
||||
const failedCount = counts.failed ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreate}
|
||||
className="w-full mb-3 px-3 py-2 bg-accent hover:bg-accent-deep active:scale-[0.98] active:bg-accent-deep text-accent-fg rounded-md text-xs font-semibold inline-flex items-center justify-center gap-1.5 transition-[transform,background-color,color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新しい依頼
|
||||
</button>
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-500 px-0.5 pb-2.5 font-mono tabular-nums">
|
||||
<span><span className="font-semibold text-slate-700">{totalCount}</span> 件</span>
|
||||
<span aria-hidden="true" className="text-slate-300">·</span>
|
||||
<span><span className="font-semibold text-emerald-600">{runningCount}</span> 実行中</span>
|
||||
<span><span className="font-semibold text-amber-600">{waitingCount}</span> 待機中</span>
|
||||
{failedCount > 0 && (
|
||||
<span><span className="font-semibold text-red-600">{failedCount}</span> 失敗</span>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar
|
||||
selectedStatus={selectedStatus}
|
||||
sortMode={sortMode}
|
||||
searchQuery={searchQuery}
|
||||
counts={counts}
|
||||
totalCount={totalCount}
|
||||
onStatusChange={onStatusChange}
|
||||
onSortChange={onSortChange}
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5 mt-2 overflow-y-auto flex-1 min-h-0 pr-0.5">
|
||||
{filtered.map(task => (
|
||||
<LocalTaskListItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
active={activeTaskId === task.id}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-[13px] text-slate-500 px-2 py-3">スレッドがありません</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { extractLatestToolName, petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
import { PetSprite } from './PetSprite';
|
||||
import { ToolSpark } from './ToolSpark';
|
||||
|
||||
const JUMP_DURATION_MS = 1500;
|
||||
const DONE_FLOURISH_MS = 1000;
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReduced(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
export function ChatPetOverlay({
|
||||
taskId,
|
||||
taskStatus,
|
||||
currentActivity,
|
||||
workerId,
|
||||
lastBackendId,
|
||||
className,
|
||||
}: {
|
||||
taskId: number | null;
|
||||
taskStatus: string | null;
|
||||
currentActivity: string | null;
|
||||
workerId: string | null;
|
||||
/**
|
||||
* Physical backend id when the worker is a proxy (LiteLLM deployment
|
||||
* name from `x-litellm-model-id`). Falls through to workerId mapping
|
||||
* if unset or unmapped. Phase A: passed in from latestJob.lastBackendId.
|
||||
*/
|
||||
lastBackendId?: string | null;
|
||||
/** Extra classes appended to the overlay wrapper. Used to gate
|
||||
* visibility per breakpoint when multiple instances render (e.g.,
|
||||
* one inside ChatPane for tablet+, another at app level for mobile). */
|
||||
className?: string;
|
||||
}) {
|
||||
const { data, isLoading } = useActivePet(workerId, lastBackendId);
|
||||
const framesPerRow = usePetFrameAnalysis(
|
||||
data?.spriteUrl ?? null,
|
||||
data?.gridCols ?? null,
|
||||
data?.gridRows ?? null,
|
||||
);
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
// Phase C: when the job hasn't reported `running` yet (e.g. fresh
|
||||
// queued state, or status SSE hasn't caught up) but the backend
|
||||
// node is actually busy on our work, surface the running animation
|
||||
// anyway. Prefer the proxy-backend mapping over the worker mapping
|
||||
// — same precedence as useActivePet uses for sprite selection.
|
||||
const nodeAnimState = useNodeAnimationState(lastBackendId ?? workerId ?? null);
|
||||
|
||||
const taskBaseState = petStateFromJobStatus(taskStatus, taskId);
|
||||
// Promote an 'idle' base state to 'running' when the backing node is
|
||||
// actively processing. Don't override informative states like
|
||||
// 'dispatching', 'waiting', 'done', or 'error' — those carry signal
|
||||
// that node.busy doesn't.
|
||||
const baseState: PetRuntimeState = taskBaseState === 'idle' && nodeAnimState === 'running'
|
||||
? 'running'
|
||||
: taskBaseState;
|
||||
const baseStateRef = useRef(baseState);
|
||||
baseStateRef.current = baseState;
|
||||
|
||||
const [displayState, setDisplayState] = useState<PetRuntimeState>('idle');
|
||||
|
||||
// Reset display to base state whenever it changes; brief 'done' flourish then
|
||||
// settle to idle so the wave doesn't loop forever.
|
||||
useEffect(() => {
|
||||
setDisplayState(baseState);
|
||||
if (baseState !== 'done') return;
|
||||
const timer = window.setTimeout(() => setDisplayState('idle'), DONE_FLOURISH_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [baseState]);
|
||||
|
||||
// When the current activity changes (= a new tool fired) during active
|
||||
// execution, jump for ~1.5s and revert to whatever the base state is by then.
|
||||
useEffect(() => {
|
||||
if (!currentActivity) return;
|
||||
const active = baseStateRef.current;
|
||||
if (active !== 'running' && active !== 'runningAlt' && active !== 'dispatching') return;
|
||||
setDisplayState('jumping');
|
||||
const timer = window.setTimeout(() => {
|
||||
const current = baseStateRef.current;
|
||||
if (current === 'running' || current === 'runningAlt' || current === 'dispatching') {
|
||||
setDisplayState(current);
|
||||
}
|
||||
// For other base states (done / error / idle / waiting) the dedicated
|
||||
// effects above will have taken over; don't fight them here.
|
||||
}, JUMP_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentActivity]);
|
||||
|
||||
const toolName = useMemo(
|
||||
() => extractLatestToolName(currentActivity),
|
||||
[currentActivity],
|
||||
);
|
||||
|
||||
if (isLoading || !data?.settings.enabled || !data.pet) return null;
|
||||
|
||||
const reducedMotion = data.settings.reducedMotion || prefersReducedMotion;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className ? `chat-pet-overlay ${className}` : 'chat-pet-overlay'}
|
||||
style={{ ['--pet-size' as string]: `${data.settings.size}px` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ToolSpark
|
||||
toolName={toolName}
|
||||
activityKey={currentActivity}
|
||||
enabled={data.settings.toolSparkEnabled}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
<PetSprite
|
||||
name={data.pet.name}
|
||||
imageUrl={data.imageUrl}
|
||||
frameWidth={data.frameWidth}
|
||||
frameHeight={data.frameHeight}
|
||||
gridCols={data.gridCols}
|
||||
gridRows={data.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state={displayState}
|
||||
size={data.settings.size}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
|
||||
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
|
||||
idle: '1.2s',
|
||||
running: '0.55s',
|
||||
runningAlt: '0.55s',
|
||||
dispatching: '0.6s',
|
||||
jumping: '0.5s',
|
||||
waiting: '1.4s',
|
||||
done: '0.8s',
|
||||
error: '0.55s',
|
||||
};
|
||||
|
||||
export function PetSprite({
|
||||
name,
|
||||
imageUrl,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
gridCols,
|
||||
gridRows,
|
||||
framesPerRow,
|
||||
state,
|
||||
size,
|
||||
reducedMotion,
|
||||
}: {
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
frameWidth: number | null;
|
||||
frameHeight: number | null;
|
||||
gridCols: number | null;
|
||||
gridRows: number | null;
|
||||
framesPerRow: number[] | null;
|
||||
state: PetRuntimeState;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
const className = [
|
||||
'pet-sprite',
|
||||
`pet-sprite-${state}`,
|
||||
reducedMotion ? 'pet-sprite-reduced' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const useGridCrop = !!(imageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
|
||||
const useFrameCrop = !useGridCrop && !!(imageUrl && frameWidth && frameHeight);
|
||||
|
||||
const stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
|
||||
const bgPosY = useGridCrop && gridRows! > 1
|
||||
? `${(stateRow / (gridRows! - 1)) * 100}%`
|
||||
: '0%';
|
||||
|
||||
const detectedFrames = framesPerRow?.[stateRow];
|
||||
const rowFrameCount = Math.max(1, Math.min(8, detectedFrames ?? gridCols ?? 1));
|
||||
const cycleAnimation = useGridCrop && !reducedMotion && rowFrameCount > 1
|
||||
? `petFrameCycle${rowFrameCount} ${STATE_FRAME_DURATION[state]} linear infinite`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
title={name}
|
||||
>
|
||||
{imageUrl ? (
|
||||
useGridCrop ? (
|
||||
<div
|
||||
className="pet-sprite-grid"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
|
||||
backgroundPositionY: bgPosY,
|
||||
animation: cycleAnimation,
|
||||
imageRendering: 'auto',
|
||||
}}
|
||||
/>
|
||||
) : useFrameCrop ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
objectFit: 'none',
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${size / frameWidth!})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img src={imageUrl} alt="" draggable={false} />
|
||||
)
|
||||
) : (
|
||||
<div className="pet-sprite-fallback">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { iconKindForTool, type ToolIconKind } from '../../lib/pets/toolIconMap';
|
||||
|
||||
function ToolIcon({ kind }: { kind: ToolIconKind }) {
|
||||
if (kind === 'search') {
|
||||
return <path d="M10.5 17a6.5 6.5 0 1 1 4.6-1.9L20 20" />;
|
||||
}
|
||||
if (kind === 'terminal') {
|
||||
return <path d="m5 7 4 4-4 4M11 17h8" />;
|
||||
}
|
||||
if (kind === 'file') {
|
||||
return <path d="M7 3h7l4 4v14H7zM14 3v5h5" />;
|
||||
}
|
||||
if (kind === 'edit') {
|
||||
return <path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16zM13 6l4 4" />;
|
||||
}
|
||||
if (kind === 'browser') {
|
||||
return <path d="M4 6h16v12H4zM4 9h16M7 7.5h.1M10 7.5h.1" />;
|
||||
}
|
||||
if (kind === 'issue') {
|
||||
return <path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 8v5M12 16h.1" />;
|
||||
}
|
||||
if (kind === 'plug') {
|
||||
return <path d="M9 7V3M15 7V3M7 7h10v4a5 5 0 0 1-10 0zM12 16v5" />;
|
||||
}
|
||||
return <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" />;
|
||||
}
|
||||
|
||||
// 4-point star particle path centered at (12, 12), radius ~8
|
||||
const STAR_PATH = 'M12 3 L13.6 10.4 L21 12 L13.6 13.6 L12 21 L10.4 13.6 L3 12 L10.4 10.4 Z';
|
||||
|
||||
const PARTICLE_BASE: Array<{ size: number; delay: string; rotate: number; x: number; y: number }> = [
|
||||
{ size: 10, delay: '0ms', rotate: 0, x: -6, y: 0 },
|
||||
{ size: 8, delay: '40ms', rotate: 25, x: 8, y: -2 },
|
||||
{ size: 9, delay: '90ms', rotate: -20, x: -10, y: 2 },
|
||||
{ size: 7, delay: '150ms', rotate: 15, x: 5, y: 4 },
|
||||
{ size: 11, delay: '210ms', rotate: 10, x: 0, y: -4 },
|
||||
{ size: 9, delay: '270ms', rotate: -10, x: 3, y: 6 },
|
||||
];
|
||||
|
||||
// Projection angle range: [85°, 95°] from horizontal — near-vertical with slight lean.
|
||||
// cos(85°) ≈ 0.087 (rightward), cos(95°) ≈ -0.087 (leftward).
|
||||
function randomLaunchVx(): number {
|
||||
const angleDeg = 85 + Math.random() * 10;
|
||||
return Math.cos((angleDeg * Math.PI) / 180);
|
||||
}
|
||||
|
||||
export function ToolSpark({
|
||||
toolName,
|
||||
activityKey,
|
||||
enabled,
|
||||
reducedMotion,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
activityKey: string | null;
|
||||
enabled: boolean;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
const [visibleTool, setVisibleTool] = useState<string | null>(null);
|
||||
const [animationToken, setAnimationToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !toolName) return;
|
||||
setVisibleTool(toolName);
|
||||
setAnimationToken(t => t + 1);
|
||||
// Bubble: 3000ms animation. Particles: 3000ms animation + up to 270ms
|
||||
// staggered delay. Hold ~30ms past the latest particle's fade-out so we
|
||||
// don't snap-cut the last one.
|
||||
const timer = window.setTimeout(() => setVisibleTool(null), 3300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [enabled, toolName, activityKey]);
|
||||
|
||||
const launchVelocities = useMemo(
|
||||
() => PARTICLE_BASE.map(() => randomLaunchVx()),
|
||||
// Re-randomize on each emission so successive sparkles don't look identical
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[animationToken],
|
||||
);
|
||||
|
||||
if (!enabled || !visibleTool) return null;
|
||||
|
||||
const kind = iconKindForTool(visibleTool);
|
||||
return (
|
||||
<div className="tool-spark-burst" key={animationToken} aria-hidden="true">
|
||||
<div className={`tool-spark-bubble ${reducedMotion ? 'tool-spark-reduced' : ''}`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ToolIcon kind={kind} />
|
||||
</svg>
|
||||
</div>
|
||||
{!reducedMotion && PARTICLE_BASE.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="tool-spark-particle"
|
||||
style={{
|
||||
left: `calc(50% + ${p.x}px)`,
|
||||
top: `calc(50% + ${p.y}px)`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
animationDelay: p.delay,
|
||||
['--p-rot' as string]: `${p.rotate}deg`,
|
||||
['--p-vx' as string]: (launchVelocities[i] ?? 0).toFixed(3),
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
|
||||
const ask = config.ask ?? {};
|
||||
const subtasks = config.subtasks ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Ask / Subtasks</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Ask: Max Per Job</FieldLabel>
|
||||
<FieldInput type="number" value={ask.maxPerJob ?? ''} onChange={v => onChange('ask.maxPerJob', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 Job あたりの ASK(ユーザーへの質問)上限</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Subtasks: Max Depth</FieldLabel>
|
||||
<FieldInput type="number" value={subtasks.maxDepth ?? ''} onChange={v => onChange('subtasks.maxDepth', v ? Number(v) : undefined)} />
|
||||
<HelpText>サブタスクのネスト最大深度</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
type AssetKind = 'logo' | 'favicon';
|
||||
|
||||
const ACCEPT: Record<AssetKind, string> = {
|
||||
logo: '.svg,.png,.jpg,.jpeg,.webp,.gif',
|
||||
favicon: '.svg,.png,.ico,.webp',
|
||||
};
|
||||
|
||||
const MAX_SIZE: Record<AssetKind, number> = {
|
||||
logo: 2 * 1024 * 1024,
|
||||
favicon: 256 * 1024,
|
||||
};
|
||||
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
const buf = await file.arrayBuffer();
|
||||
// btoa does not accept non-ASCII; convert via chunked construction.
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function AssetUploader({
|
||||
kind,
|
||||
currentUrl,
|
||||
onChanged,
|
||||
}: {
|
||||
kind: AssetKind;
|
||||
currentUrl: string | null;
|
||||
/** Called after a successful upload/delete with the new URL (null when cleared). */
|
||||
onChanged: (newUrl: string | null) => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePick = () => fileRef.current?.click();
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setError(null);
|
||||
if (file.size > MAX_SIZE[kind]) {
|
||||
setError(`ファイルサイズが上限 ${Math.round(MAX_SIZE[kind] / 1024)}KB を超えています`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setBusy(true);
|
||||
const contentBase64 = await fileToBase64(file);
|
||||
const res = await fetch('/api/branding/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind, filename: file.name, contentBase64 }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error ?? `アップロードに失敗しました (${res.status})`);
|
||||
}
|
||||
onChanged(typeof body.url === 'string' ? body.url : null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/branding/upload?kind=${kind}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`削除に失敗しました (${res.status})`);
|
||||
onChanged(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-12 w-12 flex-shrink-0 rounded-md border border-hairline bg-surface flex items-center justify-center overflow-hidden ${
|
||||
kind === 'favicon' ? 'bg-white' : ''
|
||||
}`}
|
||||
>
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt="" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">未設定</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={ACCEPT[kind]}
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePick}
|
||||
disabled={busy}
|
||||
className="px-2.5 h-7 text-2xs font-medium bg-white border border-hairline rounded-md text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{currentUrl ? '差し替え' : 'アップロード'}
|
||||
</button>
|
||||
{currentUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
disabled={busy}
|
||||
className="px-2.5 h-7 text-2xs font-medium text-red-700 border border-red-200 bg-white hover:bg-red-50 rounded-md disabled:opacity-50 transition-colors"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1 truncate font-mono">
|
||||
{currentUrl ?? `${ACCEPT[kind]} / 最大 ${Math.round(MAX_SIZE[kind] / 1024)}KB`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="mt-1.5 text-2xs text-red-600">⚠ {error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandingForm({ config, onChange }: SectionFormProps) {
|
||||
const branding = config.branding ?? {};
|
||||
const primaryColor = branding.primaryColor ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
// サーバーは upload 時に config.yaml を直接書き換える。
|
||||
// ローカル draft も同期して、他フィールドの編集中でも整合を保つ。
|
||||
const handleAssetChange = (field: 'logoUrl' | 'faviconUrl') => (newUrl: string | null) => {
|
||||
onChange(`branding.${field}`, newUrl ?? '');
|
||||
// Branding API (TopBar の画像など) は別クエリなので個別に再取得
|
||||
void qc.invalidateQueries({ queryKey: ['branding'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Branding</h2>
|
||||
<p className="text-xs text-slate-500 -mt-3">
|
||||
UI のタイトル・配色・ロゴ・フッターをカスタマイズします。設定は <code>config.yaml</code> の <code>branding</code> セクション、
|
||||
画像は <code>data/branding/</code> に保存されます。どちらも <code>.gitignore</code> 済みで
|
||||
<code> git pull</code> の影響を受けません。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<FieldLabel>アプリ名</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.appName ?? ''}
|
||||
onChange={v => onChange('branding.appName', v)}
|
||||
placeholder="MAESTRO"
|
||||
/>
|
||||
<HelpText>TopBar 左上と ブラウザタイトルに表示されます。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>プライマリカラー</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor || '#2563eb'}
|
||||
onChange={e => onChange('branding.primaryColor', e.target.value)}
|
||||
className="h-9 w-12 rounded border border-slate-300 p-0 cursor-pointer"
|
||||
aria-label="プライマリカラー"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={primaryColor}
|
||||
onChange={e => onChange('branding.primaryColor', e.target.value)}
|
||||
placeholder="#2563eb"
|
||||
className="flex-1 px-3 py-2 text-sm font-mono border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<HelpText>ヘッダーのアプリ名など、ブランドカラーに反映されます(hex / rgb)。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ログイン画面の見出し</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.loginPageTitle ?? ''}
|
||||
onChange={v => onChange('branding.loginPageTitle', v)}
|
||||
placeholder="MAESTRO"
|
||||
/>
|
||||
<HelpText>未設定の場合はアプリ名を使用します。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ロゴ</FieldLabel>
|
||||
<AssetUploader
|
||||
kind="logo"
|
||||
currentUrl={branding.logoUrl || null}
|
||||
onChanged={handleAssetChange('logoUrl')}
|
||||
/>
|
||||
<HelpText>TopBar 左上に表示されます。未設定時はデフォルトのアイコンを使用します。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Favicon</FieldLabel>
|
||||
<AssetUploader
|
||||
kind="favicon"
|
||||
currentUrl={branding.faviconUrl || null}
|
||||
onChanged={handleAssetChange('faviconUrl')}
|
||||
/>
|
||||
<HelpText>ブラウザタブに表示されます。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>フッター文言</FieldLabel>
|
||||
<FieldInput
|
||||
value={branding.footerText ?? ''}
|
||||
onChange={v => onChange('branding.footerText', v)}
|
||||
placeholder="© 2026 Your Team"
|
||||
/>
|
||||
<HelpText>画面最下部に小さく表示されます。未設定時は非表示。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
const browser = config.browser ?? {};
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Browser</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Page Timeout (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.browserPageTimeout ?? 60000}
|
||||
onChange={v => onChange('tools.browserPageTimeout', Number(v))} />
|
||||
<HelpText>ページ読み込みのタイムアウト(ミリ秒)。デフォルト: 60000</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Action Timeout (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.browserActionTimeout ?? 30000}
|
||||
onChange={v => onChange('tools.browserActionTimeout', Number(v))} />
|
||||
<HelpText>ブラウザ操作のタイムアウト(ミリ秒)。デフォルト: 30000</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Browser Channel</FieldLabel>
|
||||
<select value={browser.channel ?? 'chromium'}
|
||||
onChange={e => onChange('browser.channel', e.target.value)}
|
||||
className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md">
|
||||
<option value="chromium">chromium (bundled, default)</option>
|
||||
<option value="chrome">chrome (system Google Chrome)</option>
|
||||
<option value="msedge">msedge (system Microsoft Edge)</option>
|
||||
</select>
|
||||
<HelpText>
|
||||
Google ログイン等で「セキュアでないブラウザ」と弾かれる場合は <code>chrome</code> に切替。
|
||||
ホストに <code>google-chrome</code> がインストールされている必要あり。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Executable Path (optional)</FieldLabel>
|
||||
<FieldInput value={browser.executablePath ?? ''}
|
||||
onChange={v => onChange('browser.executablePath', v || undefined)} />
|
||||
<HelpText>非標準パスにあるブラウザを使う場合のみ指定。未設定なら channel に従う。</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>VNC Base Port</FieldLabel>
|
||||
<FieldInput type="number" value={browser.vncBasePort ?? 5900}
|
||||
onChange={v => onChange('browser.vncBasePort', Number(v))} />
|
||||
<HelpText>VNC サーバーのベースポート。デフォルト: 5900</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Data Directory</FieldLabel>
|
||||
<FieldInput value={browser.sessionDataDir ?? './data/browser-sessions'}
|
||||
onChange={v => onChange('browser.sessionDataDir', v)} />
|
||||
<HelpText>Cookie を永続化するディレクトリ。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Sessions</FieldLabel>
|
||||
<FieldInput type="number" value={browser.maxSessions ?? 3}
|
||||
onChange={v => onChange('browser.maxSessions', Number(v))} />
|
||||
<HelpText>同時に起動できるセッションの最大数。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
import { useUnsavedGuard } from '../../lib/unsavedGuard';
|
||||
import { updateConfig } from '../../api';
|
||||
import { LlmWorkersForm } from './LlmWorkersForm';
|
||||
import { WorkspaceForm } from './WorkspaceForm';
|
||||
import { PathsStorageForm } from './PathsStorageForm';
|
||||
import { ExecutionForm } from './ExecutionForm';
|
||||
import { ToolsForm } from './ToolsForm';
|
||||
import { ToolsWebForm } from './ToolsWebForm';
|
||||
import { ToolsMediaForm } from './ToolsMediaForm';
|
||||
import { ToolsExternalForm } from './ToolsExternalForm';
|
||||
import { KnowledgeNamespacesForm } from './KnowledgeNamespacesForm';
|
||||
import { AskSubtasksForm } from './AskSubtasksForm';
|
||||
import { SearchFilterForm } from './SearchFilterForm';
|
||||
import { BrowserSettingsForm } from './BrowserSettingsForm';
|
||||
import { ContextForm } from './ContextForm';
|
||||
import { SafetyForm } from './SafetyForm';
|
||||
import { PreferencesForm } from './PreferencesForm';
|
||||
import { NotificationsForm } from './NotificationsForm';
|
||||
import { BrandingForm } from './BrandingForm';
|
||||
import { MemoryLearningForm } from './MemoryLearningForm';
|
||||
import { MetricsForm } from './MetricsForm';
|
||||
import { ReflectionForm } from './ReflectionForm';
|
||||
import { McpForm } from './McpForm';
|
||||
import { SshForm } from './SshForm';
|
||||
import { GatewayServerForm } from './GatewayServerForm';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface ConfigFormProps {
|
||||
section: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
function PreferencesFormWrapper() {
|
||||
const auth = useAuthState();
|
||||
if (auth.mode !== 'authenticated') {
|
||||
return <div className="text-sm text-slate-500">Log in to manage preferences.</div>;
|
||||
}
|
||||
return (
|
||||
<PreferencesForm
|
||||
user={{
|
||||
defaultVisibility: auth.user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: auth.user.defaultVisibilityOrgId ?? null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Set a value at a dot-separated path in an object (immutable). */
|
||||
function setNestedValue(obj: any, path: string, value: any): any {
|
||||
const keys = path.split('.');
|
||||
if (keys.length === 1) {
|
||||
return { ...obj, [keys[0]]: value };
|
||||
}
|
||||
const [first, ...rest] = keys;
|
||||
return { ...obj, [first]: setNestedValue(obj[first] ?? {}, rest.join('.'), value) };
|
||||
}
|
||||
|
||||
/** Count leaf-level differences between two values. Arrays are compared as a single leaf. */
|
||||
function countDiff(a: any, b: any): number {
|
||||
if (a === b) return 0;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b) ? 0 : 1;
|
||||
}
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
let total = 0;
|
||||
for (const k of keys) total += countDiff(a[k], b[k]);
|
||||
return total;
|
||||
}
|
||||
// Treat undefined/null/empty-string as equivalent to reduce noise from optional fields.
|
||||
const norm = (v: any) => (v === undefined || v === null || v === '' ? null : v);
|
||||
return norm(a) === norm(b) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
// User-scoped sections use their own per-user APIs and should not load the
|
||||
// admin /api/config draft. Render them stand-alone without the global save bar.
|
||||
if (section === 'preferences') {
|
||||
return <div className="max-w-2xl"><PreferencesFormWrapper /></div>;
|
||||
}
|
||||
if (section === 'notifications') {
|
||||
return <div className="max-w-2xl"><NotificationsForm /></div>;
|
||||
}
|
||||
if (section === 'memory-learning') {
|
||||
return <div className="max-w-2xl"><MemoryLearningForm /></div>;
|
||||
}
|
||||
if (!isAdmin) {
|
||||
return <div className="max-w-2xl text-sm text-slate-500">この設定は管理者のみ閲覧できます。</div>;
|
||||
}
|
||||
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
||||
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
||||
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
||||
// as the Virtual Keys section.
|
||||
return <ConfigFormInner section={section} isAdmin={isAdmin} />;
|
||||
}
|
||||
|
||||
function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const { data, isLoading, error, refetch } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [draft, setDraft] = useState<any>(null);
|
||||
const [etag, setEtag] = useState('');
|
||||
const [overriddenByEnv, setOverriddenByEnv] = useState<Record<string, boolean>>({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Sync fetched config into draft
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
setEtag(data.etag);
|
||||
setOverriddenByEnv(data.overriddenByEnv);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleChange = useCallback((path: string, value: any) => {
|
||||
setDraft((prev: any) => setNestedValue(prev, path, value));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
setIsDirty(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateConfig(draft, etag);
|
||||
if (result.conflict) {
|
||||
if (confirm('設定が他で変更されました。再読み込みしますか?')) {
|
||||
await refetch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
setToast('保存しました');
|
||||
setTimeout(() => setToast(null), 2000);
|
||||
} catch (e: any) {
|
||||
setToast(`エラー: ${e.message}`);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0;
|
||||
// Arm beforeunload + register an in-app guard so navigating elsewhere
|
||||
// (e.g. clicking a TopBar tab) prompts when there are unsaved fields.
|
||||
useUnsavedGuard(dirtyCount > 0);
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
|
||||
if (error) return <div className="text-sm text-red-500">設定の読み込みに失敗しました</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
|
||||
|
||||
const sectionForm = (() => {
|
||||
switch (section) {
|
||||
// ── System
|
||||
case 'branding': return <BrandingForm {...formProps} />;
|
||||
case 'paths-storage': return <PathsStorageForm {...formProps} />;
|
||||
case 'execution': return <ExecutionForm {...formProps} />;
|
||||
|
||||
// ── LLM (Step 7: LlmWorkersForm replaces ProviderForm; reads llm.workers,
|
||||
// not provider.workers. 'provider' alias kept for URL backwards compat.)
|
||||
case 'provider':
|
||||
case 'llm-workers':
|
||||
return <LlmWorkersForm {...formProps} />;
|
||||
case 'gateway-server': return <GatewayServerForm {...formProps} />;
|
||||
case 'llm-metrics': return <MetricsForm {...formProps} />;
|
||||
|
||||
// ── Agent Runtime
|
||||
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
||||
case 'context': return <ContextForm {...formProps} />;
|
||||
case 'safety': return <SafetyForm {...formProps} />;
|
||||
case 'reflection': return <ReflectionForm {...formProps} />;
|
||||
|
||||
// ── Tools sub-sections — Step 9 split the legacy grab-bag
|
||||
// ToolsForm into focused per-category forms. Each binds to the
|
||||
// same `tools.*` config keys as before (functionally equivalent),
|
||||
// just without the in-form sub-tab nav.
|
||||
case 'tools-web':
|
||||
// Folds SearchFilterForm in as a sub-section (Step 3
|
||||
// INVESTIGATE #3 follow-up).
|
||||
return <ToolsWebForm {...formProps} />;
|
||||
case 'tools-browser':
|
||||
// Browser runtime (page/action timeouts, channel, etc.) is its
|
||||
// own form — kept verbatim, just relocated.
|
||||
return <BrowserSettingsForm {...formProps} />;
|
||||
case 'tools-media':
|
||||
return <ToolsMediaForm {...formProps} />;
|
||||
case 'tools-external':
|
||||
return <ToolsExternalForm {...formProps} />;
|
||||
case 'tools-legacy-knowledge':
|
||||
return <KnowledgeNamespacesForm {...formProps} />;
|
||||
|
||||
// ── MCP & Connections
|
||||
case 'mcp': return <McpForm {...formProps} />;
|
||||
|
||||
// ── SSH (admin)
|
||||
case 'ssh': return <SshForm {...formProps} showToast={(msg) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}} />;
|
||||
|
||||
// ── Legacy ids — kept here only so a direct URL hit still renders
|
||||
// something during the transition window. The Settings page also
|
||||
// rewrites the URL to the new id via `LEGACY_SECTION_REDIRECT`, so
|
||||
// these branches are mostly defensive. ('provider' moved to the
|
||||
// LLM-Workers case above — Step 7 — so it now lands on the new
|
||||
// form. 'tools' bookmark still resolves to the legacy ToolsForm
|
||||
// with all sub-tabs visible per Step 9 fallback design.)
|
||||
case 'workspace': return <WorkspaceForm {...formProps} />;
|
||||
case 'tools': return <ToolsForm {...formProps} />;
|
||||
case 'search-filter': return <SearchFilterForm {...formProps} />;
|
||||
case 'browser-settings': return <BrowserSettingsForm {...formProps} />;
|
||||
|
||||
default: return <div className="text-sm text-slate-400">Unknown section: {section}</div>;
|
||||
}
|
||||
})();
|
||||
|
||||
const dirty = dirtyCount > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl pb-20">
|
||||
{sectionForm}
|
||||
|
||||
{/* Sticky save bar: stays visible while scrolling, gets a strong amber
|
||||
accent when dirty so it cannot be missed. The pb-20 on the parent
|
||||
reserves space so the bar never overlaps the last form field. */}
|
||||
<div
|
||||
className={`sticky bottom-0 px-3 py-2.5 mt-6 border rounded-md flex items-center justify-end gap-2 transition-colors ${
|
||||
dirty
|
||||
? 'bg-amber-50 border-amber-300 shadow-[0_2px_8px_rgba(180,83,9,0.08)]'
|
||||
: 'bg-white border-hairline'
|
||||
}`}
|
||||
>
|
||||
{toast ? (
|
||||
<span className={`text-2xs mr-auto ${toast.startsWith('エラー') ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
) : dirty ? (
|
||||
<span className="text-xs mr-auto text-amber-800 flex items-center gap-1.5 font-medium min-w-0">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
<span className="hidden sm:inline">未保存: {dirtyCount} 項目 — 「Save & Apply」を押すまで反映されません</span>
|
||||
<span className="sm:hidden">未保存 {dirtyCount}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!dirty}
|
||||
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
<span className="hidden sm:inline">Discard Changes</span>
|
||||
<span className="sm:hidden">Discard</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{saving ? 'Saving...' : (
|
||||
<>
|
||||
<span className="hidden sm:inline">Save & Apply</span>
|
||||
<span className="sm:hidden">Save</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function ContextForm({ config, onChange }: SectionFormProps) {
|
||||
const ctx = config.context ?? {};
|
||||
const thresholds = ctx.thresholds ?? [
|
||||
{ ratio: 0.7, action: 'warn' },
|
||||
{ ratio: 0.85, action: 'prompt' },
|
||||
{ ratio: 0.95, action: 'force_transition' },
|
||||
];
|
||||
|
||||
const updateThreshold = (index: number, field: string, value: string | number) => {
|
||||
const updated = thresholds.map((t: { ratio: number; action: string }, i: number) =>
|
||||
i === index ? { ...t, [field]: field === 'ratio' ? Number(value) : value } : t
|
||||
);
|
||||
onChange('context.thresholds', updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Context</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Limit Tokens</FieldLabel>
|
||||
<FieldInput type="number" value={ctx.limitTokens ?? ''}
|
||||
onChange={v => onChange('context.limitTokens', v ? Number(v) : undefined)}
|
||||
placeholder="auto (Ollama API から取得)" />
|
||||
<HelpText>トークン上限の手動指定。空欄で自動取得。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Thresholds (閾値)</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{thresholds.map((t: { ratio: number; action: string }, i: number) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input type="number" step="0.01" min="0" max="1"
|
||||
value={t.ratio}
|
||||
onChange={e => updateThreshold(i, 'ratio', e.target.value)}
|
||||
className="w-20 px-2 py-1 text-sm border border-slate-300 rounded" />
|
||||
<select value={t.action}
|
||||
onChange={e => updateThreshold(i, 'action', e.target.value)}
|
||||
className="px-2 py-1 text-sm border border-slate-300 rounded">
|
||||
<option value="warn">warn</option>
|
||||
<option value="prompt">prompt</option>
|
||||
<option value="force_transition">force_transition</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>
|
||||
コンテキスト使用率に応じたアクション。ratio は 0〜1。
|
||||
warn: ログに警告を出力するのみ / prompt: LLM へ遷移を促すメッセージを注入 / force_transition: default_next に強制遷移
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Execution — concurrency, max_movements, and job retry settings.
|
||||
*
|
||||
* Step 3 carve-out from the old "Workspace" form. The path/storage half
|
||||
* of Workspace lives in PathsStorageForm. Field paths are unchanged so
|
||||
* the underlying config keys keep working without a migration.
|
||||
*/
|
||||
export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Execution</h2>
|
||||
<HelpText>同時実行数、1 ジョブあたりの movement 上限、ジョブ失敗時のリトライ設定。</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Concurrency</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.concurrency ?? ''}
|
||||
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
|
||||
disabled={!!overriddenByEnv['concurrency']}
|
||||
disabledReason="CONCURRENCY 環境変数で上書き中"
|
||||
/>
|
||||
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
|
||||
<HelpText>同時実行可能なジョブ数</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Movements</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.maxMovements ?? ''}
|
||||
onChange={v => onChange('maxMovements', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>1ジョブあたりの最大 movement 数</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={config.retry?.maxAttempts ?? 3}
|
||||
onChange={v => onChange('retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>ジョブ失敗時の最大リトライ回数。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Backoff Seconds</FieldLabel>
|
||||
<FieldInput
|
||||
value={(config.retry?.backoffSeconds ?? [60, 300, 900]).join(', ')}
|
||||
onChange={v =>
|
||||
onChange(
|
||||
'retry.backoffSeconds',
|
||||
v.split(',').map((s: string) => Number(s.trim())).filter((n: number) => !isNaN(n)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpText>リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CreateInput {
|
||||
team: string;
|
||||
allowedModels?: string[];
|
||||
tokensBudget?: number | null;
|
||||
rateLimitRpm?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onSubmit: (input: CreateInput) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for issuing a new gateway virtual key. The team field is
|
||||
* required; allowed_models / tokens_budget / rate_limit_rpm are
|
||||
* optional and empty = "no limit".
|
||||
*
|
||||
* After submit succeeds the parent shows the GatewayKeyRawKeyDialog
|
||||
* with the raw bearer; this dialog never displays it.
|
||||
*/
|
||||
export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }: Props) {
|
||||
const [team, setTeam] = useState('');
|
||||
const [allowedModelsText, setAllowedModelsText] = useState('');
|
||||
const [tokensBudgetText, setTokensBudgetText] = useState('');
|
||||
const [rateLimitRpmText, setRateLimitRpmText] = useState('');
|
||||
|
||||
function buildPayload(): CreateInput | { error: string } {
|
||||
const t = team.trim();
|
||||
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(t)) {
|
||||
return { error: 'team must match /^[a-zA-Z0-9._-]{1,64}$/' };
|
||||
}
|
||||
const allowedModels = allowedModelsText
|
||||
.split(/[\n,]/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
const tokensBudget = tokensBudgetText.trim() === '' ? null : Number(tokensBudgetText);
|
||||
const rateLimitRpm = rateLimitRpmText.trim() === '' ? null : Number(rateLimitRpmText);
|
||||
if (tokensBudget !== null && (!Number.isFinite(tokensBudget) || tokensBudget <= 0)) {
|
||||
return { error: 'tokens budget must be a positive integer' };
|
||||
}
|
||||
if (rateLimitRpm !== null && (!Number.isFinite(rateLimitRpm) || rateLimitRpm <= 0)) {
|
||||
return { error: 'rate limit (rpm) must be a positive integer' };
|
||||
}
|
||||
return {
|
||||
team: t,
|
||||
allowedModels: allowedModels.length > 0 ? allowedModels : undefined,
|
||||
tokensBudget,
|
||||
rateLimitRpm,
|
||||
};
|
||||
}
|
||||
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLocalError(null);
|
||||
const payload = buildPayload();
|
||||
if ('error' in payload) {
|
||||
setLocalError(payload.error);
|
||||
return;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">新規 Gateway Key 発行</h3>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
team <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={team}
|
||||
onChange={(e) => setTeam(e.target.value)}
|
||||
placeholder="alpha"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded mb-3"
|
||||
/>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Allowed models (1 行 / カンマ区切り、空欄=制限なし)
|
||||
</label>
|
||||
<textarea
|
||||
value={allowedModelsText}
|
||||
onChange={(e) => setAllowedModelsText(e.target.value)}
|
||||
placeholder="qwen3:8b qwen3:14b"
|
||||
rows={2}
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded mb-3 font-mono"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Tokens budget / month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={tokensBudgetText}
|
||||
onChange={(e) => setTokensBudgetText(e.target.value)}
|
||||
placeholder="無制限"
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Rate limit (rpm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={rateLimitRpmText}
|
||||
onChange={(e) => setRateLimitRpmText(e.target.value)}
|
||||
placeholder="無制限"
|
||||
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(localError || error) && (
|
||||
<div className="text-sm text-red-600 mb-3">{localError ?? error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '発行中...' : '発行する'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
rawKey: string;
|
||||
team: string;
|
||||
reason: 'created' | 'rotated';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time raw bearer reveal. The DB never stores the raw value — once
|
||||
* this dialog closes the operator can never see it again, so we:
|
||||
* - require an explicit "I've saved it" acknowledgement before close
|
||||
* - show a copy-to-clipboard button as the obvious primary action
|
||||
* - warn loudly in red
|
||||
* - trap ESC, browser back, and tab-close (beforeunload) until
|
||||
* acknowledged so a stray keypress can't lose the key (F10)
|
||||
*
|
||||
* The dialog is intentionally modal (overlay + focus trap via tabindex).
|
||||
*/
|
||||
export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
// F10: while the raw key is on-screen and not acknowledged, block the
|
||||
// common dismissal paths that would otherwise silently lose it:
|
||||
// - ESC keypress (Escape closes most modals by convention)
|
||||
// - browser back / forward (popstate)
|
||||
// - tab close / refresh (beforeunload — best-effort browser warning)
|
||||
// We intentionally do NOT block the dialog's own Close button (gated
|
||||
// by the `acknowledged` checkbox) or the overlay click (which the
|
||||
// current design already ignores).
|
||||
useEffect(() => {
|
||||
if (acknowledged) return;
|
||||
|
||||
const onKeydown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent): string => {
|
||||
e.preventDefault();
|
||||
const msg = 'Gateway API key has not been saved. Closing this page will lose it forever.';
|
||||
// Modern browsers ignore the returned string but require it set
|
||||
// for the warning dialog to appear. Setting both for cross-browser
|
||||
// safety (Chrome reads returnValue, some older Firefox reads return).
|
||||
(e as BeforeUnloadEvent & { returnValue: string }).returnValue = msg;
|
||||
return msg;
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
|
||||
// Push a sentinel history entry so the next back-button press lands
|
||||
// here (where we re-push it). Best-effort: doesn't fully prevent
|
||||
// navigation in every browser, but turns a single back-tap into a
|
||||
// visible alert + re-block.
|
||||
let pushed = false;
|
||||
try {
|
||||
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
|
||||
pushed = true;
|
||||
} catch { /* SSR / sandboxed iframes: skip */ }
|
||||
const onPopState = (e: PopStateEvent): void => {
|
||||
e.preventDefault?.();
|
||||
try {
|
||||
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
|
||||
} catch { /* ignore */ }
|
||||
alert(
|
||||
'API key has not been saved. Copy it and tick "保存しました" before navigating away.',
|
||||
);
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeydown, { capture: true });
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
// Drop the sentinel we pushed so the user's history isn't littered.
|
||||
if (pushed) {
|
||||
try {
|
||||
if (window.history.state && (window.history.state as { aaoGatewayKeyTrap?: boolean }).aaoGatewayKeyTrap) {
|
||||
window.history.back();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
}, [acknowledged]);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Some browsers / contexts block clipboard access. The textarea
|
||||
// is selectable as a fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-1">
|
||||
{reason === 'created' ? '新しい Gateway Key を発行しました' : 'Gateway Key をローテーションしました'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">team: {team}</p>
|
||||
|
||||
<div className="rounded border border-red-300 bg-red-50 p-3 mb-3">
|
||||
<p className="text-sm text-red-800 font-medium">⚠️ このキーは今後二度と表示されません</p>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
必ずパスワードマネージャや LLM クライアントの設定にコピー・保存してから閉じてください。
|
||||
紛失した場合は Rotate で再発行する必要があります。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">Bearer Key (sk-aao-…)</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={rawKey}
|
||||
rows={2}
|
||||
className="w-full font-mono text-xs px-2 py-1.5 border border-slate-300 rounded bg-slate-50 select-all"
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
|
||||
>
|
||||
{copied ? '✓ Copied' : 'Copy to clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline mt-4 pt-4">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-slate-700">
|
||||
キーを安全に保存しました。今後このキーは表示できなくなることを理解しています。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!acknowledged}
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded border border-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getGatewayKeyUsage } from '../../api';
|
||||
|
||||
interface Props {
|
||||
keyId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key usage detail. Shows current-month stats (with a progress bar
|
||||
* vs budget) and a simple bar chart of the last 6-12 months of token
|
||||
* usage. No external chart library — pure CSS bars keep the UI bundle
|
||||
* lean.
|
||||
*/
|
||||
export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['gateway-key-usage', keyId],
|
||||
queryFn: () => getGatewayKeyUsage(keyId),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const maxHistTokens = data
|
||||
? Math.max(1, ...data.history.map(h => h.tokensIn + h.tokensOut))
|
||||
: 1;
|
||||
|
||||
const pctUsed =
|
||||
data && data.tokensBudget !== null && data.tokensBudget > 0
|
||||
? Math.min(100, (data.tokensTotal / data.tokensBudget) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">Key 使用状況</h3>
|
||||
<p className="text-xs text-slate-500 font-mono">{keyId}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700 text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-sm text-slate-500">Loading…</div>}
|
||||
{error && (
|
||||
<div className="text-sm text-red-600">取得エラー: {String((error as Error).message ?? error)}</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Current period summary */}
|
||||
<div className="border border-hairline rounded p-3 mb-4">
|
||||
<div className="flex justify-between items-baseline mb-2">
|
||||
<span className="text-xs font-medium text-slate-600 uppercase tracking-wide">
|
||||
今月 ({data.currentPeriod})
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
Requests: {data.requestsThisMonth.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">Input tokens</div>
|
||||
<div className="font-mono">{fmtTokens(data.tokensIn)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">Output tokens</div>
|
||||
<div className="font-mono">{fmtTokens(data.tokensOut)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">Total / Budget</div>
|
||||
<div className="font-mono">
|
||||
{fmtTokens(data.tokensTotal)}{' '}
|
||||
<span className="text-slate-400">
|
||||
/ {data.tokensBudget !== null ? fmtTokens(data.tokensBudget) : '∞'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pctUsed !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 rounded bg-slate-100 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${pctUsed >= 100 ? 'bg-red-500' : pctUsed >= 80 ? 'bg-amber-500' : 'bg-accent'}`}
|
||||
style={{ width: `${pctUsed}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 text-right">
|
||||
{pctUsed.toFixed(1)}% used
|
||||
{data.remaining !== null && ` · ${fmtTokens(data.remaining)} remaining`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.rateLimitRpm !== null && (
|
||||
<div className="text-xs text-slate-500 mt-2">
|
||||
Rate limit: {data.rateLimitRpm} rpm
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History bars */}
|
||||
<div className="border border-hairline rounded p-3">
|
||||
<div className="text-xs font-medium text-slate-600 uppercase tracking-wide mb-2">
|
||||
過去 12 か月
|
||||
</div>
|
||||
{data.history.length === 0 ? (
|
||||
<div className="text-sm text-slate-400 italic">履歴なし</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{data.history.map((h) => {
|
||||
const total = h.tokensIn + h.tokensOut;
|
||||
const widthPct = (total / maxHistTokens) * 100;
|
||||
return (
|
||||
<div key={h.period} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono w-16 text-slate-500">{h.period}</span>
|
||||
<div className="flex-1 h-3 bg-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent/70"
|
||||
style={{ width: `${Math.max(2, widthPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono w-20 text-right text-slate-600">
|
||||
{fmtTokens(total)}
|
||||
</span>
|
||||
<span className="font-mono w-12 text-right text-slate-400">
|
||||
{h.requests.toLocaleString()} rq
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { GatewayKey } from '../../api';
|
||||
import {
|
||||
listGatewayKeys,
|
||||
createGatewayKey,
|
||||
revokeGatewayKey,
|
||||
rotateGatewayKey,
|
||||
patchGatewayKey,
|
||||
} from '../../api';
|
||||
import { GatewayKeyCreateDialog } from './GatewayKeyCreateDialog';
|
||||
import { GatewayKeyRawKeyDialog } from './GatewayKeyRawKeyDialog';
|
||||
import { GatewayKeyUsagePanel } from './GatewayKeyUsagePanel';
|
||||
|
||||
interface Props {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → LLM → Gateway Server → Virtual Keys section (Step 8).
|
||||
*
|
||||
* Renders the Gateway Keys list + create/rotate/revoke actions inline
|
||||
* within the Gateway Server form. Previously this lived under its own
|
||||
* sidebar entry (`gateway-keys`); the entry was removed in Step 8 because
|
||||
* key management is a Gateway Server operation, not a separate concern.
|
||||
*
|
||||
* List table + actions per row (Detail / Rotate / Revoke). Create
|
||||
* dialog issues a fresh sk-aao-* key; the raw value is then surfaced
|
||||
* in a one-time reveal dialog with copy + acknowledge gate.
|
||||
*
|
||||
* Filters: ?team= (text input) and ?activeOnly= (checkbox). Both
|
||||
* roundtrip through React Query for cache scoping.
|
||||
*
|
||||
* Note: this section talks to its own admin REST API (not the global
|
||||
* config save flow), so edits here are applied immediately and do not
|
||||
* participate in the surrounding form's draft/dirty/Save&Apply bar.
|
||||
*/
|
||||
export function GatewayKeysSection({ showToast }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createSubmitting, setCreateSubmitting] = useState(false);
|
||||
const [rawDialog, setRawDialog] = useState<{ rawKey: string; team: string; reason: 'created' | 'rotated' } | null>(null);
|
||||
const [usagePanelId, setUsagePanelId] = useState<string | null>(null);
|
||||
const [budgetDraft, setBudgetDraft] = useState<{ id: string; value: string } | null>(null);
|
||||
const [rpmDraft, setRpmDraft] = useState<{ id: string; value: string } | null>(null);
|
||||
|
||||
const queryKey = ['gateway-keys', { team: teamFilter || undefined, activeOnly }];
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => listGatewayKeys({ team: teamFilter || undefined, activeOnly }),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
function notify(msg: string, variant: 'success' | 'error' = 'success'): void {
|
||||
if (showToast) showToast(msg, variant);
|
||||
}
|
||||
|
||||
async function handleCreate(input: {
|
||||
team: string;
|
||||
allowedModels?: string[];
|
||||
tokensBudget?: number | null;
|
||||
rateLimitRpm?: number | null;
|
||||
}): Promise<void> {
|
||||
setCreateSubmitting(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await createGatewayKey(input);
|
||||
setCreating(false);
|
||||
if (created.key) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'created' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Gateway key を発行しました');
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCreateSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotate(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキーをローテーションしますか?\n旧キーは無効になります。`)) return;
|
||||
try {
|
||||
const created = await rotateGatewayKey(row.id);
|
||||
if (created.key) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'rotated' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Rotate しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキー (${row.keyPrefix}…) を Revoke しますか?\nこの操作は取り消せません。`)) return;
|
||||
try {
|
||||
await revokeGatewayKey(row.id);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Revoke しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatch(id: string, patch: { tokensBudget?: number | null; rateLimitRpm?: number | null }): Promise<void> {
|
||||
try {
|
||||
await patchGatewayKey(id, patch);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('更新しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function commitBudget(id: string): void {
|
||||
if (!budgetDraft || budgetDraft.id !== id) return;
|
||||
const v = budgetDraft.value.trim();
|
||||
const parsed = v === '' ? null : Number(v);
|
||||
if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
notify('tokens budget must be a positive integer or empty', 'error');
|
||||
setBudgetDraft(null);
|
||||
return;
|
||||
}
|
||||
handlePatch(id, { tokensBudget: parsed });
|
||||
setBudgetDraft(null);
|
||||
}
|
||||
|
||||
function commitRpm(id: string): void {
|
||||
if (!rpmDraft || rpmDraft.id !== id) return;
|
||||
const v = rpmDraft.value.trim();
|
||||
const parsed = v === '' ? null : Number(v);
|
||||
if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
notify('rate limit must be a positive integer or empty', 'error');
|
||||
setRpmDraft(null);
|
||||
return;
|
||||
}
|
||||
handlePatch(id, { rateLimitRpm: parsed });
|
||||
setRpmDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">Team filter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={teamFilter}
|
||||
onChange={(e) => setTeamFilter(e.target.value)}
|
||||
placeholder="alpha"
|
||||
className="px-2 py-1 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm mt-5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeOnly}
|
||||
onChange={(e) => setActiveOnly(e.target.checked)}
|
||||
/>
|
||||
Active only
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreateError(null); setCreating(true); }}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
|
||||
>
|
||||
+ 新規発行
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-hairline rounded overflow-hidden">
|
||||
{isLoading && <div className="p-3 text-sm text-slate-500">Loading…</div>}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600">
|
||||
取得エラー: {String((error as Error).message ?? error)}
|
||||
</div>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<div className="p-6 text-center text-sm text-slate-400">
|
||||
キーが登録されていません。「+ 新規発行」から作成できます。
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium">Prefix</th>
|
||||
<th className="text-left p-2 font-medium">Team</th>
|
||||
<th className="text-left p-2 font-medium">Models</th>
|
||||
<th className="text-right p-2 font-medium">Budget</th>
|
||||
<th className="text-right p-2 font-medium">Rpm</th>
|
||||
<th className="text-left p-2 font-medium">Source</th>
|
||||
<th className="text-left p-2 font-medium">Status</th>
|
||||
<th className="text-right p-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const isRevoked = row.revokedAt !== null;
|
||||
const isConfig = row.source === 'config-import';
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`border-t border-hairline ${isRevoked ? 'bg-slate-50 text-slate-400' : ''}`}
|
||||
>
|
||||
<td className="p-2 font-mono text-xs">{row.keyPrefix}…</td>
|
||||
<td className="p-2">{row.team}</td>
|
||||
<td className="p-2 text-xs text-slate-500">
|
||||
{row.allowedModels === null
|
||||
? <span className="text-slate-400 italic">all</span>
|
||||
: row.allowedModels.join(', ')}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{budgetDraft?.id === row.id ? (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={budgetDraft.value}
|
||||
onChange={(e) => setBudgetDraft({ id: row.id, value: e.target.value })}
|
||||
onBlur={() => commitBudget(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitBudget(row.id);
|
||||
if (e.key === 'Escape') setBudgetDraft(null);
|
||||
}}
|
||||
className="w-20 px-1 py-0.5 text-xs border border-slate-300 rounded text-right"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked || isConfig}
|
||||
onClick={() => setBudgetDraft({ id: row.id, value: row.tokensBudget?.toString() ?? '' })}
|
||||
className="text-left disabled:cursor-not-allowed hover:underline"
|
||||
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.tokensBudget !== null ? row.tokensBudget.toLocaleString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{rpmDraft?.id === row.id ? (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={rpmDraft.value}
|
||||
onChange={(e) => setRpmDraft({ id: row.id, value: e.target.value })}
|
||||
onBlur={() => commitRpm(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitRpm(row.id);
|
||||
if (e.key === 'Escape') setRpmDraft(null);
|
||||
}}
|
||||
className="w-16 px-1 py-0.5 text-xs border border-slate-300 rounded text-right"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked || isConfig}
|
||||
onClick={() => setRpmDraft({ id: row.id, value: row.rateLimitRpm?.toString() ?? '' })}
|
||||
className="text-left disabled:cursor-not-allowed hover:underline"
|
||||
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.rateLimitRpm !== null ? row.rateLimitRpm.toString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
{isConfig ? (
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-600">config</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 bg-accent-soft rounded text-accent">admin</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
{isRevoked ? (
|
||||
<span className="px-1.5 py-0.5 bg-red-50 text-red-700 rounded">revoked</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 bg-green-50 text-green-700 rounded">active</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUsagePanelId(row.id)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
詳細
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked}
|
||||
onClick={() => handleRotate(row)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked}
|
||||
onClick={() => handleRevoke(row)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
Tokens budget は月次 UTC でリセット。Rate limit (rpm) は 60 秒スライディングウィンドウ。
|
||||
config-import のキー(config.yaml から取り込まれたもの)は値の編集ができません。
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<GatewayKeyCreateDialog
|
||||
onCancel={() => setCreating(false)}
|
||||
onSubmit={handleCreate}
|
||||
submitting={createSubmitting}
|
||||
error={createError}
|
||||
/>
|
||||
)}
|
||||
{rawDialog && (
|
||||
<GatewayKeyRawKeyDialog
|
||||
rawKey={rawDialog.rawKey}
|
||||
team={rawDialog.team}
|
||||
reason={rawDialog.reason}
|
||||
onClose={() => setRawDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{usagePanelId && (
|
||||
<GatewayKeyUsagePanel
|
||||
keyId={usagePanelId}
|
||||
onClose={() => setUsagePanelId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
import { getGatewayServerStatus, type GatewayServerStatus } from '../../api';
|
||||
import { GatewayKeysSection } from './GatewayKeysSection';
|
||||
|
||||
/**
|
||||
* Settings → LLM → Gateway Server.
|
||||
*
|
||||
* Sections (top → bottom):
|
||||
* - Enable toggle + live status badge
|
||||
* - Listen port
|
||||
* - Backends list (config-driven, draft/Save&Apply)
|
||||
* - Virtual Keys (key management — admin REST API, applied immediately)
|
||||
* - Advanced timeouts (request / upstream / shutdown)
|
||||
*
|
||||
* Step 8 of the 2026-05-21 settings restructure folded the standalone
|
||||
* Gateway Keys sidebar entry into this form as the "Virtual Keys"
|
||||
* section, so key issuance / rotation / revocation lives next to the
|
||||
* Gateway it configures. The keys section uses its own admin REST API
|
||||
* and therefore bypasses the surrounding Save & Apply bar — that's why
|
||||
* it's allowed to share this form even though it doesn't touch
|
||||
* `config.gateway.*`.
|
||||
*
|
||||
* Status badge polls /api/admin/gateway/status every 3s so an enable
|
||||
* flip is reflected near-instantly without a page reload.
|
||||
*
|
||||
* Field names are camelCase to match the in-memory AppConfig shape
|
||||
* (src/config.ts:transformKeys converts YAML snake_case → camelCase on
|
||||
* load, and toSnakeKeys reverses on save). The displayed labels keep the
|
||||
* YAML names (max_slots, api_key, ...) so operators can map back to
|
||||
* config.yaml.example without translation.
|
||||
*/
|
||||
interface GatewayBackend {
|
||||
id?: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
maxSlots?: number;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
interface GatewayConfigShape {
|
||||
enabled?: boolean;
|
||||
listenPort?: number;
|
||||
requestTimeoutSec?: number;
|
||||
upstreamTimeoutSec?: number;
|
||||
shutdownGracefulSec?: number;
|
||||
backends?: GatewayBackend[];
|
||||
virtualKeys?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render value for a `<FieldInput type="number">`. Returns the number
|
||||
* when it's a finite integer-typed value, otherwise `fallback`. Without
|
||||
* this, `value={NaN ?? 1}` resolves to `NaN` (nullish-coalesce only
|
||||
* traps null/undefined), and React renders the literal string "NaN"
|
||||
* into the input — see https://gitea.example.com/.../issues for the
|
||||
* Phase 3c regression that motivated this helper.
|
||||
*/
|
||||
function numberValue(n: unknown, fallback: number | ''): number | '' {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the string emitted by a number `<FieldInput>` into either a
|
||||
* finite number, or `undefined` for empty / unparseable input. Storing
|
||||
* `undefined` (rather than NaN) keeps the next render's value clean.
|
||||
*/
|
||||
function parseNumberInput(v: string): number | undefined {
|
||||
if (v === '') return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: GatewayServerStatus | undefined }) {
|
||||
if (!status) {
|
||||
return <span className="text-2xs text-slate-400">…</span>;
|
||||
}
|
||||
if (status.state === 'unavailable') {
|
||||
return (
|
||||
<span title={status.message} className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||
unavailable
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.state === 'running') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
running (mounted at /v1, port {status.sharedPort})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.state === 'misconfigured') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200">
|
||||
misconfigured ({status.errors.length} error{status.errors.length === 1 ? '' : 's'})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.state === 'starting' || status.state === 'stopping') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
{status.state}…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||
disabled
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backend rows in-form so the operator sees red-bordered fields
|
||||
* before they hit Save. Returns a per-row error map keyed by row index.
|
||||
*/
|
||||
function validateBackends(backends: GatewayBackend[]): Map<number, string[]> {
|
||||
const errors = new Map<number, string[]>();
|
||||
const seenIds = new Set<string>();
|
||||
backends.forEach((b, i) => {
|
||||
const rowErrs: string[] = [];
|
||||
if (!b.id || b.id.trim() === '') rowErrs.push('id required');
|
||||
else if (seenIds.has(b.id)) rowErrs.push('duplicate id');
|
||||
if (b.id) seenIds.add(b.id);
|
||||
if (!b.endpoint || b.endpoint.trim() === '') rowErrs.push('endpoint required');
|
||||
else {
|
||||
try {
|
||||
const u = new URL(b.endpoint);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
rowErrs.push('endpoint must be http(s)');
|
||||
}
|
||||
} catch {
|
||||
rowErrs.push('endpoint invalid URL');
|
||||
}
|
||||
}
|
||||
if (!b.model || b.model.trim() === '') rowErrs.push('model required');
|
||||
if (
|
||||
typeof b.maxSlots !== 'number'
|
||||
|| !Number.isFinite(b.maxSlots)
|
||||
|| b.maxSlots <= 0
|
||||
|| !Number.isInteger(b.maxSlots)
|
||||
) {
|
||||
rowErrs.push('max_slots must be positive integer');
|
||||
}
|
||||
if (rowErrs.length > 0) errors.set(i, rowErrs);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function GatewayServerForm({ config, onChange }: SectionFormProps) {
|
||||
const gw: GatewayConfigShape = config.gateway ?? {};
|
||||
const backends: GatewayBackend[] = Array.isArray(gw.backends) ? gw.backends : [];
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['gateway-server-status'],
|
||||
queryFn: getGatewayServerStatus,
|
||||
refetchInterval: 3000,
|
||||
staleTime: 1000,
|
||||
});
|
||||
|
||||
const backendErrors = useMemo(() => validateBackends(backends), [backends]);
|
||||
|
||||
const setEnabled = (v: boolean) => onChange('gateway.enabled', v);
|
||||
const setListenPort = (v: number | undefined) => onChange('gateway.listenPort', v);
|
||||
const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v);
|
||||
const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v);
|
||||
const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', v);
|
||||
|
||||
const updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => {
|
||||
const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b));
|
||||
onChange('gateway.backends', next);
|
||||
};
|
||||
const addBackend = () => {
|
||||
const next: GatewayBackend = {
|
||||
id: `backend-${backends.length + 1}`,
|
||||
endpoint: '',
|
||||
model: '',
|
||||
maxSlots: 1,
|
||||
};
|
||||
onChange('gateway.backends', [...backends, next]);
|
||||
};
|
||||
const removeBackend = (i: number) => {
|
||||
onChange('gateway.backends', backends.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">Gateway Server</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
AAO 自身を LLM Gateway として動かす。有効にすると <code>/v1/chat/completions</code> などのエンドポイントが、worker UI と <strong>同じポート</strong>で待ち受けます (別 process 起動は不要)。他 AAO の <code>provider.workers[].endpoint</code> にこの URL を指定して GPU プールを共有できます。
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gw.enabled === true}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-medium text-slate-700">Enable Gateway</span>
|
||||
</label>
|
||||
<StatusBadge status={statusQuery.data} />
|
||||
</div>
|
||||
{statusQuery.data?.errors && statusQuery.data.errors.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-red-700 bg-red-50 border border-red-200 rounded p-2 space-y-0.5">
|
||||
{statusQuery.data.errors.map((e, i) => (
|
||||
<li key={i}>• {e}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<FieldLabel>Listen port</FieldLabel>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.listenPort, 4000)}
|
||||
onChange={v => setListenPort(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>
|
||||
<strong>同 process 時はこの値は使われません</strong>: worker UI と同じポート (
|
||||
{statusQuery.data?.sharedPort ?? '9876'}) を共有します。<code>AAO_MODE=gateway</code> で別 process 起動した場合のみ有効。
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 pt-1.5">
|
||||
別 process deploy:{' '}
|
||||
<code className="text-2xs">AAO_MODE=gateway scripts/gateway.sh start</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h3 className="text-sm font-medium text-slate-700">Backends</h3>
|
||||
<button
|
||||
onClick={addBackend}
|
||||
className="px-2.5 h-7 text-xs text-accent border border-accent rounded-md hover:bg-accent-soft"
|
||||
>
|
||||
+ Add backend
|
||||
</button>
|
||||
</div>
|
||||
<HelpText>
|
||||
ルーティング先の llama-server / Ollama / vLLM など。Gateway は <code>request.model</code> に一致する <code>model</code> を持つ最も busy ではない backend に割り振ります。<br/>
|
||||
<strong>api_key の保存形式</strong>: フォームで入力した値は <code>config.yaml</code> に平文で保存されます。<code>${'${VAR}'}</code> 形式の env var 参照はフォーム保存時に literal 文字列として保存されるため、env 経由で渡したい場合は <code>config.yaml</code> を直接編集してください。
|
||||
</HelpText>
|
||||
{backends.length === 0 ? (
|
||||
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 mt-2 text-center">
|
||||
backend が未登録です。最低 1 つ追加してください。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mt-2">
|
||||
{backends.map((b, i) => {
|
||||
const errs = backendErrors.get(i) ?? [];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border rounded-md p-3 space-y-2 relative ${errs.length > 0 ? 'border-red-200 bg-red-50/30' : 'border-slate-200'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => removeBackend(i)}
|
||||
className="absolute top-1.5 right-2 text-slate-400 hover:text-red-500 text-lg leading-none"
|
||||
title="この backend を削除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div>
|
||||
<FieldLabel>id</FieldLabel>
|
||||
<FieldInput value={b.id ?? ''} onChange={v => updateBackend(i, 'id', v)} placeholder="gpu-rtx-a" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>model</FieldLabel>
|
||||
<FieldInput value={b.model ?? ''} onChange={v => updateBackend(i, 'model', v)} placeholder="qwen3:8b" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>endpoint</FieldLabel>
|
||||
<FieldInput value={b.endpoint ?? ''} onChange={v => updateBackend(i, 'endpoint', v)} placeholder="http://gpu-host:8080/v1" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>max_slots</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(b.maxSlots, 1)}
|
||||
onChange={v => updateBackend(i, 'maxSlots', parseNumberInput(v))}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>api_key (任意)</FieldLabel>
|
||||
<FieldInput
|
||||
type="password"
|
||||
value={b.apiKey ?? ''}
|
||||
onChange={v => updateBackend(i, 'apiKey', v || undefined)}
|
||||
placeholder="sk-... or ${ENV_VAR}"
|
||||
/>
|
||||
{/* G2: warn when the operator saves a literal
|
||||
${VAR} reference. The config writer stores
|
||||
fields verbatim — env substitution happens at
|
||||
load time, so saving the form turns the
|
||||
reference into a literal "${VAR}" string and
|
||||
the env var indirection is lost. */}
|
||||
{typeof b.apiKey === 'string' && b.apiKey.trimStart().startsWith('${') && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||
env var reference detected: 保存すると <code>{b.apiKey}</code> がそのまま config.yaml に書き込まれ、起動時の env 置換は効かなくなります。env 経由で渡すなら config.yaml を直接編集してください。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{errs.length > 0 && (
|
||||
<ul className="text-2xs text-red-600 list-disc pl-4 space-y-0.5">
|
||||
{errs.map((e, ei) => <li key={ei}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-3">
|
||||
<div className="mb-1.5">
|
||||
<h3 className="text-sm font-medium text-slate-700">Virtual Keys</h3>
|
||||
</div>
|
||||
<HelpText>
|
||||
この Gateway を経由してアクセスするための <code>sk-aao-*</code> bearer key を発行・rotate・revoke します。<br/>
|
||||
<strong>注意</strong>: ここでの操作は Gateway Server の Save & Apply とは独立した admin API で即時反映されます (Save ボタンを押す必要はありません)。
|
||||
</HelpText>
|
||||
<div className="mt-2">
|
||||
<GatewayKeysSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="border-t border-hairline pt-3 group">
|
||||
<summary className="text-sm font-medium text-slate-700 cursor-pointer">
|
||||
Advanced
|
||||
</summary>
|
||||
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||
<div>
|
||||
<FieldLabel>request_timeout_sec</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.requestTimeoutSec, 600)}
|
||||
onChange={v => setRequestTimeout(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>chat 全体の budget (streaming 含む)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>upstream_timeout_sec</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.upstreamTimeoutSec, 30)}
|
||||
onChange={v => setUpstreamTimeout(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>1 chunk あたりの idle 上限</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>shutdown_graceful_sec</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={numberValue(gw.shutdownGracefulSec, 30)}
|
||||
onChange={v => setShutdownGraceful(parseNumberInput(v))}
|
||||
/>
|
||||
<HelpText>SIGTERM 後の drain 上限</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">
|
||||
<p>
|
||||
<strong>Hot reload:</strong> ここでの変更は Save 直後に同 process gateway に反映されます (backend / virtual_key 変更は bounce が発生し、in-flight ストリームは graceful drain されます)。
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function HelpText({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs text-slate-400 mt-1">{children}</p>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { NamespaceEditor } from './NamespaceEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Legacy Knowledge (DKS) namespace settings.
|
||||
*
|
||||
* Replaces the `knowledge` tab of the legacy grab-bag `ToolsForm`.
|
||||
* The config keys are unchanged:
|
||||
*
|
||||
* tools.knowledge_service_url
|
||||
* tools.knowledge_namespaces
|
||||
*
|
||||
* Marked as legacy in PR #357; new knowledge integrations should go
|
||||
* through MCP servers. Existing namespaces remain editable / removable,
|
||||
* but adding new namespaces is disabled in the editor.
|
||||
*/
|
||||
export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps) {
|
||||
const tools = config.tools ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-800">Knowledge (DKS)</h2>
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-800 border border-amber-300"
|
||||
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
|
||||
>
|
||||
LEGACY
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"
|
||||
>
|
||||
DKS 機能は <strong>legacy</strong> 化されており、新規の知識検索統合は{' '}
|
||||
<strong>MCP server 経由</strong> を推奨します。既存の namespace 設定は引き続き動作しますが、
|
||||
新規 namespace の追加はできません。{' '}
|
||||
<a
|
||||
href="/help"
|
||||
className="underline text-amber-900 hover:text-amber-700"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MCP 連携ガイドを開く
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Knowledge Service URL</FieldLabel>
|
||||
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
|
||||
placeholder="http://dks-server:8100" />
|
||||
<HelpText>Document Knowledge Server (DKS) の API エンドポイント。未設定時は knowledge ツール無効。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Knowledge Namespaces</FieldLabel>
|
||||
<NamespaceEditor
|
||||
value={tools.knowledgeNamespaces ?? {}}
|
||||
onChange={v => onChange('tools.knowledgeNamespaces', v)}
|
||||
addDisabled
|
||||
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
|
||||
addDisabledHref="/help"
|
||||
/>
|
||||
<HelpText>DKS の名前空間と API キーの組み合わせ。既存項目の編集・削除は可能ですが、新規追加は無効化されています。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useMemo } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import { SecretInput } from './SecretInput';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Worker entry shape used by the v2 `llm.workers[]` config block. The
|
||||
* field names mirror what the server expects after camelCase
|
||||
* conversion (see src/config.ts:transformKeys). The runtime AppConfig
|
||||
* still uses `provider.workers` internally during the v1→v2 compat
|
||||
* window, but the API surface and this UI are v2-only.
|
||||
*/
|
||||
interface LlmWorker {
|
||||
id?: string;
|
||||
connectionType?: 'direct' | 'aao_gateway';
|
||||
endpoint?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
roles?: string[];
|
||||
maxConcurrency?: number;
|
||||
enabled?: boolean;
|
||||
vlm?: boolean;
|
||||
/**
|
||||
* Phase 1 compat: older `provider.workers[].proxy: true` rows are
|
||||
* mapped to `connectionType: aao_gateway` by the normalizer. We
|
||||
* still surface the field name so the UI can read legacy drafts
|
||||
* that haven't been migrated yet.
|
||||
*/
|
||||
proxy?: boolean;
|
||||
}
|
||||
|
||||
interface LlmConfigShape {
|
||||
timeoutMinutes?: number;
|
||||
retry?: {
|
||||
maxAttempts?: number;
|
||||
backoffMs?: number[];
|
||||
retryableStatus?: number[];
|
||||
};
|
||||
workers?: LlmWorker[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an `aao_gateway` worker's endpoint appears to point at
|
||||
* the current AAO instance itself. Heuristic only — reverse proxies
|
||||
* and deployment-specific hostnames can defeat this, so we never block
|
||||
* save; the warning is purely an "are you sure?" hint.
|
||||
*
|
||||
* Triggers when the endpoint host is:
|
||||
* - `localhost` / `127.0.0.1` / `::1`
|
||||
* - the same host as `window.location.host` (excluding port mismatch
|
||||
* — a separate gateway process on the same box is legitimate)
|
||||
*
|
||||
* Phase 2 (out of scope for this PR) will replace this with a hard
|
||||
* UUID check against `/aao/instance-id`.
|
||||
*/
|
||||
function detectSelfLoop(endpoint: string | undefined): boolean {
|
||||
if (!endpoint) return false;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
|
||||
// Match against the browser's current hostname — same host, regardless
|
||||
// of port. This catches `http://my-aao.example/v1` when the operator
|
||||
// is editing settings on `my-aao.example` itself.
|
||||
if (typeof window !== 'undefined' && window.location?.hostname) {
|
||||
return host === window.location.hostname.toLowerCase();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → LLM → Workers.
|
||||
*
|
||||
* This is the v2 replacement for the old `ProviderForm` + inline
|
||||
* `WorkersBlock` pair. The big differences from the v1 forms:
|
||||
*
|
||||
* - reads/writes `llm.workers[]` instead of `provider.workers[]`
|
||||
* (the v1 form rendered empty after the API switched to v2 shape)
|
||||
* - each row carries `connectionType: direct | aao_gateway` instead
|
||||
* of a `proxy: true` toggle, so the rendered help text and warnings
|
||||
* can be specific to the connection style
|
||||
* - api keys use the 4-state `SecretInput` editor instead of a raw
|
||||
* `<input type="password">`, so masking / env-refs / clears are
|
||||
* explicit and survive round-trip without a magic `'********'`
|
||||
* literal sneaking back into config.yaml
|
||||
* - the model field is a discovery-backed dropdown with manual
|
||||
* fallback — typing a literal still works, but Ollama-style
|
||||
* `/models` endpoints pre-populate the dropdown
|
||||
* - roles use a chip editor instead of a comma-separated string, so
|
||||
* values containing commas are no longer corrupted
|
||||
* - `aao_gateway` rows show a heuristic self-loop warning when the
|
||||
* endpoint host looks like the current AAO instance
|
||||
*/
|
||||
export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const llm: LlmConfigShape = config.llm ?? {};
|
||||
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
|
||||
const retry = llm.retry ?? {};
|
||||
|
||||
const updateWorker = (index: number, patch: Partial<LlmWorker>) => {
|
||||
const next = workers.map((w, i) => (i === index ? { ...w, ...patch } : w));
|
||||
onChange('llm.workers', next);
|
||||
};
|
||||
|
||||
const removeWorker = (index: number) => {
|
||||
onChange('llm.workers', workers.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const moveWorker = (index: number, delta: number) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= workers.length) return;
|
||||
const next = [...workers];
|
||||
const [removed] = next.splice(index, 1);
|
||||
next.splice(target, 0, removed);
|
||||
onChange('llm.workers', next);
|
||||
};
|
||||
|
||||
const addWorker = () => {
|
||||
const next: LlmWorker = {
|
||||
id: `worker-${workers.length + 1}`,
|
||||
connectionType: 'direct',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
maxConcurrency: 1,
|
||||
roles: [],
|
||||
};
|
||||
onChange('llm.workers', [...workers, next]);
|
||||
};
|
||||
|
||||
// Pre-compute self-loop verdicts once per render so we don't recompute
|
||||
// URL parsing inside the row JSX. Endpoint-only dependency is enough:
|
||||
// connection_type is checked at render site.
|
||||
const selfLoopFlags = useMemo(
|
||||
() => workers.map(w => detectSelfLoop(w.endpoint)),
|
||||
[workers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">LLM Workers</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
このセクションは AAO がジョブ実行で <strong>呼び出す</strong> LLM 接続先 (workers)
|
||||
を定義します。AAO 自身を gateway として公開する設定は <em>LLM → Gateway Server</em>
|
||||
にあります。<br />
|
||||
ロール: <code>auto</code> (全 job 候補) / <code>fast</code> · <code>quality</code>
|
||||
(パフォーマンス profile) / <code>reflection</code> (reflection 専用) /{' '}
|
||||
<code>title</code> (タイトル生成専用)。複数指定可。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{workers.length === 0 && (
|
||||
<div className="text-xs text-slate-500 border border-dashed border-slate-200 rounded p-4 text-center">
|
||||
worker が未登録です。最低 1 つ追加してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workers.map((w, i) => {
|
||||
const isGateway = w.connectionType === 'aao_gateway' || w.proxy === true;
|
||||
const showSelfLoop = isGateway && selfLoopFlags[i];
|
||||
const endpointOverridden = i === 0 && overriddenByEnv['llm.workers[0].endpoint'];
|
||||
const modelOverridden = i === 0 && overriddenByEnv['llm.workers[0].model'];
|
||||
return (
|
||||
<div key={i} className="border border-slate-200 rounded-lg p-4 space-y-3 relative">
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => moveWorker(i, -1)}
|
||||
disabled={i === 0}
|
||||
title="上に移動"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveWorker(i, 1)}
|
||||
disabled={i === workers.length - 1}
|
||||
title="下に移動"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeWorker(i)}
|
||||
title="この worker を削除"
|
||||
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>ID</FieldLabel>
|
||||
<FieldInput value={w.id ?? ''} onChange={v => updateWorker(i, { id: v })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Connection type</FieldLabel>
|
||||
<select
|
||||
value={w.connectionType ?? (w.proxy === true ? 'aao_gateway' : 'direct')}
|
||||
onChange={e => {
|
||||
const next = e.target.value as 'direct' | 'aao_gateway';
|
||||
// Keep the legacy `proxy` flag in sync so an
|
||||
// operator who downgrades to a v1 build doesn't
|
||||
// lose the routing semantics.
|
||||
updateWorker(i, {
|
||||
connectionType: next,
|
||||
proxy: next === 'aao_gateway' ? true : undefined,
|
||||
});
|
||||
}}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
<option value="direct">Direct (Ollama / vLLM / llama.cpp)</option>
|
||||
<option value="aao_gateway">AAO Gateway</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>Endpoint</FieldLabel>
|
||||
<FieldInput
|
||||
value={w.endpoint ?? ''}
|
||||
onChange={v => updateWorker(i, { endpoint: v })}
|
||||
disabled={!!endpointOverridden}
|
||||
disabledReason="OLLAMA_BASE_URL 環境変数で上書き中"
|
||||
placeholder={
|
||||
isGateway
|
||||
? 'http://gateway.example.com:9876/v1'
|
||||
: 'http://localhost:11434/v1'
|
||||
}
|
||||
/>
|
||||
{endpointOverridden && <EnvOverrideWarning />}
|
||||
{showSelfLoop && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||
endpoint は自インスタンスを指しているように見えます (self-loop)。
|
||||
リバースプロキシ越しの場合はこの警告は無視できます。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>API key{isGateway ? ' (必須)' : ' (任意)'}</FieldLabel>
|
||||
<SecretInput
|
||||
rawValue={w.apiKey ?? ''}
|
||||
onChange={v => updateWorker(i, { apiKey: v === '' ? '' : v })}
|
||||
placeholder={isGateway ? 'sk-aao-...' : 'sk-... (任意)'}
|
||||
/>
|
||||
<HelpText>
|
||||
{isGateway ? (
|
||||
<>
|
||||
他 AAO の <em>LLM → Gateway Server</em> で発行した{' '}
|
||||
<code>sk-aao-*</code> を貼り付けてください。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。
|
||||
</>
|
||||
)}
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>Model</FieldLabel>
|
||||
<ModelSelect
|
||||
value={w.model ?? ''}
|
||||
onChange={v => updateWorker(i, { model: v || undefined })}
|
||||
endpoint={w.endpoint}
|
||||
apiKeyRaw={w.apiKey}
|
||||
/>
|
||||
{modelOverridden && <EnvOverrideWarning />}
|
||||
<HelpText>
|
||||
endpoint が <code>/models</code> を返せば dropdown に候補が出ます。
|
||||
出ない場合 (auth が必要、proxy 越し等) は直接入力してください。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>Roles</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={Array.isArray(w.roles) ? w.roles : []}
|
||||
onChange={roles => updateWorker(i, { roles })}
|
||||
placeholder="auto / fast / quality / reflection / title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>最大同時実行数</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={w.maxConcurrency ?? 1}
|
||||
onChange={v => updateWorker(i, { maxConcurrency: Number(v) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5 pt-5 flex-wrap">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.enabled !== false}
|
||||
onChange={e => updateWorker(i, { enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
有効
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
|
||||
title="VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.vlm === true}
|
||||
onChange={e => updateWorker(i, { vlm: e.target.checked || undefined })}
|
||||
className="rounded"
|
||||
/>
|
||||
VLM
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={addWorker}
|
||||
className="px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft"
|
||||
>
|
||||
+ Worker を追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Global LLM Settings
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Timeout (minutes)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={llm.timeoutMinutes ?? 10}
|
||||
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
|
||||
/>
|
||||
<HelpText>LLM リクエストのタイムアウト (分)。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Retry (per-call HTTP)
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={retry.maxAttempts ?? 3}
|
||||
onChange={v => onChange('llm.retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>1 回の LLM API 呼び出しでの最大試行回数</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Backoff (ms)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={Array.isArray(retry.backoffMs) ? retry.backoffMs.map(n => String(n)) : []}
|
||||
onChange={vs => {
|
||||
const nums = vs.map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
onChange('llm.retry.backoffMs', nums);
|
||||
}}
|
||||
placeholder="2000"
|
||||
/>
|
||||
<HelpText>各リトライ間の待機時間 (ms)。配列順に消費されます。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Retryable Status Codes</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={Array.isArray(retry.retryableStatus) ? retry.retryableStatus.map(n => String(n)) : []}
|
||||
onChange={vs => {
|
||||
const nums = vs.map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
onChange('llm.retry.retryableStatus', nums);
|
||||
}}
|
||||
placeholder="429"
|
||||
/>
|
||||
<HelpText>リトライ対象の HTTP ステータスコード。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
// keep in sync with src/mcp/config.ts McpRuntimeConfig
|
||||
interface McpRuntimeConfig {
|
||||
callTimeoutSeconds: number;
|
||||
maxBinarySizeMb: number;
|
||||
maxOutputFilesPerJob: number;
|
||||
maxOutputSizeMbPerJob: number;
|
||||
toolCacheTtlSeconds: number;
|
||||
oauthPendingTtlMinutes: number;
|
||||
allowPrivateAddresses: boolean;
|
||||
}
|
||||
|
||||
export function McpForm({ config, onChange }: SectionFormProps) {
|
||||
const mcp: Partial<McpRuntimeConfig> = config.mcp ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">MCP</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
外部 MCP (Model Context Protocol) サーバーの接続・実行に関する設定です。
|
||||
接続先サーバーを追加する場合は、各タスクまたは設定から MCP サーバー URL を指定してください。
|
||||
</p>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">セキュリティ</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mcp.allowPrivateAddresses === true}
|
||||
onChange={e => onChange('mcp.allowPrivateAddresses', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
プライベート IP への接続を許可する (self-hosted / localhost MCP サーバー用)
|
||||
</label>
|
||||
<HelpText>
|
||||
有効にすると、localhost・LAN アドレス (192.168.x.x, 10.x.x.x 等) への MCP 接続を許可します。
|
||||
SSRF リスクがあるため、信頼できるネットワーク環境でのみ使用してください。デフォルト: 無効
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">タイムアウト / キャッシュ</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール呼び出しタイムアウト (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.callTimeoutSeconds ?? 60}
|
||||
onChange={v => onChange('mcp.callTimeoutSeconds', Number(v))} />
|
||||
<HelpText>MCP ツールの 1 回の呼び出しに許容する最大時間(秒)。デフォルト: 60</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール一覧キャッシュ TTL (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.toolCacheTtlSeconds ?? 600}
|
||||
onChange={v => onChange('mcp.toolCacheTtlSeconds', Number(v))} />
|
||||
<HelpText>MCP サーバーから取得したツール一覧をキャッシュする時間(秒)。デフォルト: 600</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>OAuth pending state TTL (分)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.oauthPendingTtlMinutes ?? 10}
|
||||
onChange={v => onChange('mcp.oauthPendingTtlMinutes', Number(v))} />
|
||||
<HelpText>MCP OAuth 認可フローの pending 状態を保持する時間(分)。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">容量制限</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ツール出力バイナリ 1 個あたり最大サイズ (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxBinarySizeMb ?? 20}
|
||||
onChange={v => onChange('mcp.maxBinarySizeMb', Number(v))} />
|
||||
<HelpText>MCP ツールが返すバイナリ出力 1 ファイルの最大サイズ(MB)。デフォルト: 20</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ジョブあたり最大バイナリファイル数</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxOutputFilesPerJob ?? 10}
|
||||
onChange={v => onChange('mcp.maxOutputFilesPerJob', Number(v))} />
|
||||
<HelpText>1 ジョブで MCP ツールが保存できるバイナリファイルの最大数。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ジョブあたり最大バイナリ合計サイズ (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={mcp.maxOutputSizeMbPerJob ?? 200}
|
||||
onChange={v => onChange('mcp.maxOutputSizeMbPerJob', Number(v))} />
|
||||
<HelpText>1 ジョブで MCP ツールが保存できるバイナリ出力の合計最大サイズ(MB)。デフォルト: 200</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
/**
|
||||
* MemoryLearningForm.tsx — "Memory & Learning" settings section
|
||||
*
|
||||
* Two stacked panels:
|
||||
* 1. MemoryEntriesPanel — list / inline-edit / delete user memory entries
|
||||
* 2. ReflectionTimelinePanel — paged snapshot history + revert + 30-day metrics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MemoryType = 'user' | 'feedback' | 'project' | 'reference';
|
||||
|
||||
// Mirrors the server's flat shape from `listMemoryEntries` in
|
||||
// src/user-folder/memory.ts and `GET /api/local/memory/entries` in
|
||||
// src/bridge/memory-api.ts. If you change this shape, update both.
|
||||
interface MemoryEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface MemoryListResponse {
|
||||
entries: MemoryEntry[];
|
||||
index: string;
|
||||
}
|
||||
|
||||
interface SnapshotIndexEntry {
|
||||
ts: string;
|
||||
snapshotId: string;
|
||||
jobId: string;
|
||||
pieceName: string;
|
||||
memoryChanges: number;
|
||||
pieceEdited: boolean;
|
||||
reverted: boolean;
|
||||
// outcome appears in detail, not in index — fetched lazily
|
||||
}
|
||||
|
||||
interface SnapshotDetail {
|
||||
snapshotId: string;
|
||||
ts: string;
|
||||
originalJobId: string;
|
||||
userId: string;
|
||||
pieceName: string;
|
||||
outcome: string;
|
||||
reasoning: string;
|
||||
modelUsed?: string;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
ratingAtTime?: 'good' | 'bad' | null;
|
||||
memoryChanges: number;
|
||||
pieceEdited: boolean;
|
||||
rejections?: Array<{ code: string; name?: string }>;
|
||||
beforeFiles: Record<string, string>;
|
||||
afterFiles: Record<string, string>;
|
||||
pieceBeforeYaml?: string;
|
||||
pieceAfterYaml?: string;
|
||||
diff?: string;
|
||||
}
|
||||
|
||||
interface HistoryPage {
|
||||
items: SnapshotIndexEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
interface ReflectionMetrics {
|
||||
applied: number;
|
||||
partial: number;
|
||||
abstained: number;
|
||||
rejected: number;
|
||||
failed: number;
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
pieceEdits: number;
|
||||
}
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchMemoryEntries(): Promise<MemoryListResponse> {
|
||||
const res = await fetch('/api/local/memory/entries');
|
||||
if (!res.ok) throw new Error(`メモリエントリの読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function upsertMemoryEntry(
|
||||
name: string,
|
||||
payload: { description: string; type: MemoryType; body: string },
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (!res.ok) throw new Error(data.error ?? res.statusText);
|
||||
}
|
||||
|
||||
async function deleteMemoryEntry(name: string): Promise<void> {
|
||||
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error ?? res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistoryPage(cursor?: string): Promise<HistoryPage> {
|
||||
const params = new URLSearchParams({ limit: '20' });
|
||||
if (cursor) params.set('before', cursor);
|
||||
const res = await fetch(`/api/local/reflection/history?${params}`);
|
||||
if (!res.ok) throw new Error(`履歴の読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchSnapshotDetail(snapshotId: string): Promise<SnapshotDetail> {
|
||||
const res = await fetch(`/api/local/reflection/history/${encodeURIComponent(snapshotId)}`);
|
||||
if (!res.ok) throw new Error(`スナップショットの読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function revertSnapshot(snapshotId: string): Promise<{ reverted: boolean }> {
|
||||
const res = await fetch(
|
||||
`/api/local/reflection/history/${encodeURIComponent(snapshotId)}/revert`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error ?? res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchMetrics(days: number = 30): Promise<ReflectionMetrics> {
|
||||
const res = await fetch(`/api/local/reflection/metrics?days=${days}`);
|
||||
if (!res.ok) throw new Error(`メトリクスの読み込みに失敗しました (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Shared UI primitives ──────────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
applied: { label: '適用済み', cls: 'bg-emerald-100 text-emerald-800' },
|
||||
partial: { label: '一部適用', cls: 'bg-yellow-100 text-yellow-800' },
|
||||
abstained: { label: '学習なし', cls: 'bg-slate-100 text-slate-600' },
|
||||
rejected: { label: '却下', cls: 'bg-red-100 text-red-700' },
|
||||
failed: { label: '失敗', cls: 'bg-red-200 text-red-900' },
|
||||
};
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: string }) {
|
||||
const { label, cls } = OUTCOME_LABELS[outcome] ?? { label: outcome, cls: 'bg-slate-100 text-slate-600' };
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTs(ts: string): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validator rejection code messages ─────────────────────────────────────────
|
||||
|
||||
const REJECTION_MESSAGES: Record<string, string> = {
|
||||
rejected_bad_name: '名前が無効です(英数字・ハイフン・アンダースコア、1〜64文字)',
|
||||
rejected_bad_description: '概要は必須で、1行以内で入力してください',
|
||||
rejected_unknown_type: 'タイプは user / feedback / project / reference のいずれかを指定してください',
|
||||
rejected_bad_body: '本文は文字列で指定してください',
|
||||
rejected_body_too_large: '本文が許容サイズを超えています',
|
||||
rejected_bad_request: 'リクエストの形式が正しくありません',
|
||||
};
|
||||
|
||||
function rejectionMessage(code: string): string {
|
||||
return REJECTION_MESSAGES[code] ?? code;
|
||||
}
|
||||
|
||||
// ── MemoryEntryModal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface EntryFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
type: MemoryType;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MEMORY_TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
function MemoryEntryModal({
|
||||
initial,
|
||||
isNew,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
initial: EntryFormState;
|
||||
isNew: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<EntryFormState>(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const set = <K extends keyof EntryFormState>(k: K, v: EntryFormState[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await upsertMemoryEntry(form.name, {
|
||||
description: form.description,
|
||||
type: form.type,
|
||||
body: form.body,
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(rejectionMessage(e.message));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline">
|
||||
<h3 className="text-sm font-semibold text-slate-800">
|
||||
{isNew ? '新しいメモリエントリ' : `編集 — ${initial.name}`}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700 text-lg leading-none"
|
||||
aria-label="閉じる"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 space-y-3 flex-1">
|
||||
{/* Name — only editable when creating */}
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">
|
||||
名前
|
||||
{isNew && <span className="text-slate-400 ml-1">(英数字・ハイフン・アンダースコア)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
disabled={!isNew}
|
||||
placeholder="my-fact"
|
||||
className={`w-full h-8 px-2.5 text-[13px] font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${
|
||||
!isNew ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : 'bg-white'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">概要</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={e => set('description', e.target.value)}
|
||||
placeholder="メモリ一覧に表示される1行の説明"
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">タイプ</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={e => set('type', e.target.value as MemoryType)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring outline-none bg-white"
|
||||
>
|
||||
{MEMORY_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<HelpText>
|
||||
user: あなた固有の好み・役割 / feedback: 過去のフィードバック・教訓 / project: プロジェクト別の文脈 / reference: 参照資料・外部情報
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs font-medium text-slate-600 mb-1">本文</label>
|
||||
<textarea
|
||||
value={form.body}
|
||||
onChange={e => set('body', e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-2.5 py-2 text-xs font-mono border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none resize-y"
|
||||
placeholder="Markdown またはプレーンテキスト…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 border border-red-200 px-3 py-2 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-4 py-3 border-t border-hairline">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 h-8 text-xs text-slate-700 border border-hairline bg-white rounded-md hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !form.name.trim() || !form.description.trim()}
|
||||
className="px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemoryEntriesPanel ────────────────────────────────────────────────────────
|
||||
|
||||
function MemoryEntriesPanel() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery<MemoryListResponse>({
|
||||
queryKey: ['memory-entries'],
|
||||
queryFn: fetchMemoryEntries,
|
||||
});
|
||||
|
||||
const [modal, setModal] = useState<{ entry: EntryFormState; isNew: boolean } | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleNew = () => {
|
||||
setModal({
|
||||
isNew: true,
|
||||
entry: { name: '', description: '', type: 'user', body: '' },
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (e: MemoryEntry) => {
|
||||
setModal({
|
||||
isNew: false,
|
||||
entry: {
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
type: e.type,
|
||||
body: e.body,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!confirm(`メモリエントリ「${name}」を削除しますか?`)) return;
|
||||
setDeleting(name);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteMemoryEntry(name);
|
||||
await qc.invalidateQueries({ queryKey: ['memory-entries'] });
|
||||
} catch (e: any) {
|
||||
setDeleteError(`「${name}」の削除に失敗しました: ${e.message}`);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaved = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['memory-entries'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-hairline">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-hairline bg-surface rounded-t-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-800">メモリエントリ</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
エージェントの毎セッションに注入される永続的な情報。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNew}
|
||||
className="px-2.5 h-7 text-2xs font-medium bg-accent text-accent-fg rounded-md hover:bg-accent-deep transition-colors"
|
||||
>
|
||||
+ 新しいエントリ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="px-4 py-6 text-xs text-slate-400 text-center">読み込み中…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-xs text-red-600">
|
||||
メモリエントリの読み込みに失敗しました: {String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteError && (
|
||||
<div className="px-4 py-2 text-xs text-red-600 bg-red-50">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.entries.length === 0 && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-xs text-slate-400">メモリエントリはまだありません。</p>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
タスク完了後に reflection エンジンが自動で追加します。
|
||||
手動で追加することもできます。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="divide-y divide-hairline">
|
||||
{data.entries.map(entry => (
|
||||
<li key={entry.name} className="flex items-start gap-3 px-4 py-3 hover:bg-surface/60 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono font-medium text-slate-800 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 flex-shrink-0">
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 mt-0.5 truncate">{entry.description}</p>
|
||||
{entry.body && (
|
||||
<p className="text-2xs text-slate-400 mt-0.5 line-clamp-2 font-mono whitespace-pre-wrap break-words">
|
||||
{entry.body.slice(0, 200)}{entry.body.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="px-2 h-6 text-2xs text-slate-600 border border-hairline bg-white hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(entry.name)}
|
||||
disabled={deleting === entry.name}
|
||||
className="px-2 h-6 text-2xs text-red-700 border border-red-200 bg-white hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === entry.name ? '…' : '削除'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<MemoryEntryModal
|
||||
initial={modal.entry}
|
||||
isNew={modal.isNew}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SnapshotCard ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onReverted: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [confirmRevert, setConfirmRevert] = useState(false);
|
||||
const [revertDone, setRevertDone] = useState<boolean | null>(null);
|
||||
|
||||
const detailQuery = useQuery<SnapshotDetail>({
|
||||
queryKey: ['snapshot-detail', item.snapshotId],
|
||||
queryFn: () => fetchSnapshotDetail(item.snapshotId),
|
||||
enabled: expanded,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const revertMutation = useMutation({
|
||||
mutationFn: () => revertSnapshot(item.snapshotId),
|
||||
onSuccess: (result) => {
|
||||
setRevertDone(result.reverted);
|
||||
setConfirmRevert(false);
|
||||
if (result.reverted) onReverted();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`border border-hairline rounded-md overflow-hidden ${item.reverted ? 'opacity-60' : ''}`}>
|
||||
{/* Header row — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(p => !p)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-surface/60 transition-colors"
|
||||
>
|
||||
<span className="text-2xs text-slate-400 flex-shrink-0 w-32 truncate" title={item.ts}>
|
||||
{formatTs(item.ts)}
|
||||
</span>
|
||||
<span className="text-2xs font-mono text-slate-700 truncate flex-1" title={item.pieceName}>
|
||||
{item.pieceName}
|
||||
</span>
|
||||
<span className="flex-shrink-0 flex items-center gap-1.5">
|
||||
{item.memoryChanges > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded">
|
||||
{item.memoryChanges} mem
|
||||
</span>
|
||||
)}
|
||||
{item.pieceEdited && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded">
|
||||
piece
|
||||
</span>
|
||||
)}
|
||||
{item.reverted && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
|
||||
revert済み
|
||||
</span>
|
||||
)}
|
||||
{detailQuery.data && <OutcomeBadge outcome={detailQuery.data.outcome} />}
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs flex-shrink-0">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div className="border-t border-hairline bg-slate-50 px-3 py-3 space-y-3">
|
||||
{detailQuery.isLoading && (
|
||||
<div className="text-xs text-slate-400">詳細を読み込み中…</div>
|
||||
)}
|
||||
{detailQuery.error && (
|
||||
<div className="text-xs text-red-600">
|
||||
読み込みに失敗しました: {String(detailQuery.error)}
|
||||
</div>
|
||||
)}
|
||||
{detailQuery.data && (() => {
|
||||
const d = detailQuery.data;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<OutcomeBadge outcome={d.outcome} />
|
||||
{d.modelUsed && (
|
||||
<span className="text-[10px] text-slate-400">{d.modelUsed}</span>
|
||||
)}
|
||||
{(d.tokensIn || d.tokensOut) && (
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{(d.tokensIn ?? 0).toLocaleString()} in / {(d.tokensOut ?? 0).toLocaleString()} out tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{d.reasoning && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
推論
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 whitespace-pre-wrap">{d.reasoning}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.rejections && d.rejections.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
却下理由
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{d.rejections.map((r, i) => (
|
||||
<li key={i} className="text-2xs text-red-700">
|
||||
<span className="font-mono">{r.code}</span>
|
||||
{r.name && <span className="text-slate-500 ml-1">({r.name})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.diff && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
変更内容
|
||||
</div>
|
||||
<pre className="text-2xs text-slate-700 bg-white border border-hairline rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap">
|
||||
{d.diff}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Before / After file diff */}
|
||||
{(Object.keys(d.beforeFiles).length > 0 || Object.keys(d.afterFiles).length > 0) && (
|
||||
<BeforeAfterDiff beforeFiles={d.beforeFiles} afterFiles={d.afterFiles} />
|
||||
)}
|
||||
|
||||
{/* Piece diff */}
|
||||
{d.pieceEdited && d.pieceBeforeYaml && d.pieceAfterYaml && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
Piece の差分
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更前</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{d.pieceBeforeYaml}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更後</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{d.pieceAfterYaml}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revert controls */}
|
||||
{!item.reverted && (
|
||||
<div className="pt-1">
|
||||
{revertDone === true && (
|
||||
<span className="text-xs text-emerald-700">正常に revert しました。</span>
|
||||
)}
|
||||
{revertDone === false && (
|
||||
<span className="text-xs text-slate-500">すでに revert 済みです。</span>
|
||||
)}
|
||||
{revertDone === null && !confirmRevert && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRevert(true)}
|
||||
className="px-2.5 h-7 text-2xs text-amber-800 border border-amber-300 bg-amber-50 hover:bg-amber-100 rounded transition-colors"
|
||||
>
|
||||
このスナップショットを revert…
|
||||
</button>
|
||||
)}
|
||||
{revertDone === null && confirmRevert && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-amber-800">
|
||||
このスナップショットの変更前の状態に戻しますか?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revertMutation.mutate()}
|
||||
disabled={revertMutation.isPending}
|
||||
className="px-2.5 h-7 text-2xs font-semibold bg-red-600 text-white hover:bg-red-700 rounded disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{revertMutation.isPending ? 'revert 中…' : 'revert を確定'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRevert(false)}
|
||||
className="px-2.5 h-7 text-2xs text-slate-600 border border-hairline bg-white hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{revertMutation.isError && (
|
||||
<div className="text-2xs text-red-600 mt-1">
|
||||
{String(revertMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BeforeAfterDiff ───────────────────────────────────────────────────────────
|
||||
|
||||
function BeforeAfterDiff({
|
||||
beforeFiles,
|
||||
afterFiles,
|
||||
}: {
|
||||
beforeFiles: Record<string, string>;
|
||||
afterFiles: Record<string, string>;
|
||||
}) {
|
||||
const allNames = Array.from(
|
||||
new Set([...Object.keys(beforeFiles), ...Object.keys(afterFiles)]),
|
||||
).sort();
|
||||
|
||||
if (allNames.length === 0) return null;
|
||||
|
||||
const [selected, setSelected] = useState(allNames[0]);
|
||||
|
||||
const before = beforeFiles[selected];
|
||||
const after = afterFiles[selected];
|
||||
const isAdded = !before && !!after;
|
||||
const isRemoved = !!before && !after;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
|
||||
メモリファイルの差分
|
||||
</div>
|
||||
{allNames.length > 1 && (
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
{allNames.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setSelected(n)}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded border ${
|
||||
selected === n
|
||||
? 'border-accent bg-accent-soft text-accent font-semibold'
|
||||
: 'border-hairline text-slate-500 hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdded && (
|
||||
<div className="text-2xs text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-2 py-1 mb-1">
|
||||
追加
|
||||
</div>
|
||||
)}
|
||||
{isRemoved && (
|
||||
<div className="text-2xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 mb-1">
|
||||
削除
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{!isAdded && (
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更前</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{before ?? '(空)'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!isRemoved && (
|
||||
<div className={isAdded ? 'col-span-2' : ''}>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">変更後</div>
|
||||
<pre className="text-[10px] bg-white border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{after ?? '(空)'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MetricsSummary ────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricsSummary() {
|
||||
const { data, isLoading, error } = useQuery<ReflectionMetrics>({
|
||||
queryKey: ['reflection-metrics', 30],
|
||||
queryFn: () => fetchMetrics(30),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-xs text-slate-400 px-4 py-3">メトリクスを読み込み中…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-xs text-red-600 px-4 py-3">
|
||||
メトリクスの読み込みに失敗しました: {String(error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const totalRuns = data.applied + data.partial + data.abstained + data.rejected + data.failed;
|
||||
const appliedPct = totalRuns > 0 ? Math.round(((data.applied + data.partial) / totalRuns) * 100) : 0;
|
||||
const abstainPct = totalRuns > 0 ? Math.round((data.abstained / totalRuns) * 100) : 0;
|
||||
const totalTokens = data.tokensIn + data.tokensOut;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 bg-slate-50 border-t border-hairline rounded-b-lg">
|
||||
<div className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
30日間のサマリ
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '合計実行回数', value: String(totalRuns) },
|
||||
{ label: '適用率', value: `${appliedPct}%` },
|
||||
{ label: '学習なし率', value: `${abstainPct}%` },
|
||||
{ label: 'Tokens', value: totalTokens > 1000 ? `${Math.round(totalTokens / 1000)}k` : String(totalTokens) },
|
||||
{ label: 'Piece 編集', value: String(data.pieceEdits) },
|
||||
].map(({ label, value }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="bg-white border border-hairline rounded px-2 py-1.5 text-center"
|
||||
>
|
||||
<div className="text-2xs font-semibold text-slate-800">{value}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalRuns === 0 && (
|
||||
<p className="text-2xs text-slate-400 mt-2">
|
||||
まだ reflection の実行履歴がありません。最初の reflection が完了するとメトリクスが表示されます。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ReflectionTimelinePanel ───────────────────────────────────────────────────
|
||||
|
||||
const OUTCOME_FILTER_OPTIONS = [
|
||||
{ value: 'applied', label: '適用済み' },
|
||||
{ value: 'partial', label: '一部適用' },
|
||||
{ value: 'abstained', label: '学習なし' },
|
||||
{ value: 'rejected', label: '却下' },
|
||||
{ value: 'failed', label: '失敗' },
|
||||
];
|
||||
|
||||
function ReflectionTimelinePanel() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Filters (client-side — the backend doesn't support filtering natively)
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<string[]>([]);
|
||||
const [includeReverted, setIncludeReverted] = useState(true);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery<HistoryPage>({
|
||||
queryKey: ['reflection-history'],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchHistoryPage(typeof pageParam === 'string' ? pageParam : undefined),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const allItems: SnapshotIndexEntry[] = (data?.pages ?? []).flatMap(p => p.items);
|
||||
|
||||
// Client-side filtering — outcome is on the detail, but index has `reverted`
|
||||
const filteredItems = allItems.filter(item => {
|
||||
if (!includeReverted && item.reverted) return false;
|
||||
// Outcome filtering is only possible after detail is loaded; skip if no filter set
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleReverted = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['reflection-history'] });
|
||||
void qc.invalidateQueries({ queryKey: ['reflection-metrics'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-hairline">
|
||||
<div className="px-4 py-3 border-b border-hairline bg-surface rounded-t-lg">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Reflection タイムライン</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
reflection 実行の履歴。各行を展開すると推論・変更前後の差分・revert コントロールを確認できます。
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||
<label className="flex items-center gap-1.5 text-2xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeReverted}
|
||||
onChange={e => setIncludeReverted(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
revert 済みを表示
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-2xs text-slate-500">結果:</span>
|
||||
{OUTCOME_FILTER_OPTIONS.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-1 text-2xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outcomeFilter.length === 0 || outcomeFilter.includes(opt.value)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setOutcomeFilter(prev =>
|
||||
prev.length === 0 ? [] : prev.filter(v => v !== opt.value).concat(opt.value),
|
||||
);
|
||||
} else {
|
||||
setOutcomeFilter(prev => {
|
||||
const next = prev.length === 0
|
||||
? OUTCOME_FILTER_OPTIONS.map(o => o.value).filter(v => v !== opt.value)
|
||||
: prev.filter(v => v !== opt.value);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
{outcomeFilter.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeFilter([])}
|
||||
className="text-[10px] text-accent underline"
|
||||
>
|
||||
リセット
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
{isLoading && (
|
||||
<div className="text-xs text-slate-400 text-center py-4">読み込み中…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 px-2">
|
||||
読み込みに失敗しました: {String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredItems.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-slate-400">まだ reflection の実行履歴がありません。</p>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
タスク完了後に自動で reflection が実行されます。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.map(item => (
|
||||
<SnapshotCard key={item.snapshotId} item={item} onReverted={handleReverted} />
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="px-3 h-8 text-xs text-slate-600 border border-hairline bg-white hover:bg-surface rounded-md disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isFetchingNextPage ? '読み込み中…' : 'さらに表示'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MetricsSummary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemoryLearningForm (root export) ──────────────────────────────────────────
|
||||
|
||||
export function MemoryLearningForm() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-base font-semibold text-slate-800">Memory & Learning</h2>
|
||||
<p className="text-xs text-slate-500 -mt-4">
|
||||
エージェントが毎セッション参照する永続的なメモリエントリを管理し、自動学習(reflection)の実行履歴を確認できます。
|
||||
</p>
|
||||
|
||||
<MemoryEntriesPanel />
|
||||
<ReflectionTimelinePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Metrics — worker (LLM) and gateway metrics endpoints.
|
||||
*
|
||||
* config v2 places worker metrics under `llm.metrics` and gateway metrics
|
||||
* under `gateway.metrics`. The fields are nearly identical: enable flag,
|
||||
* Prometheus prefix, bearer token, and allowed-hosts ACL.
|
||||
*
|
||||
* Step 3 introduces this as a navigation home — the underlying paths are
|
||||
* the v2 shape so values will appear correctly after #360/#362. If the
|
||||
* caller still has v1 data, both objects fall back to empty.
|
||||
*/
|
||||
function MetricsBlock({
|
||||
title,
|
||||
path,
|
||||
prefixDefault,
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
path: 'llm.metrics' | 'gateway.metrics';
|
||||
prefixDefault: string;
|
||||
config: any;
|
||||
onChange: (path: string, value: any) => void;
|
||||
}) {
|
||||
const root = path.split('.').reduce((acc: any, key) => (acc ?? {})[key], config) ?? {};
|
||||
return (
|
||||
<section className="space-y-4 border border-hairline rounded-md p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">{title}</h3>
|
||||
|
||||
<div>
|
||||
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={root.enabled === true}
|
||||
onChange={e => onChange(`${path}.enabled`, e.target.checked)}
|
||||
/>
|
||||
<span>有効化</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Prefix</FieldLabel>
|
||||
<FieldInput
|
||||
value={root.prefix ?? ''}
|
||||
onChange={v => onChange(`${path}.prefix`, v || undefined)}
|
||||
placeholder={prefixDefault}
|
||||
/>
|
||||
<HelpText>Prometheus metric 名の prefix(例: <code>{prefixDefault}</code>)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Bearer Token</FieldLabel>
|
||||
<FieldInput
|
||||
type="password"
|
||||
value={root.bearerToken ?? ''}
|
||||
onChange={v => onChange(`${path}.bearerToken`, v || undefined)}
|
||||
placeholder="env:METRICS_BEARER_TOKEN"
|
||||
/>
|
||||
<HelpText>
|
||||
<code>/metrics</code> エンドポイントへのアクセス時に要求される Bearer token。
|
||||
<code>env:NAME</code> で環境変数参照可。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Allowed Hosts</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={root.allowedHosts ?? []}
|
||||
onChange={v => onChange(`${path}.allowedHosts`, v)}
|
||||
placeholder="127.0.0.1 / ::1 / localhost"
|
||||
/>
|
||||
<HelpText>許可するクライアント host (IP / hostname)。空の場合は token のみで認証。</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsForm({ config, onChange }: SectionFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Metrics</h2>
|
||||
<HelpText>
|
||||
LLM Worker と AAO Gateway Server の Prometheus 互換 metrics 設定。
|
||||
config v2 では <code className="font-mono">llm.metrics</code> と{' '}
|
||||
<code className="font-mono">gateway.metrics</code> に分離されています。
|
||||
</HelpText>
|
||||
|
||||
<MetricsBlock
|
||||
title="Worker Metrics (llm.metrics)"
|
||||
path="llm.metrics"
|
||||
prefixDefault="aao_worker"
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<MetricsBlock
|
||||
title="Gateway Metrics (gateway.metrics)"
|
||||
path="gateway.metrics"
|
||||
prefixDefault="aao_gateway"
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { parseSecretValue } from '../../api';
|
||||
|
||||
interface ModelSelectProps {
|
||||
/** Currently saved model name. Always shown even if discovery fails. */
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
/** LLM endpoint to probe `<endpoint>/models` against. */
|
||||
endpoint: string | undefined;
|
||||
/**
|
||||
* Raw `apiKey` string from the draft config. Used to attach a Bearer
|
||||
* token to the discovery request when it's a literal secret. Masked
|
||||
* / env_ref values cannot be used for direct discovery in Phase 1
|
||||
* (the actual literal is not exposed to the browser), and we fall
|
||||
* back to manual input in that case.
|
||||
*/
|
||||
apiKeyRaw: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint + apiKey -aware model dropdown with a manual-input fallback.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Probes `<endpoint>/models` once whenever endpoint / apiKey
|
||||
* identity changes.
|
||||
* - Success → renders a searchable dropdown of returned ids; the
|
||||
* currently saved `value` is always included even if discovery
|
||||
* dropped it (so a typo doesn't silently overwrite the choice).
|
||||
* - Failure (network error, non-2xx, malformed body) → renders a
|
||||
* plain text input and shows an inline amber warning suggesting
|
||||
* manual entry.
|
||||
* - apiKey is `unchanged` / `env_ref` / `cleared` → also falls back
|
||||
* to manual input. Probing with the masked sentinel would 401 and
|
||||
* leak nothing useful.
|
||||
*
|
||||
* The component is deliberately self-contained: the parent passes the
|
||||
* current draft endpoint+apiKey and the new model name flows back via
|
||||
* `onChange`. No global state, no caching across remounts — discovery
|
||||
* latency is short enough (< 1s typically) that re-probing on every
|
||||
* mount is fine, and avoids stale dropdowns if the endpoint changed.
|
||||
*/
|
||||
export function ModelSelect({ value, onChange, endpoint, apiKeyRaw }: ModelSelectProps) {
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Track the in-flight probe so a fast endpoint edit cancels the
|
||||
// previous one instead of racing the latest write.
|
||||
const probeIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint || endpoint.trim() === '') {
|
||||
setModels(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const parsed = parseSecretValue(apiKeyRaw);
|
||||
// Phase 1: only `literal` keys can be used for direct discovery
|
||||
// from the browser. For `env_ref` / `unchanged` the literal is
|
||||
// server-side only — manual fallback. For `cleared` we attempt
|
||||
// discovery without an Authorization header (works for Ollama).
|
||||
const bearer =
|
||||
parsed.type === 'literal' ? parsed.value
|
||||
: parsed.type === 'cleared' ? undefined
|
||||
: null; // null = skip discovery
|
||||
if (bearer === null) {
|
||||
setModels(null);
|
||||
setError('API key is masked or env-ref; please enter the model name manually.');
|
||||
return;
|
||||
}
|
||||
const probeId = ++probeIdRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const trimmed = endpoint.replace(/\/+$/, '');
|
||||
const url = `${trimmed}/models`;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
fetch(url, { headers })
|
||||
.then(async res => {
|
||||
if (probeId !== probeIdRef.current) return; // stale
|
||||
if (!res.ok) {
|
||||
setModels(null);
|
||||
setError(`model discovery failed (HTTP ${res.status}); please enter manually.`);
|
||||
return;
|
||||
}
|
||||
const body = await res.json().catch(() => null) as { data?: Array<{ id?: unknown }> } | null;
|
||||
if (!body || !Array.isArray(body.data)) {
|
||||
setModels(null);
|
||||
setError('model discovery returned an unexpected payload; please enter manually.');
|
||||
return;
|
||||
}
|
||||
const ids = body.data
|
||||
.map(m => (typeof m?.id === 'string' ? m.id.trim() : ''))
|
||||
.filter(id => id.length > 0);
|
||||
// Surface the discovered set even if empty — distinguishes
|
||||
// "endpoint reachable, no models loaded" from "endpoint down".
|
||||
setModels(Array.from(new Set(ids)));
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
if (probeId !== probeIdRef.current) return;
|
||||
setModels(null);
|
||||
setError(
|
||||
`model discovery failed (${err instanceof Error ? err.message : 'network error'}); ` +
|
||||
'please enter manually.',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (probeId === probeIdRef.current) setLoading(false);
|
||||
});
|
||||
}, [endpoint, apiKeyRaw]);
|
||||
|
||||
// Discovery succeeded — render a datalist-backed combobox so the user
|
||||
// can either pick or override. A native datalist is the simplest way
|
||||
// to get "dropdown with manual fallback" without a custom popover.
|
||||
if (models !== null) {
|
||||
const options = value && !models.includes(value) ? [value, ...models] : models;
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
list="llm-workers-model-options"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={loading ? 'loading...' : 'choose or type a model'}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
/>
|
||||
<datalist id="llm-workers-model-options">
|
||||
{options.map(m => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
{options.length === 0 && (
|
||||
<p className="text-2xs text-slate-500 mt-1">
|
||||
endpoint reachable but no models reported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual fallback (discovery failed or skipped).
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={loading ? 'loading...' : 'qwen3:8b'}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-100 px-2 py-1 rounded mt-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { MovementForm } from './MovementForm';
|
||||
|
||||
export interface MovementAccordionProps {
|
||||
movements: any[];
|
||||
onChange: (index: number, field: string, value: any) => void;
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
onMove: (index: number, direction: 'up' | 'down') => void;
|
||||
}
|
||||
|
||||
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove }: MovementAccordionProps) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const movementNames = movements.map((m) => m.name ?? '');
|
||||
|
||||
const toggle = (i: number) => {
|
||||
setExpandedIndex((prev) => (prev === i ? null : i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-800 mb-2">Movements</h3>
|
||||
<div className="space-y-2">
|
||||
{movements.map((movement, i) => {
|
||||
const isExpanded = expandedIndex === i;
|
||||
const toolCount = (movement.allowed_tools ?? []).length;
|
||||
const ruleCount = (movement.rules ?? []).length;
|
||||
|
||||
return (
|
||||
<div key={i} className="bg-white border border-slate-200 rounded-lg">
|
||||
{/* Collapsed header */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 cursor-pointer select-none"
|
||||
onClick={() => toggle(i)}
|
||||
>
|
||||
<span className="text-xs text-slate-400 mr-1">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className="text-sm font-medium text-slate-800">{movement.name || '(unnamed)'}</span>
|
||||
{movement.persona && (
|
||||
<span className="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded">
|
||||
{movement.persona}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${movement.edit ? 'bg-green-100 text-green-700' : 'bg-purple-100 text-purple-700'}`}>
|
||||
edit: {movement.edit ? 'on' : 'off'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{toolCount} tools</span>
|
||||
<span className="text-xs text-slate-400">{ruleCount} rules</span>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'up')}
|
||||
disabled={i === 0}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'down')}
|
||||
disabled={i === movements.length - 1}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
|
||||
onRemove(i);
|
||||
if (expandedIndex === i) setExpandedIndex(null);
|
||||
}
|
||||
}}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded form */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-slate-100 pt-3">
|
||||
<MovementForm
|
||||
movement={movement}
|
||||
movementNames={movementNames}
|
||||
onChange={(field, value) => onChange(i, field, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="mt-3 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Movement
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { ToolTagInput } from './ToolTagInput';
|
||||
import { RulesTable } from './RulesTable';
|
||||
|
||||
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
|
||||
|
||||
export interface MovementFormProps {
|
||||
movement: any;
|
||||
movementNames: string[];
|
||||
onChange: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
export function MovementForm({ movement, movementNames, onChange }: MovementFormProps) {
|
||||
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={movement.name ?? ''}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* persona */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">persona</label>
|
||||
<input
|
||||
type="text"
|
||||
value={movement.persona ?? ''}
|
||||
onChange={(e) => onChange('persona', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* default_next */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">default_next</label>
|
||||
<select
|
||||
value={movement.default_next ?? 'COMPLETE'}
|
||||
onChange={(e) => onChange('default_next', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
{nextOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* edit */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`edit-${movement.name}`}
|
||||
checked={movement.edit ?? false}
|
||||
onChange={(e) => onChange('edit', e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
|
||||
<HelpText>有効にすると Write / Edit ツールが LLM に提示されます</HelpText>
|
||||
</div>
|
||||
|
||||
{/* instruction */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">instruction</label>
|
||||
<textarea
|
||||
value={movement.instruction ?? ''}
|
||||
onChange={(e) => onChange('instruction', e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono"
|
||||
/>
|
||||
<HelpText>LLM に渡される指示文。Markdown 記法が使えます</HelpText>
|
||||
</div>
|
||||
|
||||
{/* allowed_tools */}
|
||||
<ToolTagInput
|
||||
value={movement.allowed_tools ?? []}
|
||||
onChange={(tools) => onChange('allowed_tools', tools)}
|
||||
/>
|
||||
|
||||
{/* rules */}
|
||||
<RulesTable
|
||||
rules={movement.rules ?? []}
|
||||
movementNames={movementNames.filter((n) => n !== movement.name)}
|
||||
onChange={(rules) => onChange('rules', rules)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface NamespaceEditorProps {
|
||||
value: Record<string, { apiKey: string }>;
|
||||
onChange: (value: Record<string, { apiKey: string }>) => void;
|
||||
/**
|
||||
* Disable the "add namespace" controls (input fields + button). Existing
|
||||
* entries remain editable / removable. Used by the DKS [LEGACY] section
|
||||
* to steer new integrations toward MCP servers.
|
||||
*/
|
||||
addDisabled?: boolean;
|
||||
/** Tooltip shown on the disabled controls. */
|
||||
addDisabledReason?: string;
|
||||
/** Optional href surfaced alongside the tooltip (e.g. MCP help doc). */
|
||||
addDisabledHref?: string;
|
||||
}
|
||||
|
||||
export function NamespaceEditor({
|
||||
value,
|
||||
onChange,
|
||||
addDisabled = false,
|
||||
addDisabledReason,
|
||||
addDisabledHref,
|
||||
}: NamespaceEditorProps) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
|
||||
const entries = Object.entries(value);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (addDisabled) return;
|
||||
const name = newName.trim();
|
||||
if (!name || name in value) return;
|
||||
onChange({ ...value, [name]: { apiKey: newApiKey } });
|
||||
setNewName('');
|
||||
setNewApiKey('');
|
||||
};
|
||||
|
||||
const handleRemove = (name: string) => {
|
||||
const { [name]: _, ...rest } = value;
|
||||
onChange(rest);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (name: string, apiKey: string) => {
|
||||
onChange({ ...value, [name]: { apiKey } });
|
||||
};
|
||||
|
||||
const disabledTitle = addDisabled ? addDisabledReason : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([name, { apiKey }]) => (
|
||||
<div key={name} className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-700 min-w-[140px] truncate" title={name}>{name}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => handleApiKeyChange(name, e.target.value)}
|
||||
placeholder="API Key"
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemove(name)}
|
||||
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="namespace"
|
||||
disabled={addDisabled}
|
||||
title={disabledTitle}
|
||||
className="w-[140px] px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newApiKey}
|
||||
onChange={e => setNewApiKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="API Key"
|
||||
disabled={addDisabled}
|
||||
title={disabledTitle}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={addDisabled}
|
||||
title={disabledTitle}
|
||||
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed disabled:hover:bg-slate-200"
|
||||
aria-label={addDisabled ? '新規追加は無効化されています' : '新規追加'}
|
||||
>+ 追加</button>
|
||||
{addDisabled && addDisabledHref && (
|
||||
<a
|
||||
href={addDisabledHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-2 py-1.5 text-xs text-accent underline self-center"
|
||||
title={disabledTitle}
|
||||
>MCP ガイド</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import {
|
||||
isNotificationSupported,
|
||||
getNotificationPermission,
|
||||
requestNotificationPermission,
|
||||
createNotification,
|
||||
buildNotificationOptions,
|
||||
DEFAULT_NOTIFY_EVENTS,
|
||||
type NotifyEventType,
|
||||
type NotifyEventSettings,
|
||||
} from '../../lib/notifications';
|
||||
import {
|
||||
isPushSupported,
|
||||
isStandalonePWA,
|
||||
isIOS,
|
||||
subscribePush,
|
||||
unsubscribePush,
|
||||
getCurrentPushSubscription,
|
||||
} from '../../lib/push-subscribe';
|
||||
import {
|
||||
fetchVapidPublicKey,
|
||||
listPushSubscriptions,
|
||||
postPushSubscription,
|
||||
deletePushSubscription as apiDeletePushSubscription,
|
||||
fetchNotificationPrefs,
|
||||
updateNotificationPrefs,
|
||||
migrateLocalStoragePrefs,
|
||||
postTestNotification,
|
||||
type PushSubscriptionPublic,
|
||||
type NotificationPrefsDTO,
|
||||
} from '../../api';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
const EVENT_LABELS: Array<{ key: NotifyEventType; label: string }> = [
|
||||
{ key: 'running', label: 'タスク開始 (running)' },
|
||||
{ key: 'succeeded', label: 'タスク完了 (succeeded)' },
|
||||
{ key: 'failed', label: 'タスク失敗 (failed / aborted)' },
|
||||
{ key: 'waiting_human', label: 'ユーザー回答待ち (waiting_human)' },
|
||||
];
|
||||
|
||||
type PushAvailability =
|
||||
| { kind: 'supported' }
|
||||
| { kind: 'needs-pwa-ios' }
|
||||
| { kind: 'unsupported'; reason: string };
|
||||
|
||||
function evaluatePushAvailability(): PushAvailability {
|
||||
if (!isPushSupported()) {
|
||||
return { kind: 'unsupported', reason: 'お使いのブラウザは Web Push API に対応していません' };
|
||||
}
|
||||
if (isIOS() && !isStandalonePWA()) {
|
||||
return { kind: 'needs-pwa-ios' };
|
||||
}
|
||||
return { kind: 'supported' };
|
||||
}
|
||||
|
||||
export function NotificationsForm() {
|
||||
const supported = isNotificationSupported();
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
|
||||
getNotificationPermission(),
|
||||
);
|
||||
// V1 localStorage (legacy fallback; server prefs override once loaded).
|
||||
const [v1Enabled, setV1Enabled] = useLocalStorageState<boolean>('notify.enabled', true);
|
||||
const [v1Events, setV1Events] = useLocalStorageState<NotifyEventSettings>(
|
||||
'notify.events',
|
||||
DEFAULT_NOTIFY_EVENTS,
|
||||
);
|
||||
|
||||
// V2 server-side state — null until first fetch / not configured.
|
||||
const [serverPrefs, setServerPrefs] = useState<NotificationPrefsDTO | null>(null);
|
||||
const [subscriptions, setSubscriptions] = useState<PushSubscriptionPublic[]>([]);
|
||||
const [pushAvailable] = useState<PushAvailability>(() => evaluatePushAvailability());
|
||||
const [hasLocalSubscription, setHasLocalSubscription] = useState<boolean>(false);
|
||||
const [pushFatal, setPushFatal] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
// Effective prefs come from the server when available; otherwise fall back
|
||||
// to localStorage so V1-only deployments keep working unchanged.
|
||||
const enabled = serverPrefs?.enabled ?? v1Enabled;
|
||||
const events: NotifyEventSettings = serverPrefs
|
||||
? serverPrefs.events
|
||||
: v1Events;
|
||||
const includeDetails = serverPrefs?.includeDetails ?? false;
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (next: boolean) => {
|
||||
setV1Enabled(next);
|
||||
if (serverPrefs) {
|
||||
const updated = await updateNotificationPrefs({ enabled: next });
|
||||
setServerPrefs(updated);
|
||||
}
|
||||
},
|
||||
[serverPrefs, setV1Enabled],
|
||||
);
|
||||
|
||||
const toggleEvent = useCallback(
|
||||
async (key: NotifyEventType) => {
|
||||
const nextValue = !events[key];
|
||||
setV1Events(prev => ({ ...prev, [key]: nextValue }));
|
||||
if (serverPrefs) {
|
||||
const updated = await updateNotificationPrefs({ events: { [key]: nextValue } });
|
||||
setServerPrefs(updated);
|
||||
}
|
||||
},
|
||||
[events, serverPrefs, setV1Events],
|
||||
);
|
||||
|
||||
const setIncludeDetails = useCallback(
|
||||
async (next: boolean) => {
|
||||
if (!serverPrefs) return;
|
||||
const updated = await updateNotificationPrefs({ includeDetails: next });
|
||||
setServerPrefs(updated);
|
||||
},
|
||||
[serverPrefs],
|
||||
);
|
||||
|
||||
// First-load: hydrate server prefs, migrate from localStorage if needed.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const prefs = await fetchNotificationPrefs();
|
||||
if (cancelled) return;
|
||||
if (!prefs.v1Migrated) {
|
||||
// One-shot import from localStorage. 409 means another tab beat us;
|
||||
// in that case just adopt the server state.
|
||||
const result = await migrateLocalStoragePrefs({
|
||||
enabled: v1Enabled,
|
||||
events: v1Events,
|
||||
includeDetails: false,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if ('alreadyMigrated' in result) {
|
||||
setServerPrefs(prefs);
|
||||
} else {
|
||||
setServerPrefs(result.prefs);
|
||||
}
|
||||
} else {
|
||||
setServerPrefs(prefs);
|
||||
}
|
||||
} catch (err) {
|
||||
// /api/notifications/preferences not reachable → keep V1 fallback.
|
||||
// Logged for diagnostics; the UI remains usable.
|
||||
console.warn('[notifications] failed to load server prefs', err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Subscriptions list — re-fetch on mount and after subscribe/unsubscribe.
|
||||
const refreshSubscriptions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listPushSubscriptions();
|
||||
setSubscriptions(list);
|
||||
} catch (err) {
|
||||
console.warn('[notifications] failed to load subscriptions', err);
|
||||
}
|
||||
try {
|
||||
const local = await getCurrentPushSubscription();
|
||||
setHasLocalSubscription(local !== null);
|
||||
} catch {
|
||||
setHasLocalSubscription(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pushAvailable.kind !== 'supported') return;
|
||||
refreshSubscriptions();
|
||||
}, [pushAvailable.kind, refreshSubscriptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setPermission(getNotificationPermission());
|
||||
window.addEventListener('focus', refresh);
|
||||
return () => window.removeEventListener('focus', refresh);
|
||||
}, []);
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">ブラウザ通知</h3>
|
||||
<HelpText>お使いのブラウザは Notification API に未対応です。</HelpText>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleEnable = async () => {
|
||||
const result = await requestNotificationPermission();
|
||||
setPermission(result === 'unsupported' ? 'unsupported' : result);
|
||||
if (result === 'granted') void setEnabled(true);
|
||||
else void setEnabled(false);
|
||||
};
|
||||
|
||||
const handleTestV1 = () => {
|
||||
const opts = buildNotificationOptions(
|
||||
{ id: 0, title: 'テスト通知', pieceName: 'ブラウザ通知は正常に動作しています' },
|
||||
'succeeded',
|
||||
);
|
||||
createNotification(opts, () => { /* no-op */ });
|
||||
};
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (pushAvailable.kind !== 'supported') return;
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
const { publicKey } = await fetchVapidPublicKey();
|
||||
const dto = await subscribePush(publicKey);
|
||||
await postPushSubscription(dto);
|
||||
await refreshSubscriptions();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsubscribeLocal = async () => {
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
const local = await getCurrentPushSubscription();
|
||||
// Match the server-side row by endpoint host (server returns only the host).
|
||||
const targetHost = local ? (() => {
|
||||
try { return new URL(local.endpoint).host; } catch { return null; }
|
||||
})() : null;
|
||||
const serverRow = subscriptions.find(s => targetHost && s.endpointHost === targetHost);
|
||||
await unsubscribePush();
|
||||
if (serverRow) await apiDeletePushSubscription(serverRow.id);
|
||||
await refreshSubscriptions();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRemote = async (id: string) => {
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
await apiDeletePushSubscription(id);
|
||||
await refreshSubscriptions();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestV2 = async () => {
|
||||
setBusy(true);
|
||||
setPushFatal(null);
|
||||
try {
|
||||
await postTestNotification();
|
||||
} catch (err) {
|
||||
setPushFatal(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const v1StatusBadge = (() => {
|
||||
if (permission === 'granted' && enabled) return '✅ 有効化済み';
|
||||
if (permission === 'granted' && !enabled) return '⏸ 一時停止中';
|
||||
if (permission === 'denied') return '🚫 ブラウザで拒否';
|
||||
return '❌ 未許可';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── V1: 前面通知 ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">ブラウザ通知 (V1: 前面表示)</h3>
|
||||
<p className="mt-1 text-[13px] text-slate-700">状態: {v1StatusBadge}</p>
|
||||
|
||||
{permission === 'default' && (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="mt-2 px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
|
||||
>
|
||||
ブラウザ通知を有効化
|
||||
</button>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<HelpText>
|
||||
ブラウザのアドレスバー左の設定アイコンから「通知」を許可に変更してください。
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{permission === 'granted' && (
|
||||
<label className="mt-2 flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => void setEnabled(e.target.checked)}
|
||||
/>
|
||||
通知を受け取る (マスター ON/OFF)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{permission === 'granted' && (
|
||||
<button
|
||||
onClick={handleTestV1}
|
||||
disabled={!enabled}
|
||||
className="mt-2 px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
テスト通知 (ページ内)
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── V2: モバイル / バックグラウンド通知 ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">📱 モバイル / バックグラウンド通知 (V2)</h3>
|
||||
|
||||
{pushAvailable.kind === 'unsupported' && (
|
||||
<HelpText>{pushAvailable.reason}</HelpText>
|
||||
)}
|
||||
|
||||
{pushAvailable.kind === 'needs-pwa-ios' && (
|
||||
<HelpText>
|
||||
iOS Safari では「共有 → ホーム画面に追加」でアプリとしてインストールしてから、
|
||||
ホーム画面のアイコンから開いた状態で通知を有効化できます。
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{pushAvailable.kind === 'supported' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-[13px] text-slate-700">
|
||||
状態: {hasLocalSubscription ? '✅ このデバイスで購読中' : '❌ このデバイスは未購読'}
|
||||
{subscriptions.length > 0 && ` (合計 ${subscriptions.length} デバイス)`}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={busy || hasLocalSubscription || !enabled}
|
||||
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px] disabled:opacity-50"
|
||||
>
|
||||
このデバイスで購読
|
||||
</button>
|
||||
{hasLocalSubscription && (
|
||||
<button
|
||||
onClick={handleUnsubscribeLocal}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
購読を解除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTestV2}
|
||||
disabled={busy || subscriptions.length === 0}
|
||||
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
|
||||
>
|
||||
テスト通知 (サーバー経由)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subscriptions.length > 0 && (
|
||||
<div className="mt-2 border border-slate-200 rounded">
|
||||
<p className="px-3 py-1 text-[12px] text-slate-600 border-b border-slate-200">
|
||||
購読デバイス一覧
|
||||
</p>
|
||||
{subscriptions.map(s => (
|
||||
<div key={s.id} className="flex items-center justify-between px-3 py-2 text-[13px] border-b border-slate-100 last:border-b-0">
|
||||
<div>
|
||||
<div className="truncate max-w-md">{s.userAgent ?? '(unknown)'}</div>
|
||||
<div className="text-[11px] text-slate-500">
|
||||
{s.endpointHost} • {new Date(s.createdAt).toLocaleString('ja-JP')}
|
||||
{s.failureCount > 0 && (
|
||||
<span className="ml-2 text-red-600">⚠ {s.failureCount} failures</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteRemote(s.id)}
|
||||
disabled={busy}
|
||||
className="ml-2 px-2 py-1 text-[11px] text-red-700 hover:bg-red-50 rounded"
|
||||
>
|
||||
解除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverPrefs && (
|
||||
<label className="mt-2 flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDetails}
|
||||
onChange={e => void setIncludeDetails(e.target.checked)}
|
||||
/>
|
||||
通知にタスクの詳細(タイトル・piece 名)を含める
|
||||
<span className="text-[11px] text-slate-500">
|
||||
(OFF: 「タスク #N 完了」のみ)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pushFatal && (
|
||||
<p className="text-[12px] text-red-700">エラー: {pushFatal}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 通知するイベント (V1 + V2 共通) ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">通知するイベント</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
{EVENT_LABELS.map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events[key]}
|
||||
onChange={() => void toggleEvent(key)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HelpText>
|
||||
ⓘ V1 (前面表示) はタブが開いていてフォーカスがある時のみ動作します<br />
|
||||
ⓘ V2 (モバイル / バックグラウンド) は HTTPS + PWA インストール時のみ確実に動作します<br />
|
||||
ⓘ 自分が owner のタスクのみ通知されます
|
||||
</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Paths & Storage — config v2 `storage.*` block.
|
||||
*
|
||||
* Reads from the new `storage.*` keys emitted by `GET /api/config` (after
|
||||
* #360 normalization + #362 v2 API shape). Old flat keys (`worktreeDir` etc.)
|
||||
* are rejected by `PUT /api/config` since v2, so this form only writes
|
||||
* `storage.*`.
|
||||
*/
|
||||
export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
|
||||
const storage = config.storage ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Paths & Storage</h2>
|
||||
<HelpText>
|
||||
ファイルシステム上の保存先と上限の設定。config v2 では <code className="font-mono">storage.*</code> に集約されています。
|
||||
</HelpText>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Worktree Directory</FieldLabel>
|
||||
<FieldInput
|
||||
value={storage.worktreeDir ?? ''}
|
||||
onChange={v => onChange('storage.worktreeDir', v || undefined)}
|
||||
disabled={!!overriddenByEnv['storage.worktreeDir'] || !!overriddenByEnv['worktreeDir']}
|
||||
disabledReason="WORKTREE_DIR 環境変数で上書き中"
|
||||
/>
|
||||
{(overriddenByEnv['storage.worktreeDir'] || overriddenByEnv['worktreeDir']) && <EnvOverrideWarning />}
|
||||
<HelpText>ジョブ実行時の作業ディレクトリのベースパス</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Custom Pieces Directory</FieldLabel>
|
||||
<FieldInput
|
||||
value={storage.customPiecesDir ?? ''}
|
||||
onChange={v => onChange('storage.customPiecesDir', v || undefined)}
|
||||
placeholder="/path/to/your/custom-pieces"
|
||||
/>
|
||||
<HelpText>リポジトリ内の pieces/ とは別に、追加の Piece を配置するディレクトリ。省略時は pieces/ のみ使用</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>User Folder Root</FieldLabel>
|
||||
<FieldInput
|
||||
value={storage.userFolderRoot ?? ''}
|
||||
onChange={v => onChange('storage.userFolderRoot', v || undefined)}
|
||||
placeholder="./data/users"
|
||||
/>
|
||||
<HelpText>ユーザーごとの設定・スクリプト・メモリ等を保存するルートディレクトリ</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Task Upload 最大サイズ (MB)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={storage.taskUploadMaxSizeMb ?? 50}
|
||||
onChange={v => onChange('storage.taskUploadMaxSizeMb', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>
|
||||
<code>POST /api/local/tasks</code> および <code>POST /api/local/tasks/:id/comments</code> の
|
||||
リクエスト body 上限。範囲 1〜1000 MB、デフォルト 50。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Trash Retention (日)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={storage.trashRetentionDays ?? 30}
|
||||
onChange={v => onChange('storage.trashRetentionDays', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>
|
||||
<code>data/users/{userId}/trash/</code> のファイルを自動削除するまでの日数。
|
||||
0 を指定すると即削除。デフォルト 30 日。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { stringify, parse } from 'yaml';
|
||||
import { usePiece } from '../../hooks/usePieces';
|
||||
import { updatePiece, deletePiece } from '../../api';
|
||||
import { useUrlState } from '../../hooks/useUrlState';
|
||||
import { PieceMetaForm } from './PieceMetaForm';
|
||||
import { MovementAccordion } from './MovementAccordion';
|
||||
|
||||
export interface PieceEditorProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function PieceEditor({ name }: PieceEditorProps) {
|
||||
const { data: piece, isLoading, error } = usePiece(name);
|
||||
const queryClient = useQueryClient();
|
||||
const { setUrlState } = useUrlState();
|
||||
|
||||
const [draft, setDraft] = useState<any>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// YAML editing mode
|
||||
const [editMode, setEditMode] = useState<'visual' | 'yaml'>('visual');
|
||||
const [yamlText, setYamlText] = useState('');
|
||||
const [yamlError, setYamlError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (piece) {
|
||||
setDraft(structuredClone(piece));
|
||||
setIsDirty(false);
|
||||
setEditMode('visual');
|
||||
setYamlError(null);
|
||||
}
|
||||
}, [piece]);
|
||||
|
||||
const showToast = (msg: string, duration = 2000) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), duration);
|
||||
};
|
||||
|
||||
const handleMetaChange = useCallback((field: string, value: any) => {
|
||||
setDraft((prev: any) => {
|
||||
if (field === 'triggers.keywords') {
|
||||
return { ...prev, triggers: { ...prev.triggers, keywords: value } };
|
||||
}
|
||||
return { ...prev, [field]: value };
|
||||
});
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleMovementChange = useCallback((index: number, field: string, value: any) => {
|
||||
setDraft((prev: any) => {
|
||||
const movements = [...prev.movements];
|
||||
movements[index] = { ...movements[index], [field]: value };
|
||||
return { ...prev, movements };
|
||||
});
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleAddMovement = useCallback(() => {
|
||||
setDraft((prev: any) => ({
|
||||
...prev,
|
||||
movements: [
|
||||
...prev.movements,
|
||||
{
|
||||
name: `step_${prev.movements.length + 1}`,
|
||||
persona: '',
|
||||
default_next: 'COMPLETE',
|
||||
edit: false,
|
||||
instruction: '',
|
||||
allowed_tools: [],
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveMovement = useCallback((index: number) => {
|
||||
setDraft((prev: any) => ({
|
||||
...prev,
|
||||
movements: prev.movements.filter((_: any, i: number) => i !== index),
|
||||
}));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleMoveMovement = useCallback((index: number, direction: 'up' | 'down') => {
|
||||
setDraft((prev: any) => {
|
||||
const movements = [...prev.movements];
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= movements.length) return prev;
|
||||
[movements[index], movements[targetIndex]] = [movements[targetIndex], movements[index]];
|
||||
return { ...prev, movements };
|
||||
});
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
// Switch to YAML editing mode
|
||||
const switchToYaml = () => {
|
||||
const text = stringify(draft, { lineWidth: 120 });
|
||||
setYamlText(text);
|
||||
setYamlError(null);
|
||||
setEditMode('yaml');
|
||||
};
|
||||
|
||||
// Switch to visual editing mode
|
||||
const switchToVisual = () => {
|
||||
try {
|
||||
const parsed = parse(yamlText);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
setYamlError('YAML のパースに失敗しました');
|
||||
return;
|
||||
}
|
||||
setDraft(parsed);
|
||||
setYamlError(null);
|
||||
setEditMode('visual');
|
||||
setIsDirty(true);
|
||||
} catch (e: any) {
|
||||
setYamlError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleYamlChange = (text: string) => {
|
||||
setYamlText(text);
|
||||
setYamlError(null);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (piece) {
|
||||
setDraft(structuredClone(piece));
|
||||
setIsDirty(false);
|
||||
setEditMode('visual');
|
||||
setYamlError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draft) return;
|
||||
|
||||
let saveData = draft;
|
||||
if (editMode === 'yaml') {
|
||||
try {
|
||||
saveData = parse(yamlText);
|
||||
if (!saveData || typeof saveData !== 'object') {
|
||||
showToast('エラー: YAML のパースに失敗しました', 3000);
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: YAML パースエラー — ${e.message}`, 3000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure name matches
|
||||
saveData.name = name;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePiece(name, saveData);
|
||||
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setIsDirty(false);
|
||||
if (editMode === 'yaml') {
|
||||
setDraft(saveData);
|
||||
}
|
||||
showToast('保存しました');
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: ${e.message}`, 3000);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
|
||||
try {
|
||||
await deletePiece(name);
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
|
||||
} catch (e: any) {
|
||||
showToast(`エラー: ${e.message}`, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
|
||||
if (error) return <div className="text-sm text-red-500">Piece の読み込みに失敗しました</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
const movementNames = (draft.movements ?? []).map((m: any) => m.name ?? '');
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">{draft.name}</h2>
|
||||
{draft.description && (
|
||||
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2">{String(draft.description).split('\n')[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center gap-1 mb-4 bg-slate-100 rounded-lg p-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => editMode === 'yaml' ? switchToVisual() : undefined}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
editMode === 'visual'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editMode === 'visual' ? switchToYaml() : undefined}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
editMode === 'yaml'
|
||||
? 'bg-white text-slate-800 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
YAML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<>
|
||||
{/* Meta form */}
|
||||
<div className="mb-8">
|
||||
<PieceMetaForm
|
||||
piece={draft}
|
||||
onChange={handleMetaChange}
|
||||
movementNames={movementNames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Movements */}
|
||||
<div className="mb-6">
|
||||
<MovementAccordion
|
||||
movements={draft.movements ?? []}
|
||||
onChange={handleMovementChange}
|
||||
onAdd={handleAddMovement}
|
||||
onRemove={handleRemoveMovement}
|
||||
onMove={handleMoveMovement}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* YAML editor */
|
||||
<div className="mb-6">
|
||||
{yamlError && (
|
||||
<div className="mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-600">
|
||||
{yamlError}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={yamlText}
|
||||
onChange={(e) => handleYamlChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono bg-slate-50 leading-relaxed resize-y"
|
||||
style={{ minHeight: '500px', tabSize: 2 }}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
YAML を直接編集できます。Visual モードに切り替えると自動でパースされます。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
|
||||
{toast && (
|
||||
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!isDirty}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-4 py-2 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
export interface PieceMetaFormProps {
|
||||
piece: any;
|
||||
onChange: (field: string, value: any) => void;
|
||||
movementNames: string[];
|
||||
}
|
||||
|
||||
export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormProps) {
|
||||
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={piece.name ?? ''}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-slate-50 text-slate-500 outline-none cursor-not-allowed"
|
||||
/>
|
||||
<HelpText>英小文字・数字・ハイフンのみ使用可能</HelpText>
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={piece.description ?? ''}
|
||||
onChange={(e) => onChange('description', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* max_movements */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">max_movements</label>
|
||||
<input
|
||||
type="number"
|
||||
value={piece.max_movements ?? 10}
|
||||
onChange={(e) => onChange('max_movements', parseInt(e.target.value, 10) || 0)}
|
||||
min={1}
|
||||
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<HelpText>1 ジョブで実行できる movement の最大回数。ループ防止のため</HelpText>
|
||||
</div>
|
||||
|
||||
{/* initial_movement */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">initial_movement</label>
|
||||
<select
|
||||
value={piece.initial_movement ?? ''}
|
||||
onChange={(e) => onChange('initial_movement', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
{movementNames.length === 0 && <option value="">--</option>}
|
||||
{movementNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<HelpText>ジョブ開始時に最初に実行される movement です</HelpText>
|
||||
</div>
|
||||
|
||||
{/* triggers.keywords */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">triggers.keywords</label>
|
||||
<input
|
||||
type="text"
|
||||
value={triggersText}
|
||||
onChange={(e) => {
|
||||
const keywords = e.target.value
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
onChange('triggers', { ...piece.triggers, keywords });
|
||||
}}
|
||||
placeholder="keyword1, keyword2, ..."
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
/>
|
||||
<HelpText>タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchMyOrgs, Visibility } from '../../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
export function PreferencesForm({ user }: { user: { defaultVisibility: Visibility; defaultVisibilityOrgId: string | null } }) {
|
||||
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs });
|
||||
const qc = useQueryClient();
|
||||
const [vis, setVis] = useState<Visibility>(user.defaultVisibility);
|
||||
const [orgId, setOrgId] = useState<string | null>(user.defaultVisibilityOrgId);
|
||||
useEffect(() => { setVis(user.defaultVisibility); setOrgId(user.defaultVisibilityOrgId); }, [user]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/users/me/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ defaultVisibility: vis, defaultVisibilityOrgId: vis === 'org' ? orgId : null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['auth', 'me'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">新規タスクのデフォルト公開範囲</h3>
|
||||
<div className="mt-2 flex gap-3 text-[13px]">
|
||||
<label><input type="radio" checked={vis === 'private'} onChange={() => setVis('private')} /> 🔒 非公開</label>
|
||||
<label><input type="radio" checked={vis === 'org'} onChange={() => setVis('org')} disabled={orgs.length === 0} /> 🏢 組織</label>
|
||||
<label><input type="radio" checked={vis === 'public'} onChange={() => setVis('public')} /> 🌐 公開</label>
|
||||
</div>
|
||||
<HelpText>
|
||||
🔒 非公開: 自分のみ閲覧可能 / 🏢 組織: 同じ Gitea org のメンバーが閲覧可能 / 🌐 公開: ログイン中の全ユーザーが閲覧可能
|
||||
</HelpText>
|
||||
{vis === 'org' && (
|
||||
<select value={orgId ?? ''} onChange={e => setOrgId(e.target.value)} className="mt-2 px-2 py-1 border rounded text-[13px]">
|
||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-slate-900">所属している Gitea 組織</h3>
|
||||
<ul className="mt-2 text-[13px] text-slate-700 list-disc pl-5">
|
||||
{orgs.map(o => <li key={o.orgId}>{o.orgName}</li>)}
|
||||
{orgs.length === 0 && <li className="text-slate-400">(なし — Gitea でログインすると表示されます)</li>}
|
||||
</ul>
|
||||
<p className="mt-2 text-2xs text-slate-500">最新状態に更新するには、一度ログアウトして再ログインしてください。</p>
|
||||
</section>
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
|
||||
>
|
||||
{save.isPending ? '保存中…' : '設定を保存'}
|
||||
</button>
|
||||
{save.isError && <div className="text-red-600 text-xs">{String(save.error)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Reflection (Hermes mode) settings.
|
||||
*
|
||||
* Toggle + caps for the per-job reflection loop that auto-updates a user's
|
||||
* persistent memory. See `src/engine/reflection/` and the design doc at
|
||||
* docs/superpowers/specs/2026-05-11-self-improving-memory-design.md.
|
||||
*
|
||||
* For Reflection to actually run, a worker with `roles: [reflection]` must
|
||||
* exist in the LLM Workers tab. Otherwise (with the default `worker_required`)
|
||||
* jobs are silently skipped.
|
||||
*/
|
||||
export function ReflectionForm({ config, onChange }: SectionFormProps) {
|
||||
const reflection = config.reflection ?? {};
|
||||
// Step 7 (design 2026-05-21): read v2 `llm.workers`. The v2 API contract
|
||||
// already strips the legacy `provider` block from GET /api/config, so
|
||||
// falling back to it would always be empty. Kept as a defensive `?? []`
|
||||
// for the brief window where draft state may still be undefined.
|
||||
const workers = config.llm?.workers ?? [];
|
||||
const hasReflectionWorker = workers.some(
|
||||
(w: { roles?: string[] }) => Array.isArray(w.roles) && w.roles.includes('reflection'),
|
||||
);
|
||||
const enabled = reflection.enabled === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Reflection (Hermes mode)</h2>
|
||||
|
||||
<p className="text-xs text-slate-600 leading-relaxed">
|
||||
通常ジョブが完了するたびに LLM がそのジョブから学んだ教訓を抽出し、ユーザーの memory
|
||||
(<code className="font-mono text-2xs">data/users/{'{userId}'}/memory/</code>)
|
||||
と必要に応じて custom piece を自動更新します。全変更は snapshot として保存され、
|
||||
Memory & Learning タブから revert 可能です。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => onChange('reflection.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-semibold">Reflection を有効化(自動適用)</span>
|
||||
</label>
|
||||
<HelpText>
|
||||
ON にすると、エージェントジョブが終わるたびに reflection ジョブが裏で走り、memory
|
||||
を自動で書き換えます。デフォルト: 無効。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
{enabled && !hasReflectionWorker && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<div className="font-semibold mb-1">⚠ Reflection worker が未設定です</div>
|
||||
<div>
|
||||
Reflection を有効化しても、<code className="font-mono">roles</code> に
|
||||
<code className="font-mono">reflection</code> を含む worker が無いとジョブは
|
||||
enqueue されません。<strong>LLM → Workers</strong> タブで以下のような worker
|
||||
を追加してください:
|
||||
</div>
|
||||
<pre className="mt-2 text-2xs font-mono bg-white border border-amber-200 rounded p-2 overflow-auto">{`id: reflection-1
|
||||
connection_type: direct
|
||||
endpoint: http://localhost:11434/v1
|
||||
model: qwen2.5:3b # cheap モデル推奨
|
||||
roles: [reflection]
|
||||
max_concurrency: 1`}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reflection.workerRequired !== false}
|
||||
onChange={e => onChange('reflection.workerRequired', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
専用 reflection worker を必須にする
|
||||
</label>
|
||||
<HelpText>
|
||||
ON: <code className="font-mono">roles: [reflection]</code> を持つ worker が無い場合、
|
||||
reflection ジョブを enqueue せずスキップします (デフォルト)。OFF にすると enabled
|
||||
のみで他 worker に拾われる可能性あり。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Caps</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max memory changes per job</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.maxMemoryChangesPerJob ?? 3}
|
||||
onChange={v => onChange('reflection.maxMemoryChangesPerJob', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ジョブの reflection で書き込める memory entry の上限。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max entry body bytes</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.maxEntryBodyBytes ?? 8192}
|
||||
onChange={v => onChange('reflection.maxEntryBodyBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>memory entry body の最大バイト数。これを超えると semantic validator が reject。デフォルト: 8192</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Piece edit cooldown (hours)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.pieceEditCooldownHours ?? 24}
|
||||
onChange={v => onChange('reflection.pieceEditCooldownHours', Number(v))}
|
||||
/>
|
||||
<HelpText>同じ piece への連続編集を抑制する cooldown。デフォルト: 24 (24h 以内に 2 回編集されたら 3 回目以降はスキップ)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Activity log max bytes</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.activityLogMaxBytes ?? 4096}
|
||||
onChange={v => onChange('reflection.activityLogMaxBytes', Number(v))}
|
||||
/>
|
||||
<HelpText>reflection LLM に渡す activity log の圧縮上限。デフォルト: 4096</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Budget</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Per-user daily budget (tokens)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.perUserDailyBudgetTokens ?? 200000}
|
||||
onChange={v => onChange('reflection.perUserDailyBudgetTokens', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ユーザーが 1 日に reflection で消費できる token 合計。超えた以降の reflection は enqueue されません。デフォルト: 200000</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Snapshot & Retention</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Snapshot retention (days)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.snapshotRetentionDays ?? 90}
|
||||
onChange={v => onChange('reflection.snapshotRetentionDays', Number(v))}
|
||||
/>
|
||||
<HelpText>reflection-history snapshot の保持日数。デフォルト: 90</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Snapshot max bytes per user</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.snapshotMaxBytesPerUser ?? 100 * 1024 * 1024}
|
||||
onChange={v => onChange('reflection.snapshotMaxBytesPerUser', Number(v))}
|
||||
/>
|
||||
<HelpText>1 ユーザーあたりの snapshot ディレクトリ合計サイズ上限 (bytes)。超えると古い順に削除。デフォルト: 100 MiB</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Snapshot max bytes per entry</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.snapshotMaxBytesPerEntry ?? 1 * 1024 * 1024}
|
||||
onChange={v => onChange('reflection.snapshotMaxBytesPerEntry', Number(v))}
|
||||
/>
|
||||
<HelpText>1 snapshot エントリの最大サイズ (bytes)。デフォルト: 1 MiB</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reflection.storeLlmRaw === true}
|
||||
onChange={e => onChange('reflection.storeLlmRaw', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
スナップショットに LLM の生レスポンスを保存する
|
||||
</label>
|
||||
<HelpText>
|
||||
ON にすると <code className="font-mono">llm-raw.json</code> を snapshot に含めます。
|
||||
デバッグ用途、デフォルトは OFF (ディスク節約)。
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Monitoring</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Abstain rate floor</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={reflection.abstainRateFloor ?? 0.3}
|
||||
onChange={v => onChange('reflection.abstainRateFloor', v ? Number(v) : undefined)}
|
||||
/>
|
||||
<HelpText>
|
||||
abstain (学ぶことなし) 率がこれを下回ると過剰学習サインとして警告 (運用シグナル)。
|
||||
デフォルト: 0.3。この値を下回った場合はシステムログに warn が出ます。
|
||||
反映率が高すぎる場合は max_memory_changes_per_job を下げることを検討してください。
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
|
||||
|
||||
export interface RulesTableProps {
|
||||
rules: Array<{ condition: string; next: string }>;
|
||||
movementNames: string[];
|
||||
onChange: (rules: Array<{ condition: string; next: string }>) => void;
|
||||
}
|
||||
|
||||
export function RulesTable({ rules, movementNames, onChange }: RulesTableProps) {
|
||||
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
|
||||
|
||||
const updateRule = (index: number, field: 'condition' | 'next', value: string) => {
|
||||
const updated = rules.map((r, i) => (i === index ? { ...r, [field]: value } : r));
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
onChange([...rules, { condition: '', next: movementNames[0] ?? 'COMPLETE' }]);
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
onChange(rules.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">rules</label>
|
||||
{rules.length > 0 && (
|
||||
<table className="w-full text-sm mb-2">
|
||||
<thead>
|
||||
<tr className="text-xs text-slate-500">
|
||||
<th className="text-left font-medium pb-1 pr-2">condition</th>
|
||||
<th className="text-left font-medium pb-1 pr-2 w-44">next</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule, i) => (
|
||||
<tr key={i}>
|
||||
<td className="pr-2 pb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.condition}
|
||||
onChange={(e) => updateRule(i, 'condition', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
placeholder="条件..."
|
||||
/>
|
||||
</td>
|
||||
<td className="pr-2 pb-1">
|
||||
<select
|
||||
value={rule.next}
|
||||
onChange={(e) => updateRule(i, 'next', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
>
|
||||
{nextOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(i)}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
<HelpText>LLM が transition ツールで遷移先を選ぶ際の条件です</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function SafetyForm({ config, onChange }: SectionFormProps) {
|
||||
const safety = config.safety ?? {};
|
||||
const historySummarization = safety.historySummarization ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Safety</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Iterations</FieldLabel>
|
||||
<FieldInput type="number" value={safety.maxIterations ?? 200}
|
||||
onChange={v => onChange('safety.maxIterations', Number(v))} />
|
||||
<HelpText>1 movement あたりの最大イテレーション回数。デフォルト: 200</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Revisits</FieldLabel>
|
||||
<FieldInput type="number" value={safety.maxRevisits ?? 3}
|
||||
onChange={v => onChange('safety.maxRevisits', Number(v))} />
|
||||
<HelpText>同一 movement への再訪問上限(ループ検出)。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Prompt Guard Ratio</FieldLabel>
|
||||
<FieldInput type="number" value={safety.promptGuardRatio ?? 0.8}
|
||||
onChange={v => onChange('safety.promptGuardRatio', v ? Number(v) : undefined)} />
|
||||
<HelpText>送信前に prompt がコンテキスト上限の何割を占めたら自動圧縮するか(0.5〜0.95、デフォルト: 0.8)</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">History Summarization</h3>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={historySummarization.enabled !== false}
|
||||
onChange={e => onChange('safety.historySummarization.enabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
履歴の自動要約を有効化
|
||||
</label>
|
||||
<HelpText>古い会話履歴を自動で要約して context を節約。デフォルト: 有効</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Tail Turns</FieldLabel>
|
||||
<FieldInput type="number" value={historySummarization.tailTurns ?? 2}
|
||||
onChange={v => onChange('safety.historySummarization.tailTurns', Number(v))} />
|
||||
<HelpText>常に保持する直近の assistant+tool ターン数。デフォルト: 2</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Preserve Recent Budget</FieldLabel>
|
||||
<FieldInput type="number" value={historySummarization.preserveRecentBudget ?? 8000}
|
||||
onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} />
|
||||
<HelpText>要約せず温存する直近メッセージのトークン予算。デフォルト: 8000</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
export function SearchFilterForm({ config, onChange }: SectionFormProps) {
|
||||
const sf = config.searchFilter ?? {};
|
||||
const autoBlock = sf.autoBlock ?? {};
|
||||
|
||||
const toggleAutoBlock = (key: string, value: boolean) => {
|
||||
onChange(`searchFilter.autoBlock.${key}`, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Search Filter</h2>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Blocked Patterns (ブロックパターン)</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={sf.blockedPatterns ?? []}
|
||||
onChange={v => onChange('searchFilter.blockedPatterns', v)}
|
||||
placeholder="regex pattern"
|
||||
/>
|
||||
<HelpText>WebSearch クエリからフィルタするパターン(正規表現)。</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Auto Block (自動ブロック)</FieldLabel>
|
||||
<div className="space-y-2 mt-1">
|
||||
{([
|
||||
['privateIp', 'プライベートIP', autoBlock.privateIp],
|
||||
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
|
||||
['email', 'メールアドレス', autoBlock.email],
|
||||
['phone', '電話番号', autoBlock.phone],
|
||||
] as const).map(([key, label, checked]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={e => toggleAutoBlock(key, e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>検索クエリに含まれる機密情報を自動でブロック。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
parseSecretValue,
|
||||
serializeSecretValue,
|
||||
type SecretFieldValue,
|
||||
} from '../../api';
|
||||
|
||||
interface SecretInputProps {
|
||||
/**
|
||||
* Current stored value (as fetched from `/api/config`). May be the
|
||||
* masked sentinel `'********'`, an `${ENV_REF}` pattern, a literal
|
||||
* plaintext (rare), or empty. The component parses it on the fly so
|
||||
* the parent form can keep using `config.llm.workers[i].apiKey` as a
|
||||
* plain string.
|
||||
*/
|
||||
rawValue: string | null | undefined;
|
||||
/**
|
||||
* Called when the user changes the stored form. Receives the
|
||||
* already-serialized string the parent should write back into the
|
||||
* draft config. Phase 1 keeps this string-shaped for backwards
|
||||
* compatibility with the existing `apiKey: string` config field.
|
||||
*/
|
||||
onChange: (serialized: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-state secret editor (Phase 1).
|
||||
*
|
||||
* The control surface exposes three actions:
|
||||
* - Edit literal: user types a plaintext secret
|
||||
* - Use env ref: user types an env var name; saved as `${NAME}`
|
||||
* - Clear: erases the stored secret (saved as empty string)
|
||||
* The fourth state — `unchanged` — is what the component reports when
|
||||
* the displayed value is still the server mask and the user has not
|
||||
* touched anything.
|
||||
*
|
||||
* The form payload is currently a plain string so the existing server-
|
||||
* side mask preservation (see `src/config-manager.ts`) continues to
|
||||
* work without API changes. The 4-state contract lives in the UI today;
|
||||
* Phase 2 will lift it onto the wire.
|
||||
*/
|
||||
export function SecretInput({ rawValue, onChange, placeholder }: SecretInputProps) {
|
||||
const initial = parseSecretValue(rawValue);
|
||||
// Local UI state for the editor mode. Initialized from the stored
|
||||
// value so reopening the form shows the right shape.
|
||||
const [mode, setMode] = useState<SecretFieldValue['type']>(initial.type);
|
||||
// For literal / env_ref modes we keep a local draft so the user can
|
||||
// type freely. We push to the parent on each keystroke.
|
||||
const [literalDraft, setLiteralDraft] = useState(
|
||||
initial.type === 'literal' ? initial.value : '',
|
||||
);
|
||||
const [envDraft, setEnvDraft] = useState(
|
||||
initial.type === 'env_ref' ? initial.env_name : '',
|
||||
);
|
||||
|
||||
const emit = (next: SecretFieldValue) => {
|
||||
onChange(serializeSecretValue(next));
|
||||
};
|
||||
|
||||
const setLiteralMode = () => {
|
||||
setMode('literal');
|
||||
// Don't emit yet — wait for the user to type. Pre-fill the parent
|
||||
// with an empty literal so save reflects "literal=empty" rather than
|
||||
// the previous masked value.
|
||||
emit({ type: 'literal', value: literalDraft });
|
||||
};
|
||||
|
||||
const setEnvMode = () => {
|
||||
setMode('env_ref');
|
||||
emit({ type: 'env_ref', env_name: envDraft });
|
||||
};
|
||||
|
||||
const setClearedMode = () => {
|
||||
setMode('cleared');
|
||||
emit({ type: 'cleared' });
|
||||
};
|
||||
|
||||
const setUnchangedMode = () => {
|
||||
setMode('unchanged');
|
||||
emit({ type: 'unchanged' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{mode === 'unchanged' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value="••••••••"
|
||||
readOnly
|
||||
className="flex-1 h-8 px-2.5 text-[13px] border border-hairline rounded-md bg-slate-50 text-slate-500"
|
||||
/>
|
||||
<span className="inline-flex items-center px-2 h-6 text-2xs rounded bg-slate-100 text-slate-600">
|
||||
masked
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'literal' && (
|
||||
<input
|
||||
type="password"
|
||||
value={literalDraft}
|
||||
onChange={e => {
|
||||
setLiteralDraft(e.target.value);
|
||||
emit({ type: 'literal', value: e.target.value });
|
||||
}}
|
||||
placeholder={placeholder ?? 'sk-...'}
|
||||
className="w-full h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'env_ref' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2 h-8 text-2xs rounded bg-slate-100 text-slate-600 font-mono">
|
||||
${'{'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={envDraft}
|
||||
onChange={e => {
|
||||
const next = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
setEnvDraft(next);
|
||||
emit({ type: 'env_ref', env_name: next });
|
||||
}}
|
||||
placeholder="ENV_VAR_NAME"
|
||||
className="flex-1 h-8 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white font-mono"
|
||||
/>
|
||||
<span className="inline-flex items-center px-2 h-8 text-2xs rounded bg-slate-100 text-slate-600 font-mono">
|
||||
{'}'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'cleared' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value="(cleared)"
|
||||
readOnly
|
||||
className="flex-1 h-8 px-2.5 text-[13px] border border-hairline rounded-md bg-slate-50 text-slate-400 italic"
|
||||
/>
|
||||
<span className="inline-flex items-center px-2 h-6 text-2xs rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
will be cleared
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1 text-2xs">
|
||||
{/* Show "Keep" only when the server actually has a masked value
|
||||
to keep; otherwise the option is meaningless. */}
|
||||
{initial.type === 'unchanged' && mode !== 'unchanged' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setUnchangedMode}
|
||||
className="px-2 py-0.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Keep current
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'literal' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setLiteralMode}
|
||||
className="px-2 py-0.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Edit literal
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'env_ref' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setEnvMode}
|
||||
className="px-2 py-0.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Use env var
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'cleared' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setClearedMode}
|
||||
className="px-2 py-0.5 rounded border border-amber-200 text-amber-700 hover:bg-amber-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
interface SettingsSidebarProps {
|
||||
activeSection?: string;
|
||||
onSelectSection: (section: string) => void;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings navigation, restructured to match the
|
||||
* 2026-05-21-settings-ui-and-config-restructure-design.md (Step 3).
|
||||
*
|
||||
* The form components themselves are intentionally not rewritten in this
|
||||
* step — Provider/Workers/etc keep reading `provider.*` for now and will
|
||||
* show as partially empty against the v2 API. Steps 7-9 swap those forms
|
||||
* to read the new `llm.*` / `gateway.*` keys.
|
||||
*
|
||||
* Old sidebar ids (provider, workspace, tools, browser-settings,
|
||||
* search-filter) still parse via `urlState.ts` and are redirected to
|
||||
* their new homes by `LEGACY_SECTION_REDIRECT` in this file. This keeps
|
||||
* old bookmarks/links working through the transition.
|
||||
*/
|
||||
const CONFIG_GROUPS = [
|
||||
{
|
||||
label: 'User',
|
||||
sections: [
|
||||
{ id: 'preferences', label: 'Preferences' },
|
||||
{ id: 'notifications', label: '🔔 Notifications' },
|
||||
{ id: 'memory-learning', label: '🧠 Memory & Learning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'branding', label: 'Branding' },
|
||||
{ id: 'paths-storage', label: 'Paths & Storage' },
|
||||
{ id: 'execution', label: 'Execution' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'LLM',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'llm-workers', label: 'Workers' },
|
||||
// Step 8: Gateway Keys absorbed into Gateway Server as the
|
||||
// "Virtual Keys" section. Bookmarks to `gateway-keys` are
|
||||
// redirected via LEGACY_SECTION_REDIRECT below.
|
||||
{ id: 'gateway-server', label: 'Gateway Server' },
|
||||
{ id: 'llm-metrics', label: 'Metrics' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Agent Runtime',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'ask-subtasks', label: 'Ask / Subtasks' },
|
||||
{ id: 'context', label: 'Context' },
|
||||
{ id: 'safety', label: 'Safety' },
|
||||
{ id: 'reflection', label: 'Reflection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'tools-web', label: 'Web & Search' },
|
||||
{ id: 'tools-browser', label: 'Browser Runtime' },
|
||||
{ id: 'tools-media', label: 'Media & Documents' },
|
||||
{ id: 'tools-external', label: 'External Services' },
|
||||
{ id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MCP & Connections',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'mcp', label: 'MCP Runtime' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SSH',
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'ssh', label: 'Admin SSH' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Old sidebar id → new id mapping. Used by `SettingsPage` to upgrade
|
||||
* URLs / bookmarks left over from the pre-Step-3 sidebar layout. Keep
|
||||
* each entry until the underlying old id is fully removed from
|
||||
* `SETTINGS_SECTIONS` in `urlState.ts`.
|
||||
*
|
||||
* `tools` (the catch-all tab) maps to the first new Tools sub-section.
|
||||
* Power users coming in via that old URL should land on something
|
||||
* visible rather than a blank screen.
|
||||
*/
|
||||
export const LEGACY_SECTION_REDIRECT: Record<string, string> = {
|
||||
provider: 'llm-workers',
|
||||
workspace: 'paths-storage',
|
||||
tools: 'tools-web',
|
||||
'browser-settings': 'tools-browser',
|
||||
'search-filter': 'tools-web',
|
||||
// browser-sessions never had a Settings page in the new layout —
|
||||
// it lives in User Folder. Keep mapping so an old URL still goes
|
||||
// somewhere sensible.
|
||||
'browser-sessions': 'preferences',
|
||||
// Step 8: Gateway Keys folded into Gateway Server. Bookmarks land
|
||||
// on the parent form which now hosts the Virtual Keys section.
|
||||
'gateway-keys': 'gateway-server',
|
||||
skills: 'preferences',
|
||||
};
|
||||
|
||||
/** Sections that any authenticated user (not just admin) can access. */
|
||||
export const USER_SECTIONS: string[] = CONFIG_GROUPS
|
||||
.filter(g => !('adminOnly' in g) || !g.adminOnly)
|
||||
.flatMap(g => g.sections.map(s => s.id));
|
||||
|
||||
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
|
||||
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
|
||||
{visibleGroups.map(group => (
|
||||
<div key={group.label} className="mb-3">
|
||||
<div className="section-label px-2 py-1">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.sections.map(s => (
|
||||
<button key={s.id} onClick={() => onSelectSection(s.id)}
|
||||
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
|
||||
activeSection === s.id
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user