Files
maestro/ui/src/components/spaces/SpaceDetail.tsx
T
oss-sync 29ccaf1e92
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

1195 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
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<SpaceTab>('chat');
const containerRef = useRef<HTMLDivElement>(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 (
<div className="flex h-full items-center justify-center p-6 text-sm text-slate-500">
左の一覧からワークスペースを選んでください。
</div>
);
}
const dot = space.brandColor ?? 'var(--brand-primary)';
// 狭幅でチャットを開いているときは、スペースタイトルと「チャット|ファイル」タブ行を
// 隠してヘッダーの段数を減らす(会話画面が複数バーに埋もれないように)。広幅(md+)では
// 常時表示、チャット未選択(一覧表示)の狭幅でも表示する。
const chatOpen = tab === 'chat' && spaceTaskId != null;
const hideOnMobileWhenChatOpen = chatOpen ? 'hidden md:flex' : 'flex';
return (
<div ref={containerRef} data-testid="space-detail" className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
<SpaceHeaderTitle
spaceId={spaceId}
title={space.title}
canManage={canManage}
/>
{space.kind === 'case' && (
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
)}
{space.kind === 'case' && canManage && (
<SpaceDeleteButton
spaceId={spaceId}
title={space.title}
onDeleted={() => onSelectSpace?.(undefined)}
/>
)}
</div>
{/* Tabs */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>チャット</TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>ファイル</TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>アプリ</TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>カレンダー</TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>スケジュール</TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>設定</TabButton>
</div>
{/* Body */}
<div className={`relative flex-1 min-h-0 ${tab === 'files' ? 'overflow-y-auto p-4' : tab === 'apps' ? 'overflow-y-auto' : tab === 'settings' ? 'overflow-hidden p-2' : 'overflow-hidden'}`}>
{tab === 'chat' && (
// key={spaceId}: ワークスペース切替で SpaceChat を remount し、scope トグル等の
// ローカル state を確実にリセットする(files/apps/settings タブと同じ方針)。
<SpaceChat
key={spaceId}
spaceId={spaceId}
isPersonalSpace={space.kind === 'personal'}
spaceTaskId={spaceTaskId}
onSelectSpaceTask={onSelectSpaceTask}
onCreateTask={onCreateTask}
/>
)}
{/* key={spaceId}: ワークスペースを切り替えたら detail のサブツリーを remount し、
各パネルのローカル state(Monaco エディタの内容・編集中フォーム・プレビュー等)を
確実にリセットする。query は spaceId を含むキーで別途 refetch されるが、
ローカル state は remount しないと前のワークスペースの値が残る(空内容で上書き
されない AGENTS.md の stale 表示など)。 */}
{tab === 'files' && <SpaceFiles key={spaceId} spaceId={spaceId} canManage={canEditFiles} />}
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} />}
{tab === 'calendar' && (
<SpaceCalendar
key={spaceId}
spaceId={spaceId}
canEdit={canEdit}
onOpenChat={(taskId) => { onSelectSpaceTask(taskId); setTab('chat'); }}
/>
)}
{tab === 'schedules' && <SchedulesPage key={spaceId} spaceId={spaceId} />}
{tab === 'settings' && <SpaceSettings key={spaceId} spaceId={spaceId} />}
</div>
</div>
);
}
/**
* ヘッダーのワークスペース名。管理権限があればインラインで名前変更できる
* (鉛筆ボタン → 入力 → 保存で 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<HTMLInputElement>(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 (
<div className="flex min-w-0 flex-1 items-center gap-1">
<h1 className="min-w-0 truncate text-[15px] font-bold text-slate-800">{title}</h1>
{canManage && (
<button
type="button"
data-testid="space-rename"
onClick={startEdit}
title="ワークスペース名を変更"
aria-label="ワークスペース名を変更"
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
</svg>
</button>
)}
</div>
);
}
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
<input
ref={inputRef}
data-testid="space-rename-input"
value={value}
onChange={e => 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"
/>
<button
type="button"
data-testid="space-rename-save"
onClick={() => void save()}
disabled={updateSpace.isPending}
title="保存"
aria-label="保存"
className="inline-flex h-7 items-center rounded-md bg-accent px-2 text-xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditing(false)}
title="キャンセル"
aria-label="キャンセル"
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
>
キャンセル
</button>
</div>
);
}
/**
* 案件ワークスペースの削除(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 (
<div className="flex shrink-0 items-center gap-1">
<span className="hidden text-2xs text-slate-500 sm:inline">{title}」を削除?</span>
<button
type="button"
data-testid="space-delete-confirm"
onClick={() => void doDelete()}
disabled={archiveSpace.isPending}
className="inline-flex h-7 items-center rounded-md bg-red-600 px-2 text-xs font-bold text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
削除する
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
>
キャンセル
</button>
</div>
);
}
return (
<button
type="button"
data-testid="space-delete"
onClick={() => setConfirming(true)}
title="ワークスペースを削除"
aria-label="ワークスペースを削除"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
);
}
/**
* スペースヘッダー右詰めの「共有メンバー」アバター列。
* 共有されているとき(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 (
<button
type="button"
onClick={onManage}
data-testid="space-member-avatars"
title={label}
aria-label={label}
className="flex shrink-0 items-center -space-x-2 rounded-full pl-1 transition hover:opacity-80"
>
{shown.map(m => (
<MemberAvatar key={m.userId} url={m.avatarUrl} name={m.name} />
))}
{overflow > 0 && (
<span className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-surface bg-surface-2 text-[10px] font-semibold text-slate-600">
+{overflow}
</span>
)}
</button>
);
}
function MemberAvatar({ url, name }: { url: string | null; name: string | null }) {
const initial = (name ?? '?').trim().charAt(0).toUpperCase() || '?';
if (url) {
return <img src={url} alt="" className="h-6 w-6 rounded-full border-2 border-surface object-cover" />;
}
return (
<span className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-surface bg-surface-2 text-[10px] font-semibold text-slate-600">
{initial}
</span>
);
}
function TabButton({
active,
onClick,
children,
testid,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
testid?: string;
}) {
return (
<button
type="button"
data-testid={testid}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{children}
</button>
);
}
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<TaskScope>('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<SortMode>('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 (
<div className="flex h-full min-h-0">
{/* 左: チャット一覧。会話を開いている狭幅では隠す(会話が一覧を置き換える)。 */}
<div
className={`w-full md:w-[280px] md:shrink-0 flex flex-col min-h-0 overflow-hidden border-r border-hairline ${
spaceTaskId != null ? 'hidden md:flex' : 'flex'
}`}
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">このワークスペースのチャット</span>
<button
type="button"
data-testid="space-new-chat-btn"
onClick={() => setShowCreate(true)}
className="shrink-0 rounded-md bg-accent px-2.5 py-1.5 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
>
新規
</button>
</div>
{hasOthersTasks && (
<div
data-testid="space-chat-scope-toggle"
className="flex items-center gap-1 border-b border-hairline px-3 py-1.5"
>
{([['mine', '自分'], ['others', '他のメンバー']] as const).map(([val, label]) => (
<button
key={val}
type="button"
data-testid={`space-chat-scope-${val}`}
aria-pressed={scope === val}
onClick={() => setScope(val)}
className={`rounded-md px-2.5 py-1 text-xs font-semibold transition-colors ${
scope === val
? 'bg-accent text-accent-fg'
: 'text-slate-500 hover:bg-surface hover:text-slate-700'
}`}
>
{label}
</button>
))}
</div>
)}
{totalCount > 0 && (
<div className="px-3 pt-2">
<FilterBar
selectedStatus={selectedStatus}
sortMode={sortMode}
searchQuery={searchQuery}
counts={counts}
totalCount={totalCount}
onStatusChange={setSelectedStatus}
onSortChange={setSortMode}
onSearchChange={setSearchQuery}
/>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{totalCount === 0 ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{hasOthersTasks && scope === 'others'
? '他のメンバーが作成したチャットはありません。'
: 'このワークスペースにはまだチャットがありません。「+ 新規」で始めましょう。'}
</p>
) : visibleTasks.length === 0 ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
条件に一致するチャットがありません。
</p>
) : (
<div className="space-y-1.5">
{visibleTasks.map(task => (
<div key={task.id} data-testid="space-chat-row" data-task-id={task.id}>
<LocalTaskListItem
task={task}
active={task.id === spaceTaskId}
onClick={() => onSelectSpaceTask(task.id)}
/>
</div>
))}
</div>
)}
</div>
</div>
{/* 右: 選択したチャットの会話をインライン表示。 */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
{spaceTaskId != null ? (
// key={spaceTaskId}: チャット切替で会話サブツリー(ChatPane 含む)を remount し、
// 入力中の下書き・添付が別チャットへ持ち越されないようにする。詳細タブの切替では
// remount しないので、2ペイン内のチャット入力は保たれる。
<SpaceConversation
key={spaceTaskId}
taskId={spaceTaskId}
onBack={() => onSelectSpaceTask(0)}
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
左の一覧からチャットを選ぶか、「+ 新規」で始めてください。
</div>
)}
</div>
{showCreate && (
<CreateTaskDialog
onClose={() => setShowCreate(false)}
onSubmit={async (input, attachments) => {
await handleSpaceCreate(input, attachments);
setShowCreate(false);
}}
initialSpaceId={spaceId}
/>
)}
</div>
);
}
// スペース内インライン会話。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<DetailTabId | 'chat'>('chat');
const tabs = [{ id: 'chat' as const, labelKey: 'tabs.chat' }, ...detailTabs];
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const fileBrowser = useFileBrowser(taskId);
// モバイル(<md)だけスワイプ UI を「単一 DOM 枝」としてマウントする。両枝を CSS で
// 隠して二重に描画すると ChatPane の textarea 等が複製され strict locator が壊れる
// ため、JS メディアクエリで実際にマウントする側を 1 つに絞る(desktop の DOM は不変)。
const isMobile = useIsMobile();
const hasSubtasks = (task?.subtasks?.length ?? 0) > 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 <SkeletonChatPane />;
// スワイプ中の隣接タブ(preview=true)が browser(noVNC iframe)/ssh(WebSocket) の
// 場合は軽量プレースホルダを返し、未確定のスワイプで接続を開かない。確定後
// (preview=false) に本体をマウントする(Tasks ページと同じ)。
if (preview && (id === 'browser' || id === 'ssh')) {
return (
<div className="flex h-full w-full items-center justify-center bg-canvas text-sm font-medium text-slate-400">
{id === 'browser' ? t('tabs.browser') : t('tabs.ssh')}
</div>
);
}
if (id === 'chat') {
return (
<ChatPane
task={task!}
comments={comments}
onSubmit={handleComment}
onCancel={handleCancel}
/>
);
}
const detailTab = id as DetailTabId;
return (
<LocalDetailPanel
headerless
task={task}
taskId={taskId}
section={fileBrowser.section}
currentPath={fileBrowser.currentPath}
entries={fileBrowser.entries}
pathSegments={fileBrowser.pathSegments}
loading={taskQuery.isLoading}
detailTab={detailTab}
detailWidth="normal"
showWidthToggle={false}
onTabChange={(tab) => 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 (
<div data-testid="space-conversation" className="relative flex h-full min-h-0 flex-col overflow-hidden">
{/* 狭幅では一覧へ戻るボタン(広幅では一覧が常時見えるので不要)。 */}
<button
type="button"
onClick={onBack}
className="md:hidden inline-flex items-center gap-1 px-3 py-2 text-xs font-semibold text-slate-600 border-b border-hairline hover:text-slate-900"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4l-4 4 4 4" />
</svg>
一覧へ
</button>
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
同じ実体(ShareButton・ContinueButton・ContinueWithPieceDialog・
updateLocalTask)を再利用し、機能パリティを保つ。タブバーとは別行に置き、
狭幅でも横並びのまま収まるアイコン主体の密度にする。 */}
{chatReady && task && (
<div
data-testid="space-chat-actions"
className="flex items-center gap-1.5 border-b border-hairline px-3 py-1.5"
>
{/* 公開範囲は選択不可: スペース内のチャットは常にそのスペースの
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
<span
data-testid="space-chat-visibility-note"
title="このワークスペースのメンバーだけが閲覧できます"
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
>
🔒 このワークスペースのメンバーに公開
</span>
<div className="ml-auto flex items-center gap-1">
<ContinueButton
testid="space-chat-continue"
latestJobStatus={task.latestJob?.status ?? null}
onClick={() => setContinueOpen(true)}
/>
<ShareButton
testid="space-chat-share"
taskId={taskId}
shareToken={task.shareToken ?? null}
onShareChange={refetchTask}
/>
<button
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title="削除"
aria-label="削除"
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
</div>
</div>
)}
{/* 単一インプレース・タブバー。会話+詳細タブ(ファイルを除く)を1本に集約し、
戻る導線を「会話」タブのみに統一する(オーバーレイ・✕ を廃止)。 */}
<div
role="tablist"
aria-label={t('chatTabsLabel')}
data-testid="space-chat-tabs"
className="flex items-center gap-1 overflow-x-auto border-b border-hairline px-3"
>
{tabs.map((tb, i) => {
const active = tb.id === activeTab;
return (
<button
key={tb.id}
ref={(el) => { tabRefs.current[i] = el; }}
type="button"
role="tab"
id={`space-chat-tab-${tb.id}`}
data-testid={`space-chat-tab-${tb.id}`}
aria-selected={active}
aria-controls="space-chat-tabpanel"
tabIndex={active ? 0 : -1}
onClick={() => setActiveTab(tb.id)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${tabAppearClass(tb.id)} ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{t(tb.labelKey)}
</button>
);
})}
</div>
<div
id="space-chat-tabpanel"
role="tabpanel"
tabIndex={0}
aria-labelledby={`space-chat-tab-${activeTab}`}
className="relative flex-1 min-h-0 overflow-hidden"
>
<OutputPreviewProvider openOutputPath={openOutputPath}>
{isMobile ? (
// モバイル(<md): 同じタブ集合を指でスワイプして切り替え。先頭(会話)で
// 右スワイプすると onBack(一覧へ戻る)を呼ぶ。上のクリックタブバーは残るので
// タップでも切り替え可能(Tasks ページと同じパターン)。
<SwipeableTabs
tabs={tabIds}
activeTab={activeTab}
onTabChange={setActiveTab}
onSwipeBackFromFirst={onBack}
renderTab={(id, preview) => renderTabContent(id, preview)}
/>
) : (
// デスクトップ(md+): 会話を左に常時表示し、詳細タブ(ブラウザ/SSH/概要 等)を
// 右に並べる2ペイン。`chat` タブ選択時は右を畳んでチャット全幅にする。左ペインは
// remount されないので、詳細を開閉してもチャットの入力中テキストは保たれる。
<ChatDetailSplit
storageKey="space.chatDetailWidthPx"
rightVisible={activeTab !== 'chat'}
left={renderTabContent('chat')}
right={activeTab !== 'chat' ? renderTabContent(activeTab) : null}
/>
)}
</OutputPreviewProvider>
</div>
{continueOpen && task?.latestJob && (
<ContinueWithPieceDialog
taskId={taskId}
prevJob={{
id: task.latestJob.id,
// task.pieceName は Continue ごとに last-piece-wins で更新されるので
// 最新ジョブの piece と一致する(DetailPanel と同じ前提)。
pieceName: task.pieceName,
status: task.latestJob.status,
}}
onClose={() => setContinueOpen(false)}
/>
)}
{previewState && (
<FilePreview
name={previewState.name}
content={previewState.content}
imageSrc={previewState.imageSrc}
markdownImageBaseUrl={previewState.markdownImageBaseUrl}
trustedHtmlUrl={previewState.trustedHtmlUrl}
onClose={closePreview}
taskId={previewState.taskId}
section={previewState.section}
filePath={previewState.filePath}
editable={previewState.editable}
/>
)}
{toast && (
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 z-10">
<div className={`pointer-events-auto rounded-md px-3 py-2 text-xs font-medium shadow-lg ${
toast.variant === 'error' ? 'bg-red-600 text-white' : 'bg-slate-800 text-white'
}`}>
{toast.message}
</div>
</div>
)}
</div>
);
}
// --- ファイル窓(スペースの永続ワークスペース)---
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<LocalFileEntry[]>([]);
const [loadError, setLoadError] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const [preview, setPreview] = useState<SpacePreviewState | null>(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<Set<string>>(() => 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 (
<div data-testid="space-files" className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 pt-1 font-mono text-2xs text-slate-500 break-all">
/files{currentPath ? `/${currentPath}` : ''}
</div>
<FileActions
idPrefix="space"
canUpload={canManage}
onUploadFiles={files => void uploadFiles(files)}
onRefresh={() => void load()}
isRefreshing={isRefreshing}
isUploading={isUploading}
/>
</div>
<FileBreadcrumb testid="space-files-breadcrumb" pathSegments={pathSegments} onNavigate={setCurrentPath} />
{canManage && selectablePaths.length > 0 && (
<FileSelectionBar
idPrefix="space"
allSelected={allSelected}
onToggleSelectAll={() => setSelected(allSelected ? new Set() : new Set(selectablePaths))}
selectedCount={selectedInView.length}
onDeleteSelected={() => void deleteSelected(selectedInView)}
onDownloadSelected={() => void downloadSelected(selectedInView)}
isDeleting={isDeleting}
isDownloading={isDownloading}
/>
)}
{loadError && <p className="text-xs text-red-600">{loadError}</p>}
{uploadMsg && (
<p className={`text-xs ${uploadMsg.kind === 'ok' ? 'text-emerald-600' : 'text-red-600'}`}>{uploadMsg.text}</p>
)}
<FileDropzone
idPrefix="space"
enabled={canManage}
isUploading={isUploading}
onDropFiles={files => void uploadFiles(files)}
onRejectFolder={() => setUploadMsg({ text: 'フォルダは未対応です。ファイルを選んでください。', kind: 'error' })}
>
<FileTileGrid
entries={sorted}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => 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 (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title="アプリとして実行"
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100"
>
アプリとして実行
</button>
);
}}
emptyHint={loadError ? null : (canManage
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
: 'ファイルがありません。')}
/>
</FileDropzone>
{preview && (
<FilePreview
name={preview.name}
content={preview.content}
imageSrc={preview.imageSrc}
markdownImageBaseUrl={preview.markdownImageBaseUrl}
trustedHtmlUrl={preview.trustedHtmlUrl}
onClose={() => setPreview(null)}
/>
)}
{appToRun && (
<AppRunner
spaceId={spaceId}
appName={appToRun.appName}
entryPath={appToRun.entryPath}
onClose={() => setAppToRun(null)}
/>
)}
</div>
);
}