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, isOfficePreviewable, officePreviewKind } 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 { SpaceChatTabBar } from './SpaceChatTabBar';
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)';

  // 狭幅でチャットを開いているときは、ドット/タイトル/タブ群を1本化した統合ヘッダー行を
  // 隠してヘッダーの段数を減らす（会話画面が複数バーに埋もれないように）。広幅(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 + Tabs 統合行: 左からドット/タイトル/タブ群(スクロール)/右端固定
          (アバター・削除)。2行(約86px)→1行(約40px)。タイトルは max-w-[28ch] で
          truncate し、タブ群が flex-1 を持つ。 */}
      <div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface pl-4 pr-2`}>
        <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
        <SpaceHeaderTitle space={space} canManage={canManage} />
        <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
          <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>
        <div className="flex shrink-0 items-center gap-2">
          {space.kind === 'case' && (
            <SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
          )}
          {space.kind === 'case' && canManage && (
            <SpaceDeleteButton
              spaceId={spaceId}
              title={space.title}
              onDeleted={() => onSelectSpace?.(undefined)}
            />
          )}
        </div>
      </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>
  );
}

/**
 * ヘッダーのワークスペース名。説明文はタイトルの title 属性（ツールチップ）に退避し、
 * 行の幅は max-w-[28ch] で制約してタブ群に flex-1 を譲る。管理権限があれば鉛筆ボタンで
 * 編集ダイアログ（名前・色・説明）を開く。権限が無ければ表示のみ。
 */
function SpaceHeaderTitle({
  space,
  canManage,
}: {
  space: Space;
  canManage: boolean;
}) {
  const { t } = useTranslation('spaces');
  const [editing, setEditing] = useState(false);

  return (
    <div className="flex min-w-0 max-w-[28ch] shrink items-center gap-1.5">
      <h1
        title={space.description || undefined}
        className="min-w-0 truncate text-[15px] font-bold text-slate-800"
      >
        {space.title}
      </h1>
      {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 shrink-0 whitespace-nowrap 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 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]);

  // タスクのファイルにアップロード/削除できるか。スペースメンバーでもタスク所有者で
  // なければサーバ側 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>

      <SpaceChatTabBar
        tabs={tabs}
        activeTab={activeTab}
        onSelect={(id) => setActiveTab(id as DetailTabId | 'chat')}
        ariaLabel={t('chatTabsLabel')}
        renderLabel={(tb) => t(tb.labelKey)}
        appearClass={(id) => tabAppearClass(id as DetailTabId | 'chat')}
        actions={
          chatReady && task ? (
            <>
              <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>
            </>
          ) : undefined
        }
      />

      <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 (isOfficePreviewable(name)) {
        const kind = officePreviewKind(name);
        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>
  );
}
