import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSpaces, 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 { workspaceDirRole } from '../../lib/workspaceDirs'; import { FilterBar } from '../list/FilterBar'; import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils'; import type { OfficePreviewDescriptor } from '../files/FilePreview'; 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 { EmptyState } from '../shared/EmptyState'; import { SwipeableTabs } from '../mobile/SwipeableTabs'; import { FilePreview } from '../files/FilePreview'; import { FileTileGrid } from '../files/FileTileGrid'; import { FileDetailList } from '../files/FileDetailList'; import { useFileView } from '../../hooks/useFileView'; import { FileBreadcrumb } from '../files/FileBreadcrumb'; import { MoveTargetDialog } from '../files/MoveTargetDialog'; import { resolveMoves } from '../../lib/fileMove'; import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from '../files/FileToolbar'; import { filesToBase64 } from '../../lib/fileBase64'; import { AppRunner } from './AppRunner'; import { createSpaceGateway } from './app-file-gateway'; 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, getSpaceFileOfficePreviewUrl, getSpaceTrustedHtmlUrl, uploadSpaceFiles, deleteSpaceFiles, createSpaceFolder, moveSpaceFile, downloadSpaceFilesZip, type CreateLocalTaskInput, type LocalFileEntry, type Space, } from '../../api'; import { SpaceFormDialog } from './SpaceFormDialog'; /** Filter state for the space chat list. Persisted in the URL by App (urlState) * so it survives tab switches, reloads and bookmarks. */ export interface SpaceChatFilter { search: string; status: 'all' | StatusColumn; sort: SortMode; scope: TaskScope; } 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; chatFilter: SpaceChatFilter; onChatFilterChange: (next: Partial) => void; } type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings'; export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) { const { t } = useTranslation('spaces'); 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')}>{t('detail.tab.chat')} setTab('files')}>{t('detail.tab.files')} setTab('apps')}>{t('detail.tab.apps')} setTab('calendar')}>{t('detail.tab.calendar')} setTab('schedules')}>{t('detail.tab.schedules')} setTab('settings')}>{t('detail.tab.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' && }
); } /** * ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が * あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。 */ function SpaceHeaderTitle({ space, canManage, }: { space: Space; canManage: boolean; }) { const { t } = useTranslation('spaces'); const [editing, setEditing] = useState(false); return (

{space.title}

{space.description && ( {space.description} )} {canManage && ( )} {editing && setEditing(false)} />}
); } /** * 案件ワークスペースの削除(archiveSpace = ソフト削除)ボタン。確認ダイアログ後に * アーカイブし、一覧へ戻る(onDeleted)。個人ワークスペースには出さない(呼び出し側で * kind==='case' をガード)。 */ function SpaceDeleteButton({ spaceId, title, onDeleted, }: { spaceId: string; title: string; onDeleted: () => void; }) { const { t } = useTranslation('spaces'); 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 (
{t('detail.deletePrompt', { title })}
); } return ( ); } /** * スペースヘッダー右詰めの「共有メンバー」アバター列。 * 共有されているとき(owner + 1名以上 = メンバー総数 2 以上)だけ表示。 * 重なり表示で先頭から最大 4 名、超過は「+N」。クリックで設定タブ(メンバー管理)へ。 */ function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage: () => void }) { const { t } = useTranslation('spaces'); // キーは 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 = t('detail.sharedMembers', { count: members.length, names: 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, filter, onFilterChange, }: { spaceId: string; isPersonalSpace: boolean; spaceTaskId?: number; onSelectSpaceTask: (id: number) => void; onCreateTask: SpaceDetailProps['onCreateTask']; filter: SpaceChatFilter; onFilterChange: (next: Partial) => void; }) { const { t } = useTranslation('spaces'); const qc = useQueryClient(); const auth = useAuthState(); const { data: allTasks } = useLocalTaskList(); const { data: spaces } = useSpaces(); const [showCreate, setShowCreate] = useState(false); const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter; const setScope = (val: TaskScope) => onFilterChange({ scope: val }); const setSearchQuery = (val: string) => onFilterChange({ search: val }); const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val }); const setSortMode = (val: SortMode) => onFilterChange({ sort: val }); const userId = auth.mode === 'authenticated' ? auth.user.id : null; // 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。 const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true; // case スペースの id 集合。個人バケツ判定(null か case でない id = 誰かの個人 // スペース)に使う。admin の listSpaces は全 case スペースを返すので集合は完全。 const caseSpaceIds = useMemo( () => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)), [spaces], ); let spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace, caseSpaceIds); // 可視性ルール: 個人ワークスペースは本人専用。admin だけが他ユーザーの個人コンテンツを // 「他のメンバー」タブで監視できる。非 admin の個人スペースは自分の所有分に絞り、他人を // 一切出さない(リスト API が public/org 等を返しても個人 WS には混ぜない)。 if (isPersonalSpace && !isAdmin && userId != null) { spaceTasks = spaceTasks.filter(t => t.ownerId === userId); } // 自分/他メンバーの切替。共有ワークスペースのメンバー間、または個人スペースを admin が // 監視するときに「他の人のタスク」が存在する場合だけ出す。スコープ分割は Tasks ページと // 同じ filterTasksByScope を再利用する(owner_id null は others 側、という既存規約に合わ // せる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の onSelectSpace が // search/status/sort/scope を明示リセットする(remount 依存ではない)。 const othersAllowed = !isPersonalSpace || isAdmin; const hasOthersTasks = othersAllowed && userId != null && spaceTasks.some(t => t.ownerId !== userId); const tasks = userId != null && hasOthersTasks ? filterTasksByScope(spaceTasks, scope, userId) : spaceTasks; // 検索・ステータス・ソート(Tasks ページの FilterBar と同じ挙動を共有ヘルパーで再現)。 // scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。state は // URL 永続化のため SpaceDetail(親)→ App の urlState から流す。 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 (
{/* 左: チャット一覧。会話を開いている狭幅では隠す(会話が一覧を置き換える)。 */}
{t('chat.listHeading')}
{hasOthersTasks && (
{(['mine', 'others'] as const).map((val) => ( ))}
)} {totalCount > 0 && (
)}
{totalCount === 0 ? ( hasOthersTasks && scope === 'others' ? (

{t('chat.empty.others')}

) : (

{t('chat.empty.none')}

{t('chat.empty.filesHint')}

) ) : visibleTasks.length === 0 ? (

{t('chat.empty.noMatch')}

) : (
{visibleTasks.map(task => (
onSelectSpaceTask(task.id)} />
))}
)}
{/* 右: 選択したチャットの会話をインライン表示。 */}
{spaceTaskId != null ? ( // key={spaceTaskId}: チャット切替で会話サブツリー(ChatPane 含む)を remount し、 // 入力中の下書き・添付が別チャットへ持ち越されないようにする。詳細タブの切替では // remount しないので、2ペイン内のチャット入力は保たれる。 onSelectSpaceTask(0)} /> ) : (
{t('chat.selectHint')}
)}
{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 { t: ts } = useTranslation('spaces'); 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(ts('conversation.deleteConfirm'))) 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 && (
{/* 公開範囲は選択不可: スペース内のチャットは常にそのスペースの メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */} {ts('conversation.visibilityNote')}
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; office?: OfficePreviewDescriptor; } // ソースライブラリの 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 { t } = useTranslation('spaces'); 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 [isMoving, setIsMoving] = useState(false); // 「移動」ダイアログで移動する対象(null = 閉じている)。 const [moveDialogSources, setMoveDialogSources] = useState(null); const load = useCallback(async () => { setIsRefreshing(true); try { const r = await fetchSpaceFiles(spaceId, currentPath); setEntries(r.entries); setLoadError(''); } catch { setEntries([]); setLoadError(t('files.loadError')); } finally { setIsRefreshing(false); } }, [spaceId, currentPath]); useEffect(() => { void load(); }, [load]); // スペースを切り替えたらルートに戻す。 useEffect(() => { setCurrentPath(''); }, [spaceId]); const handlePreview = useCallback(async (filePath: string, name: string) => { try { if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) { const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation'; setPreview({ name, content: '', imageSrc: '', office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) }, }); return; } 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(t('files.previewError')); } }, [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: t('files.uploadedCount', { count: r.uploaded.length }), kind: 'ok' }); } catch (e) { setUploadMsg({ text: t('files.uploadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' }); } finally { setIsUploading(false); } }, [spaceId, currentPath, load]); // 現在フォルダに空フォルダを作る。既存スペースに readonly/ を後付けする用途も兼ねる。 const createFolder = useCallback(async () => { const name = window.prompt(t('files.newFolderPrompt'))?.trim(); if (!name) return; if (/[\\/]/.test(name)) { setUploadMsg({ text: t('files.folderNameInvalid'), kind: 'error' }); return; } const rel = currentPath ? `${currentPath}/${name}` : name; setUploadMsg(null); try { await createSpaceFolder(spaceId, rel); await load(); setUploadMsg({ text: t('files.folderCreated', { name }), kind: 'ok' }); } catch (e) { setUploadMsg({ text: t('files.folderCreateFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' }); } }, [spaceId, currentPath, load]); // ファイル/フォルダのリネーム(move エンドポイント経由)。構造ディレクトリ // (input/output/logs/apps/readonly)はサーバ側で拒否され、UI でも行アクションを出さない。 const renameEntry = useCallback(async (entry: LocalFileEntry) => { const next = window.prompt(t('files.renamePrompt'), entry.name)?.trim(); if (!next || next === entry.name) return; if (/[\\/]/.test(next)) { setUploadMsg({ text: t('files.nameInvalid'), kind: 'error' }); return; } const slash = entry.path.lastIndexOf('/'); const parent = slash >= 0 ? entry.path.slice(0, slash) : ''; const to = parent ? `${parent}/${next}` : next; setUploadMsg(null); try { const r = await moveSpaceFile(spaceId, entry.path, to); await load(); setUploadMsg({ text: t('files.renamed', { from: entry.name, to: r.to.split('/').pop() }), kind: 'ok' }); } catch (e) { setUploadMsg({ text: t('files.renameFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' }); } }, [spaceId, load]); // ファイル/フォルダをフォルダへ移動する(ドラッグ移動・複数選択移動の共通経路)。 // resolveMoves が no-op / 自己内移動を事前除外し、残りを move エンドポイントへ順に投げる。 // 衝突はサーバが自動リネームするため、ここでは件数だけ報告する。 const moveInto = useCallback(async (sourcePaths: string[], destDir: string) => { const { moves, skipped } = resolveMoves(sourcePaths, destDir); if (moves.length === 0) { if (skipped.length > 0) setUploadMsg({ text: t('files.alreadyThere'), kind: 'ok' }); return; } setIsMoving(true); setUploadMsg(null); let moved = 0; let failed = 0; for (const m of moves) { try { await moveSpaceFile(spaceId, m.from, m.to); moved++; } catch { failed++; } } setSelected(new Set()); await load(); const where = destDir ? t('files.moveTargetFolder', { name: destDir.split('/').pop() }) : t('files.moveTargetRoot'); setUploadMsg( failed > 0 // 一部でも失敗したら赤で示す(成功緑だと失敗を見落とすため)。 ? { text: t('files.movedWithFailures', { moved, where, failed }), kind: 'error' } : { text: t('files.moved', { moved, where }), kind: 'ok' }, ); setIsMoving(false); }, [spaceId, 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(t('files.deleteConfirm', { count: paths.length }))) return; setIsDeleting(true); setUploadMsg(null); try { const r = await deleteSpaceFiles(spaceId, paths); setSelected(new Set()); await load(); setUploadMsg({ text: t('files.deletedCount', { count: r.deleted.length }), kind: 'ok' }); } catch (e) { setUploadMsg({ text: t('files.deleteFailed', { msg: (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: t('files.downloadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' }); } finally { setIsDownloading(false); } }, [spaceId]); const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : []; // source/index.jsonl は「ソース」グループのデータ源なので、生のファイル一覧には // 出さない(ノイズ回避)。source/ フォルダ自体は通常どおりブラウズできる。 const visibleEntries = entries.filter( e => !(e.kind !== 'directory' && currentPath === 'source' && e.name === 'index.jsonl'), ); const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(visibleEntries); // アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態に縮約して乗せる。 // サイズ順は詳細表示の列見出しから操作する(タスク窓の FileBrowser と同方針)。 const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name'; const onMenuSort = (s: FileSort) => setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' }); // 選択可能なのはファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有 は保護で除外)。 const selectablePaths = visibleEntries .filter(e => e.kind !== 'directory' || workspaceDirRole(e.path, e.name, e.kind) == null) .map(f => f.path); const allSelected = selectablePaths.length > 0 && selectablePaths.every(p => selected.has(p)); const selectedInView = selectablePaths.filter(p => selected.has(p)); // リネームボタン(詳細表示・アイコン表示で共有)。構造ディレクトリ // (input/output/logs/apps/readonly)はサーバ側で拒否されるため出さない。 const renameButton = (entry: LocalFileEntry) => { const isStructural = workspaceDirRole(entry.path, entry.name, entry.kind) != null; if (!canManage || isStructural) return null; return ( ); }; return (
{/* パンくず(現在地)+ 操作。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
void moveInto(s, d) : undefined} />
{viewMode === 'icon' && } {canManage && ( )} void uploadFiles(files)} onRefresh={() => void load()} isRefreshing={isRefreshing} isUploading={isUploading} />
{!currentPath && !loadError && selectablePaths.length === 0 && (

{t('files.inputHint')}

)} {canManage && selectablePaths.length > 0 && ( setSelected(allSelected ? new Set() : new Set(selectablePaths))} selectedCount={selectedInView.length} onDeleteSelected={() => void deleteSelected(selectedInView)} onDownloadSelected={() => void downloadSelected(selectedInView)} onMoveSelected={() => setMoveDialogSources(selectedInView)} isDeleting={isDeleting} isDownloading={isDownloading} isMoving={isMoving} /> )} {loadError &&

{loadError}

} {uploadMsg && (

{uploadMsg.text}

)} void uploadFiles(files)} onRejectFolder={() => setUploadMsg({ text: t('files.folderNotSupported'), kind: 'error' })} > {viewMode === 'detail' ? ( void handlePreview(path, name)} onDeleteOne={path => void deleteSelected([path])} isDeleting={isDeleting} fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)} onDownloadDir={path => void downloadSelected([path])} sort={sort} onSort={toggleSort} onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined} renderRowAction={entry => { // 詳細表示でも apps/{name}/index.html は「実行」で起動できるようにする // (アイコン表示の renderTileOverlay と対)。 const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null; const rename = renameButton(entry); if (!appEntry && !rename) return null; return ( <> {rename} {appEntry && ( )} ); }} emptyHint={loadError ? null : (canManage ? t('files.emptyManage') : t('files.empty'))} /> ) : ( void handlePreview(path, name)} onDeleteOne={path => void deleteSelected([path])} isDeleting={isDeleting} fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)} onDownloadDir={path => void downloadSelected([path])} onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined} renderEntryAction={renameButton} 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 ? t('files.emptyManage') : t('files.empty'))} /> )} {preview && ( setPreview(null)} /> )} {appToRun && ( setAppToRun(null)} /> )} {moveDialogSources && ( setMoveDialogSources(null)} onConfirm={dest => { setMoveDialogSources(null); void moveInto(moveDialogSources, dest); }} /> )}
); }