import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSpaces, useUpdateSpace, useArchiveSpace } from '../../hooks/useSpaces'; import { useLocalTaskList } from '../../hooks/useTaskList'; import { useLocalTask, useLocalTaskComments } from '../../hooks/useTaskDetail'; import { useTaskOperations } from '../../hooks/useTaskOperations'; import { useToast } from '../../hooks/useToast'; import { useSpaceBranding } from '../../hooks/useSpaceBranding'; import { useFileBrowser } from '../../hooks/useFileBrowser'; import { useIsMobile } from '../../hooks/useIsMobile'; import { useFilePreview } from '../../hooks/useFilePreview'; import { useSubtaskActivities } from '../../hooks/useSubtaskActivities'; import { useVisibleDetailTabs, tabAppearClass } from '../detail/detailTabs'; import { LocalDetailPanel } from '../detail/DetailPanel'; import { ShareButton, ContinueButton } from '../detail/DetailHeader'; import { ContinueWithPieceDialog } from '../detail/ContinueWithPieceDialog'; import type { DetailTabId, SortMode, StatusColumn } from '../../lib/urlState'; import { filterTasksForSpace } from '../../lib/spaceTasks'; import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount } from '../../lib/taskFilter'; import { filterTasksByScope, type TaskScope } from '../../lib/taskScope'; import { FilterBar } from '../list/FilterBar'; import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils'; import { CreateTaskDialog } from '../create/CreateTaskDialog'; import { LocalTaskListItem } from '../list/TaskListItem'; import { ChatPane } from '../chat/ChatPane'; import { SpaceSettings } from './SpaceSettings'; import { SpaceCalendar } from './SpaceCalendar'; import { SchedulesPage } from '../../pages/SchedulesPage'; import { SpaceApps } from './SpaceApps'; import { useAuthState } from '../../App'; import { SkeletonChatPane } from '../shared/Skeleton'; import { SwipeableTabs } from '../mobile/SwipeableTabs'; import { FilePreview } from '../files/FilePreview'; import { FileTileGrid } from '../files/FileTileGrid'; import { FileBreadcrumb } from '../files/FileBreadcrumb'; import { FileActions, FileSelectionBar, FileDropzone } from '../files/FileToolbar'; import { filesToBase64 } from '../../lib/fileBase64'; import { AppRunner } from './AppRunner'; import { detectAppEntry } from './app-bridge'; import { ChatDetailSplit } from './ChatDetailSplit'; import { OutputPreviewProvider } from '../../lib/output-preview-context'; import { stripOutputPrefix } from '../../lib/output-path-detect'; import { createLocalTask, fetchSpaceMembers, fetchSpaceFiles, fetchSpaceFileContent, getSpaceFileRawUrl, getSpaceTrustedHtmlUrl, uploadSpaceFiles, deleteSpaceFiles, downloadSpaceFilesZip, type CreateLocalTaskInput, type LocalFileEntry, } from '../../api'; interface SpaceDetailProps { spaceId?: string; spaceTaskId?: number; onSelectSpace?: (id: string | undefined) => void; onSelectSpaceTask: (id: number) => void; onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise; onOpenTask: (id: number) => void; } type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings'; export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpaceDetailProps) { const { data: spaces } = useSpaces(); const [tab, setTab] = useState('chat'); const containerRef = useRef(null); const auth = useAuthState(); const space = spaces?.find(s => s.id === spaceId); // owner(または admin)だけが管理操作(名前変更・削除・予定編集)を行える。 // auth 無効(ローカル)では全員可。バックエンドの canManageSpace と揃える // (member.role==='owner' は UI では検出しないが、その場合もサーバが許可する)。 const canManage = auth.mode === 'disabled' || (auth.mode === 'authenticated' && (auth.user.role === 'admin' || (space?.ownerId != null && space.ownerId === auth.user.id))); // カレンダー編集権の別名(既存の prop 名を保つ)。 const canEdit = canManage; // ファイル編集(アップロード/削除)可否。管理権より広く、editor メンバーも含む // (サーバの canEditInSpace と揃える: admin / owner / member.role∈{owner,editor})。 const myRole = space?.myRole ?? null; const canEditFiles = auth.mode === 'disabled' || (auth.mode === 'authenticated' && (auth.user.role === 'admin' || (space?.ownerId != null && space.ownerId === auth.user.id) || myRole === 'owner' || myRole === 'editor')); // ブランド色をこのパネルに scoped 適用(スペースを離れる / 色なしで解除)。 useSpaceBranding(containerRef, space?.brandColor); if (!spaceId || !space) { return (
左の一覧からワークスペースを選んでください。
); } const dot = space.brandColor ?? 'var(--brand-primary)'; // 狭幅でチャットを開いているときは、スペースタイトルと「チャット|ファイル」タブ行を // 隠してヘッダーの段数を減らす(会話画面が複数バーに埋もれないように)。広幅(md+)では // 常時表示、チャット未選択(一覧表示)の狭幅でも表示する。 const chatOpen = tab === 'chat' && spaceTaskId != null; const hideOnMobileWhenChatOpen = chatOpen ? 'hidden md:flex' : 'flex'; return (
{/* Header */}
{space.kind === 'case' && ( setTab('settings')} /> )} {space.kind === 'case' && canManage && ( onSelectSpace?.(undefined)} /> )}
{/* Tabs */}
setTab('chat')}>チャット setTab('files')}>ファイル setTab('apps')}>アプリ setTab('calendar')}>カレンダー setTab('schedules')}>スケジュール setTab('settings')}>設定
{/* Body */}
{tab === 'chat' && ( // key={spaceId}: ワークスペース切替で SpaceChat を remount し、scope トグル等の // ローカル state を確実にリセットする(files/apps/settings タブと同じ方針)。 )} {/* key={spaceId}: ワークスペースを切り替えたら detail のサブツリーを remount し、 各パネルのローカル state(Monaco エディタの内容・編集中フォーム・プレビュー等)を 確実にリセットする。query は spaceId を含むキーで別途 refetch されるが、 ローカル state は remount しないと前のワークスペースの値が残る(空内容で上書き されない AGENTS.md の stale 表示など)。 */} {tab === 'files' && } {tab === 'apps' && } {tab === 'calendar' && ( { onSelectSpaceTask(taskId); setTab('chat'); }} /> )} {tab === 'schedules' && } {tab === 'settings' && }
); } /** * ヘッダーのワークスペース名。管理権限があればインラインで名前変更できる * (鉛筆ボタン → 入力 → 保存で PATCH → spaces を invalidate)。権限が無ければ * 単なるタイトル表示。 */ function SpaceHeaderTitle({ spaceId, title, canManage, }: { spaceId: string; title: string; canManage: boolean; }) { const updateSpace = useUpdateSpace(); const [editing, setEditing] = useState(false); const [value, setValue] = useState(title); const inputRef = useRef(null); // 編集を開始したら現在のタイトルを入れ、フォーカスする。 const startEdit = useCallback(() => { setValue(title); setEditing(true); }, [title]); useEffect(() => { if (editing) inputRef.current?.select(); }, [editing]); const save = useCallback(async () => { const next = value.trim(); if (!next || next === title) { setEditing(false); return; } try { await updateSpace.mutateAsync({ id: spaceId, patch: { title: next } }); setEditing(false); } catch { // 失敗時は編集状態のまま(入力を消さない)。 } }, [value, title, spaceId, updateSpace]); if (!editing) { return (

{title}

{canManage && ( )}
); } return (
setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void save(); } else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); } }} className="min-w-0 flex-1 rounded-md border border-hairline bg-canvas px-2 py-1 text-[15px] font-bold text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400" />
); } /** * 案件ワークスペースの削除(archiveSpace = ソフト削除)ボタン。確認ダイアログ後に * アーカイブし、一覧へ戻る(onDeleted)。個人ワークスペースには出さない(呼び出し側で * kind==='case' をガード)。 */ function SpaceDeleteButton({ spaceId, title, onDeleted, }: { spaceId: string; title: string; onDeleted: () => void; }) { const archiveSpace = useArchiveSpace(); const [confirming, setConfirming] = useState(false); const doDelete = useCallback(async () => { try { await archiveSpace.mutateAsync(spaceId); onDeleted(); } catch { // 失敗時は何もしない(一覧に残る)。確認 UI は閉じる。 setConfirming(false); } }, [spaceId, archiveSpace, onDeleted]); if (confirming) { return (
「{title}」を削除?
); } return ( ); } /** * スペースヘッダー右詰めの「共有メンバー」アバター列。 * 共有されているとき(owner + 1名以上 = メンバー総数 2 以上)だけ表示。 * 重なり表示で先頭から最大 4 名、超過は「+N」。クリックで設定タブ(メンバー管理)へ。 */ function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage: () => void }) { // キーは SpaceMembersPanel と共有する(招待/ロール変更/除去の invalidate が // ヘッダーのアバター列にも即時反映されるように)。 const { data: members } = useQuery({ queryKey: ['space-members', spaceId], queryFn: () => fetchSpaceMembers(spaceId), staleTime: 30_000, }); // 共有されていない(オーナーのみ / 取得前)なら何も出さない。 if (!members || members.length < 2) return null; const MAX = 4; const shown = members.slice(0, MAX); const overflow = members.length - shown.length; const label = `共有メンバー ${members.length} 名: ${members.map(m => m.name ?? m.userId).join(', ')}`; return ( ); } function MemberAvatar({ url, name }: { url: string | null; name: string | null }) { const initial = (name ?? '?').trim().charAt(0).toUpperCase() || '?'; if (url) { return ; } return ( {initial} ); } function TabButton({ active, onClick, children, testid, }: { active: boolean; onClick: () => void; children: React.ReactNode; testid?: string; }) { return ( ); } function SpaceChat({ spaceId, isPersonalSpace, spaceTaskId, onSelectSpaceTask, }: { spaceId: string; isPersonalSpace: boolean; spaceTaskId?: number; onSelectSpaceTask: (id: number) => void; onCreateTask: SpaceDetailProps['onCreateTask']; }) { const qc = useQueryClient(); const auth = useAuthState(); const { data: allTasks } = useLocalTaskList(); const [showCreate, setShowCreate] = useState(false); const [scope, setScope] = useState('mine'); const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace); // 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ // 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks // ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という // 既存規約に合わせる)。SpaceChat は key={spaceId} で remount されるので、スペースを // 切り替えると scope は 'mine' に戻る。 const userId = auth.mode === 'authenticated' ? auth.user.id : null; const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId); const tasks = userId != null && hasOthersTasks ? filterTasksByScope(spaceTasks, scope, userId) : spaceTasks; // 検索・ステータス・ソート(Tasks ページの FilterBar と同じ挙動を共有ヘルパーで再現)。 // scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。 const [searchQuery, setSearchQuery] = useState(''); const [selectedStatus, setSelectedStatus] = useState<'all' | StatusColumn>('all'); const [sortMode, setSortMode] = useState('updated'); const statusColumns = groupTasksByStatus(tasks); const counts = statusCounts(statusColumns); const totalCount = totalTaskCount(statusColumns); const visibleTasks = filterAndSortTasks(tasks, selectedStatus, searchQuery, sortMode); // スペースの「新規チャット」専用の作成フロー。App 共通の handleCreateTask は // Tasks ページの taskId を更新してしまうため、ここでは createLocalTask を直接呼び、 // 一覧を再取得 (['localTasks']) してから、できたチャットを spaceTaskId として // その場で開く。 const handleSpaceCreate = useCallback(async ( input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>, ) => { const created = await createLocalTask({ ...input, attachments } as CreateLocalTaskInput); qc.invalidateQueries({ queryKey: ['localTasks'] }); onSelectSpaceTask(created.task.id); }, [qc, onSelectSpaceTask]); return (
{/* 左: チャット一覧。会話を開いている狭幅では隠す(会話が一覧を置き換える)。 */}
このワークスペースのチャット
{hasOthersTasks && (
{([['mine', '自分'], ['others', '他のメンバー']] as const).map(([val, label]) => ( ))}
)} {totalCount > 0 && (
)}
{totalCount === 0 ? (

{hasOthersTasks && scope === 'others' ? '他のメンバーが作成したチャットはありません。' : 'このワークスペースにはまだチャットがありません。「+ 新規」で始めましょう。'}

) : visibleTasks.length === 0 ? (

条件に一致するチャットがありません。

) : (
{visibleTasks.map(task => (
onSelectSpaceTask(task.id)} />
))}
)}
{/* 右: 選択したチャットの会話をインライン表示。 */}
{spaceTaskId != null ? ( // key={spaceTaskId}: チャット切替で会話サブツリー(ChatPane 含む)を remount し、 // 入力中の下書き・添付が別チャットへ持ち越されないようにする。詳細タブの切替では // remount しないので、2ペイン内のチャット入力は保たれる。 onSelectSpaceTask(0)} /> ) : (
左の一覧からチャットを選ぶか、「+ 新規」で始めてください。
)}
{showCreate && ( setShowCreate(false)} onSubmit={async (input, attachments) => { await handleSpaceCreate(input, attachments); setShowCreate(false); }} initialSpaceId={spaceId} /> )}
); } // スペース内インライン会話。App の Tasks 詳細と同じ hook / handler を使い、挙動 // (追加指示送信・キャンセル・ライブ表示)を完全一致させる。 function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => void }) { const { toast, showToast } = useToast(); const taskQuery = useLocalTask(taskId, true); const commentsQuery = useLocalTaskComments(taskId, true); const task = taskQuery.data ?? null; const comments = commentsQuery.data ?? []; // App と同じゲート: 両クエリ完了後にだけ ChatPane をマウントする。 // 先にタスク詳細だけ解決すると comments=[] で一瞬「メッセージはまだありません」が // 出てしまうため。data===undefined は未取得、ロード済みなら最悪でも []。 const chatReady = task !== null && commentsQuery.data !== undefined; const qc = useQueryClient(); // handleComment / handleCancel / handleDelete は Tasks ページと同じ実体 // (useTaskOperations)。Tasks ページは setUrlState で taskId を URL から外して // 詳細を閉じるが、スペース会話には URL state が無い。代わりに、削除後に // handleDelete が渡してくる next state を評価し、taskId:null(=詳細を閉じる // 合図)なら onBack() を呼んでチャット一覧へ戻す。 const { handleComment, handleCancel, handleDelete } = useTaskOperations({ taskId, showToast, setUrlState: (fn) => { const next = fn({}); if (next?.taskId === null || next?.taskId === undefined) onBack(); }, setShowCreateDialog: () => {}, }); // 継続ダイアログの開閉。最新ジョブが終端状態のときのみ Continue を有効化する。 const [continueOpen, setContinueOpen] = useState(false); // スペース内チャットの公開範囲は選択不可(常にスペースメンバーへ公開)。 // 旧 handleVisibilityChange / savingVisibility は撤去した。 // 削除(確認ダイアログ付き)。confirm をキャンセルしたら何もしない。 const confirmAndDelete = useCallback(async () => { if (!window.confirm('このチャットを削除しますか?この操作は取り消せません。')) return; await handleDelete(); }, [handleDelete]); const refetchTask = useCallback(() => { qc.invalidateQueries({ queryKey: ['localTask', taskId] }); }, [qc, taskId]); const { t } = useTranslation('detail'); // 詳細タブ(概要/進捗/トレース/ファイル 等)をスペースを離れずインライン表示する。 // 単一のタブバーをこの SpaceConversation が所有し、`会話` を先頭・既定とする。 // チャット側「ファイル」はスペースレベルの「ファイル」(共有成果物)とは別物で、 // そのチャット固有の入出力・ログ(logs セクション = runs/{taskId})を見るためのもの。 // チャット単位でログを追えるようにここで表示する(スペース共有ファイルとは用途が違う)。 // activeTab は URL ではなくローカル状態で持ち、チャットごとに taskId で remount される // ため自然にリセットされる。'chat' は会話(ChatPane)を指す。 const detailTabs = useVisibleDetailTabs(taskId); const [activeTab, setActiveTab] = useState('chat'); const tabs = [{ id: 'chat' as const, labelKey: 'tabs.chat' }, ...detailTabs]; const tabRefs = useRef>([]); const fileBrowser = useFileBrowser(taskId); // モバイル( 0; const { data: subtaskActivities } = useSubtaskActivities(taskId, hasSubtasks); const { previewState, previewLocalFile, previewSubtaskFile, closePreview } = useFilePreview(showToast); // 選択中タブが(live セッション終了などで)リストから消えたら会話へ戻す。 useEffect(() => { if (!tabs.some(tb => tb.id === activeTab)) setActiveTab('chat'); }, [tabs, activeTab]); // 矢印キーでタブ間移動(端で wrap)+ Home/End で先頭・末尾へ。選択とフォーカスを // 同時に動かす(role=tablist の標準操作)。 const handleTabKeyDown = (e: React.KeyboardEvent, index: number) => { let next: number; if (e.key === 'ArrowRight') next = (index + 1) % tabs.length; else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length; else if (e.key === 'Home') next = 0; else if (e.key === 'End') next = tabs.length - 1; else return; e.preventDefault(); setActiveTab(tabs[next].id); tabRefs.current[next]?.focus(); }; // タスクのファイルにアップロード/削除できるか。スペースメンバーでもタスク所有者で // なければサーバ側 checkTaskOwnership が 403 を返すため、UI も owner/admin/no-auth に限る。 const auth = useAuthState(); const canManageFiles = auth.mode === 'disabled' || (auth.mode === 'authenticated' && (auth.user.role === 'admin' || task?.ownerId === auth.user.id)); const fileManagement = { canManage: canManageFiles, writableSection: fileBrowser.writableSection, selected: fileBrowser.selected, toggleSelect: fileBrowser.toggleSelect, toggleSelectAll: fileBrowser.toggleSelectAll, upload: fileBrowser.upload, remove: fileBrowser.remove, download: fileBrowser.download, isUploading: fileBrowser.isUploading, isDeleting: fileBrowser.isDeleting, isDownloading: fileBrowser.isDownloading, message: fileBrowser.message, }; const handleLocalFilePreview = (filePath: string, name: string) => previewLocalFile(taskId, fileBrowser.section, filePath, name); // チャット/詳細内の output パスリンク(data-output-path)クリックを拾い、 // App の handleOutputPathLinkClick と同じく section を 'output' に固定して // プレビューを開く。`output/...` プレフィックスを剥がして相対パスにする。 const openOutputPath = (matchedPath: string) => { const relative = stripOutputPrefix(matchedPath); const name = relative.includes('/') ? relative.slice(relative.lastIndexOf('/') + 1) : relative; previewLocalFile(taskId, 'output', relative, name); }; const handleSubtaskFilePreview = (tid: number, jobId: string, category: string, filePath: string) => previewSubtaskFile(tid, jobId, category, filePath); // 1タブ分のコンテンツ。デスクトップ(クリック)とモバイル(スワイプ)の両方が // 同じ実体を使うため共通化(ロジック重複を避ける)。`chat` は ChatPane、それ以外は // headerless な LocalDetailPanel を描画する。chatReady 前は Skeleton。 const renderTabContent = (id: DetailTabId | 'chat', preview = false) => { if (!chatReady) return ; // スワイプ中の隣接タブ(preview=true)が browser(noVNC iframe)/ssh(WebSocket) の // 場合は軽量プレースホルダを返し、未確定のスワイプで接続を開かない。確定後 // (preview=false) に本体をマウントする(Tasks ページと同じ)。 if (preview && (id === 'browser' || id === 'ssh')) { return (
{id === 'browser' ? t('tabs.browser') : t('tabs.ssh')}
); } if (id === 'chat') { return ( ); } const detailTab = id as DetailTabId; return ( setActiveTab(tab)} onWidthToggle={() => {}} onClose={() => setActiveTab('chat')} onSectionChange={fileBrowser.setSection} onNavigate={fileBrowser.setCurrentPath} onPreview={handleLocalFilePreview} onViewFullLog={() => handleLocalFilePreview('activity.log', 'activity.log')} onRefresh={fileBrowser.refresh} isRefreshing={fileBrowser.isRefreshing} fileManagement={fileManagement} subtaskActivities={subtaskActivities} onSubtaskFilePreview={handleSubtaskFilePreview} shareToken={task?.shareToken ?? null} /> ); }; const tabIds = tabs.map(tb => tb.id); return (
{/* 狭幅では一覧へ戻るボタン(広幅では一覧が常時見えるので不要)。 */} {/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と 同じ実体(ShareButton・ContinueButton・ContinueWithPieceDialog・ updateLocalTask)を再利用し、機能パリティを保つ。タブバーとは別行に置き、 狭幅でも横並びのまま収まるアイコン主体の密度にする。 */} {chatReady && task && (
{/* 公開範囲は選択不可: スペース内のチャットは常にそのスペースの メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */} 🔒 このワークスペースのメンバーに公開
setContinueOpen(true)} />
)} {/* 単一インプレース・タブバー。会話+詳細タブ(ファイルを除く)を1本に集約し、 戻る導線を「会話」タブのみに統一する(オーバーレイ・✕ を廃止)。 */}
{tabs.map((tb, i) => { const active = tb.id === activeTab; return ( ); })}
{isMobile ? ( // モバイル( renderTabContent(id, preview)} /> ) : ( // デスクトップ(md+): 会話を左に常時表示し、詳細タブ(ブラウザ/SSH/概要 等)を // 右に並べる2ペイン。`chat` タブ選択時は右を畳んでチャット全幅にする。左ペインは // remount されないので、詳細を開閉してもチャットの入力中テキストは保たれる。 )}
{continueOpen && task?.latestJob && ( setContinueOpen(false)} /> )} {previewState && ( )} {toast && (
{toast.message}
)}
); } // --- ファイル窓(スペースの永続ワークスペース)--- interface SpacePreviewState { name: string; content: string; imageSrc: string; markdownImageBaseUrl?: string; trustedHtmlUrl?: string; } // ソースライブラリの curated 一覧 UI は撤去した(#6)。エージェントが取得した資料は // 引き続き source/ フォルダに蓄積され(WebFetch / DownloadFile / BrowseWeb → source/、 // source/index.jsonl)、通常のフォルダとしてファイル窓からブラウズできる。 // (旧 SpaceSources コンポーネント + 出典メタ整形ヘルパー + fetchSpaceSourceIndex は削除済み。) // canManage 既定は true(個人ワークスペースなど App から直接マウントする経路では // 自分のワークスペースなので削除可)。共有ワークスペースでは SpaceDetail が // owner/admin 判定を渡す。サーバ側も canEditInSpace で再度ゲートする。 export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) { const [currentPath, setCurrentPath] = useState(''); const [entries, setEntries] = useState([]); const [loadError, setLoadError] = useState(''); const [isRefreshing, setIsRefreshing] = useState(false); const [preview, setPreview] = useState(null); // ワークスペース・アプリ(apps/{name}/index.html)を AppRunner で実行中のときの対象。 const [appToRun, setAppToRun] = useState<{ appName: string; entryPath: string } | null>(null); const [isUploading, setIsUploading] = useState(false); const [uploadMsg, setUploadMsg] = useState<{ text: string; kind: 'ok' | 'error' } | null>(null); // 複数選択(チェック済みファイルの相対パス集合)と削除中フラグ。 const [selected, setSelected] = useState>(() => new Set()); const [isDeleting, setIsDeleting] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const load = useCallback(async () => { setIsRefreshing(true); try { const r = await fetchSpaceFiles(spaceId, currentPath); setEntries(r.entries); setLoadError(''); } catch { setEntries([]); setLoadError('ファイルの取得に失敗しました'); } finally { setIsRefreshing(false); } }, [spaceId, currentPath]); useEffect(() => { void load(); }, [load]); // スペースを切り替えたらルートに戻す。 useEffect(() => { setCurrentPath(''); }, [spaceId]); const handlePreview = useCallback(async (filePath: string, name: string) => { try { if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) { const imageSrc = getSpaceFileRawUrl(spaceId, filePath); const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined; setPreview({ name, content: '', imageSrc, trustedHtmlUrl }); return; } const content = await fetchSpaceFileContent(spaceId, filePath); let markdownImageBaseUrl: string | undefined; if (/\.(md|markdown)$/i.test(name)) { const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/') + 1) : ''; markdownImageBaseUrl = `/api/local/spaces/${spaceId}/files/raw?path=${dir}`; } setPreview({ name, content, imageSrc: '', markdownImageBaseUrl }); } catch { setLoadError('ファイルの読み込みに失敗しました'); } }, [spaceId]); const uploadFiles = useCallback(async (fileList: File[]) => { if (fileList.length === 0) return; setIsUploading(true); setUploadMsg(null); try { const payload = await filesToBase64(fileList); const r = await uploadSpaceFiles(spaceId, currentPath, payload); await load(); setUploadMsg({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' }); } catch (e) { setUploadMsg({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' }); } finally { setIsUploading(false); } }, [spaceId, currentPath, load]); // フォルダ移動・ワークスペース切替で選択をクリア(別ディレクトリのパスを持ち越さない)。 useEffect(() => { setSelected(new Set()); }, [currentPath, spaceId]); const toggleSelect = useCallback((path: string) => { setSelected(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); // 選択中のファイルを削除する(複数選択は paths 配列で一括)。サーバが realpath // ガード + canEditInSpace で再ゲートするが、UI は相対パスだけを送る。 const deleteSelected = useCallback(async (paths: string[]) => { if (paths.length === 0) return; if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return; setIsDeleting(true); setUploadMsg(null); try { const r = await deleteSpaceFiles(spaceId, paths); setSelected(new Set()); await load(); setUploadMsg({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' }); } catch (e) { setUploadMsg({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' }); } finally { setIsDeleting(false); } }, [spaceId, load]); // 選択ファイルを zip でダウンロード(read 操作)。 const downloadSelected = useCallback(async (paths: string[]) => { if (paths.length === 0) return; setIsDownloading(true); setUploadMsg(null); try { await downloadSpaceFilesZip(spaceId, paths); } catch (e) { setUploadMsg({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' }); } finally { setIsDownloading(false); } }, [spaceId]); const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : []; const dirs = entries.filter(e => e.kind === 'directory'); // source/index.jsonl は「ソース」グループのデータ源なので、生のファイル一覧には // 出さない(ノイズ回避)。source/ フォルダ自体は通常どおりブラウズできる。 const files = entries.filter( e => e.kind !== 'directory' && !(currentPath === 'source' && e.name === 'index.jsonl'), ); const sorted = [ ...dirs.sort((a, b) => a.name.localeCompare(b.name)), ...files.sort((a, b) => a.name.localeCompare(b.name)), ]; // 選択可能なのはファイルのみ(ディレクトリは削除対象外)。 const selectablePaths = files.map(f => f.path); const allSelected = selectablePaths.length > 0 && selectablePaths.every(p => selected.has(p)); const selectedInView = selectablePaths.filter(p => selected.has(p)); return (
/files{currentPath ? `/${currentPath}` : ''}
void uploadFiles(files)} onRefresh={() => void load()} isRefreshing={isRefreshing} isUploading={isUploading} />
{canManage && selectablePaths.length > 0 && ( setSelected(allSelected ? new Set() : new Set(selectablePaths))} selectedCount={selectedInView.length} onDeleteSelected={() => void deleteSelected(selectedInView)} onDownloadSelected={() => void downloadSelected(selectedInView)} isDeleting={isDeleting} isDownloading={isDownloading} /> )} {loadError &&

{loadError}

} {uploadMsg && (

{uploadMsg.text}

)} void uploadFiles(files)} onRejectFolder={() => setUploadMsg({ text: 'フォルダは未対応です。ファイルを選んでください。', kind: 'error' })} > void handlePreview(path, name)} onDeleteOne={path => void deleteSelected([path])} isDeleting={isDeleting} fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)} renderTileOverlay={entry => { // apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。 const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null; if (!appEntry) return null; return ( ); }} emptyHint={loadError ? null : (canManage ? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。' : 'ファイルがありません。')} /> {preview && ( setPreview(null)} /> )} {appToRun && ( setAppToRun(null)} /> )}
); }