Files
maestro/ui/src/components/spaces/SpaceDetail.tsx
T
oss-sync a6d1879d63
CI / build-and-test (push) Has been cancelled
sync: update from private repo (c764df2f)
2026-06-29 01:32:25 +00:00

1411 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, 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>
);
}