1195 lines
52 KiB
TypeScript
1195 lines
52 KiB
TypeScript
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>
|
||
);
|
||
}
|