1411 lines
63 KiB
TypeScript
1411 lines
63 KiB
TypeScript
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<void>;
|
||
onOpenTask: (id: number) => void;
|
||
chatFilter: SpaceChatFilter;
|
||
onChatFilterChange: (next: Partial<SpaceChatFilter>) => 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<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">
|
||
<EmptyState
|
||
title={t('detail.empty.title')}
|
||
hint={t('detail.empty.hint')}
|
||
/>
|
||
</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
|
||
space={space}
|
||
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')}>{t('detail.tab.chat')}</TabButton>
|
||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
|
||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
|
||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
|
||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
|
||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.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}
|
||
filter={chatFilter}
|
||
onFilterChange={onChatFilterChange}
|
||
/>
|
||
)}
|
||
{/* 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} canManage={canManage} />}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
|
||
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
|
||
*/
|
||
function SpaceHeaderTitle({
|
||
space,
|
||
canManage,
|
||
}: {
|
||
space: Space;
|
||
canManage: boolean;
|
||
}) {
|
||
const { t } = useTranslation('spaces');
|
||
const [editing, setEditing] = useState(false);
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
|
||
{space.description && (
|
||
<span
|
||
data-testid="space-description"
|
||
title={space.description}
|
||
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
|
||
>
|
||
{space.description}
|
||
</span>
|
||
)}
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
data-testid="space-edit"
|
||
onClick={() => setEditing(true)}
|
||
title={t('detail.editSpace')}
|
||
aria-label={t('detail.editSpace')}
|
||
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>
|
||
)}
|
||
{editing && <SpaceFormDialog space={space} onClose={() => setEditing(false)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 案件ワークスペースの削除(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 (
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<span className="hidden text-2xs text-slate-500 sm:inline">{t('detail.deletePrompt', { 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"
|
||
>
|
||
{t('detail.deleteConfirmButton')}
|
||
</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"
|
||
>
|
||
{t('common:cancel')}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
data-testid="space-delete"
|
||
onClick={() => setConfirming(true)}
|
||
title={t('detail.deleteSpace')}
|
||
aria-label={t('detail.deleteSpace')}
|
||
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 }) {
|
||
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 (
|
||
<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,
|
||
filter,
|
||
onFilterChange,
|
||
}: {
|
||
spaceId: string;
|
||
isPersonalSpace: boolean;
|
||
spaceTaskId?: number;
|
||
onSelectSpaceTask: (id: number) => void;
|
||
onCreateTask: SpaceDetailProps['onCreateTask'];
|
||
filter: SpaceChatFilter;
|
||
onFilterChange: (next: Partial<SpaceChatFilter>) => 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 (
|
||
<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">{t('chat.listHeading')}</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"
|
||
>
|
||
{t('chat.new')}
|
||
</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) => (
|
||
<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'
|
||
}`}
|
||
>
|
||
{t(`chat.scope.${val}`)}
|
||
</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 ? (
|
||
hasOthersTasks && scope === 'others' ? (
|
||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||
{t('chat.empty.others')}
|
||
</p>
|
||
) : (
|
||
<div className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||
<p>{t('chat.empty.none')}</p>
|
||
<p className="mt-1.5 text-2xs text-slate-400 leading-relaxed">
|
||
{t('chat.empty.filesHint')}
|
||
</p>
|
||
</div>
|
||
)
|
||
) : visibleTasks.length === 0 ? (
|
||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||
{t('chat.empty.noMatch')}
|
||
</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">
|
||
{t('chat.selectHint')}
|
||
</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 { 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<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>
|
||
{ts('conversation.backToList')}
|
||
</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={ts('conversation.visibilityTitle')}
|
||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
|
||
>
|
||
{ts('conversation.visibilityNote')}
|
||
</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={ts('common:delete')}
|
||
aria-label={ts('common:delete')}
|
||
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}
|
||
office={previewState.office}
|
||
/>
|
||
)}
|
||
{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;
|
||
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<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 [isMoving, setIsMoving] = useState(false);
|
||
// 「移動」ダイアログで移動する対象(null = 閉じている)。
|
||
const [moveDialogSources, setMoveDialogSources] = useState<string[] | null>(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 (
|
||
<button
|
||
type="button"
|
||
data-testid={`space-file-rename-${entry.name}`}
|
||
onClick={() => void renameEntry(entry)}
|
||
title={t('files.rename')}
|
||
aria-label={t('files.renameAria', { name: entry.name })}
|
||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||
>
|
||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M10.5 2.5l3 3L6 13l-3.5.5L3 10z" />
|
||
</svg>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
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-0.5">
|
||
<FileBreadcrumb
|
||
testid="space-files-breadcrumb"
|
||
pathSegments={pathSegments}
|
||
onNavigate={setCurrentPath}
|
||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||
/>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
|
||
<FileViewToggle idPrefix="space" mode={viewMode} onChange={setViewMode} />
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
data-testid="space-files-mkdir-btn"
|
||
onClick={() => void createFolder()}
|
||
className="inline-flex items-center gap-1 px-2 h-7 rounded text-2xs font-medium border border-hairline bg-canvas text-slate-600 hover:bg-surface transition-colors"
|
||
title={t('files.mkdirTitle')}
|
||
>
|
||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5zM8 7.5v4M6 9.5h4" />
|
||
</svg>
|
||
{t('files.newFolder')}
|
||
</button>
|
||
)}
|
||
<FileActions
|
||
idPrefix="space"
|
||
canUpload={canManage}
|
||
onUploadFiles={files => void uploadFiles(files)}
|
||
onRefresh={() => void load()}
|
||
isRefreshing={isRefreshing}
|
||
isUploading={isUploading}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{!currentPath && !loadError && selectablePaths.length === 0 && (
|
||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||
{t('files.inputHint')}
|
||
</p>
|
||
)}
|
||
|
||
{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)}
|
||
onMoveSelected={() => setMoveDialogSources(selectedInView)}
|
||
isDeleting={isDeleting}
|
||
isDownloading={isDownloading}
|
||
isMoving={isMoving}
|
||
/>
|
||
)}
|
||
|
||
{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: t('files.folderNotSupported'), kind: 'error' })}
|
||
>
|
||
{viewMode === 'detail' ? (
|
||
<FileDetailList
|
||
entries={sortedEntries}
|
||
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)}
|
||
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 && (
|
||
<button
|
||
type="button"
|
||
data-testid={`space-app-run-${appEntry.appName}`}
|
||
onClick={() => setAppToRun(appEntry)}
|
||
title={t('files.runAsApp')}
|
||
className="inline-flex h-5 items-center rounded bg-[var(--brand-primary)] px-1.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-90 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||
>
|
||
{t('files.run')}
|
||
</button>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
emptyHint={loadError ? null : (canManage
|
||
? t('files.emptyManage')
|
||
: t('files.empty'))}
|
||
/>
|
||
) : (
|
||
<FileTileGrid
|
||
entries={sortedEntries}
|
||
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)}
|
||
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 (
|
||
<button
|
||
type="button"
|
||
data-testid={`space-app-run-${appEntry.appName}`}
|
||
onClick={() => setAppToRun(appEntry)}
|
||
title={t('files.runAsApp')}
|
||
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 reveal-hover"
|
||
>
|
||
{t('files.runAsApp')}
|
||
</button>
|
||
);
|
||
}}
|
||
emptyHint={loadError ? null : (canManage
|
||
? t('files.emptyManage')
|
||
: t('files.empty'))}
|
||
/>
|
||
)}
|
||
</FileDropzone>
|
||
|
||
{preview && (
|
||
<FilePreview
|
||
name={preview.name}
|
||
content={preview.content}
|
||
imageSrc={preview.imageSrc}
|
||
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
||
trustedHtmlUrl={preview.trustedHtmlUrl}
|
||
office={preview.office}
|
||
onClose={() => setPreview(null)}
|
||
/>
|
||
)}
|
||
|
||
{appToRun && (
|
||
<AppRunner
|
||
gateway={createSpaceGateway(spaceId)}
|
||
appName={appToRun.appName}
|
||
entryPath={appToRun.entryPath}
|
||
onClose={() => setAppToRun(null)}
|
||
/>
|
||
)}
|
||
|
||
{moveDialogSources && (
|
||
<MoveTargetDialog
|
||
spaceId={spaceId}
|
||
sourcePaths={moveDialogSources}
|
||
isMoving={isMoving}
|
||
onClose={() => setMoveDialogSources(null)}
|
||
onConfirm={dest => {
|
||
setMoveDialogSources(null);
|
||
void moveInto(moveDialogSources, dest);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|