import { test, expect, type Page } from '@playwright/test';

// ── Shared helpers for the space task/settings parity suite ───────────────────
//
// The tests below all start the same way: open Spaces, create a case space, open
// it, and (for chat actions) start an inline chat. The first batch of tests in
// this file inlines that flow; the feature-parity tests added later reuse these
// helpers to stay focused on the action under test. The helpers mirror the
// inlined flow exactly (same testids, same waits) — they do NOT relax any
// assertion, they just remove copy-paste.

// The no-auth + live-session probe allowlist shared by the chat/settings tests.
// Identical set to the inline-chat/settings tests above: with auth disabled the
// UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local
// user; CreateTaskDialog's org/MCP probes 401; the per-task console/browser
// status probes 404 with no live session. All benign, not failures.
const SPACE_BENIGN_PATHS = new Set([
  '/api/auth/me',
  '/api/users/me/orgs',
  '/api/mcp/connections',
  '/api/mcp/servers',
  '/api/mcp/user-servers',
  '/api/ssh/connections',
]);
const SPACE_BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
const SPACE_BENIGN_PATH_RE =
  /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;

/** Attach the standard fatal-error tracker used by every space test and return
 *  the collected list (assert it stays empty at the end of the test). */
function trackFatalErrors(page: Page): string[] {
  const fatalErrors: string[] = [];
  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (
        !SPACE_BENIGN_ASSET.test(pathname) &&
        !SPACE_BENIGN_PATHS.has(pathname) &&
        !SPACE_BENIGN_PATH_RE.test(pathname)
      ) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });
  return fatalErrors;
}

/** Open Spaces, create a case space with a unique title, select it, and return
 *  the detail locator + the title used. Mirrors the inlined create flow. */
async function createAndOpenCaseSpace(page: Page, label: string) {
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();
  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `${label}-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();
  const spaceId = await caseRow.getAttribute('data-space-id');
  expect(spaceId).toBeTruthy();
  await caseRow.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();
  return { detail, caseTitle, rail, spaceId: spaceId as string };
}

/** Start a new inline chat in the currently-open space, returning the inline
 *  conversation locator and the created task id (from the chat= URL param). */
async function startInlineChat(page: Page, body: string) {
  await page.getByTestId('space-tab-chat').click();
  await page.getByTestId('space-new-chat-btn').click();
  const composer = page.getByTestId('create-task-body');
  await expect(composer).toBeVisible();
  await composer.fill(body);
  await page.getByTestId('create-task-submit').click();

  const conversation = page.getByTestId('space-conversation');
  await expect(conversation).toBeVisible();
  await expect(page).toHaveURL(/[?&]chat=\d+/);
  const taskId = new URL(page.url()).searchParams.get('chat');
  expect(taskId).toBeTruthy();
  return { conversation, taskId: taskId as string };
}

type TaskDetail = {
  task?: {
    latestJob?: { status?: string } | null;
    shareToken?: string | null;
    feedbackRating?: string | null;
    visibility?: string | null;
  };
};

/** Fetch the task detail. NOTE the API wraps the row under `.task`. */
async function fetchTask(page: Page, taskId: string): Promise<TaskDetail['task'] | null> {
  const res = await page.request.get(`/api/local/tasks/${taskId}`);
  if (!res.ok()) return null;
  return ((await res.json()) as TaskDetail).task ?? null;
}

const TERMINAL_JOB_STATES = ['succeeded', 'failed', 'waiting_human', 'cancelled', 'aborted'];

/** Poll the task detail until the latest job reaches a terminal state, OR the
 *  window expires. Returns the terminal status, or '' if the job never settled.
 *
 *  ENV NOTE: a terminal job requires the worker to actually run the job, which
 *  needs a reachable LLM. The E2E env configures NO provider, so the worker
 *  stays unhealthy and the job sits `queued` indefinitely — it never reaches a
 *  terminal state here. Tests that need the terminal branch (Continue / Feedback)
 *  therefore skip when this returns '' (documented at each call site), and run
 *  the full active assertion only when a real LLM IS reachable. */
async function waitForTerminalJob(page: Page, taskId: string, timeout = 30_000): Promise<string> {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    const task = await fetchTask(page, taskId);
    const status = task?.latestJob?.status ?? '';
    if (TERMINAL_JOB_STATES.includes(status)) return status;
    await page.waitForTimeout(1500);
  }
  return '';
}

// End-to-end smoke for the spaces-foundation UI (plan 4, Task 5).
//
// Drives the real orchestrator (auth off => synthetic 'local' user). The
// personal space is auto-created on the first /api/local/spaces call, so it is
// always present in a fresh temp DB. We then create a case space and open it.
//
// Selectors use data-testid added to the spaces components rather than brittle
// Japanese label text, but the visible labels stay Japanese.

test('spaces foundation: rail, create case space, open detail tabs', async ({ page }) => {
  // Track only genuinely fatal client errors. Uncaught JS exceptions
  // (pageerror) are always fatal. Generic console "Failed to load resource"
  // messages carry no URL, so we resolve the real culprit from the matching
  // HTTP response and ignore benign static-asset 404s (favicon, source maps,
  // PWA manifest) that don't break the flow.
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  // When auth is disabled (our default E2E config), /api/auth/me is NOT mounted,
  // so the UI's probe 404s on purpose and falls back to the synthetic local
  // user. That 404 is the expected no-auth signal, not a failure.
  const benignPaths = new Set(['/api/auth/me']);

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const url = res.url();
      const { pathname } = new URL(url);
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${url}`);
      }
    }
  });

  // 1. Open the app. The server redirects / -> /ui; load /ui directly.
  await page.goto('/ui');

  // App shell is up — the Spaces nav tab is rendered.
  const navSpaces = page.getByTestId('nav-spaces');
  await expect(navSpaces).toBeVisible();

  // 2. Navigate to the Spaces tab.
  await navSpaces.click();

  // 3. The space rail renders, with the auto-created personal space pinned.
  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  const personalRow = rail.locator('[data-testid="space-row"][data-space-kind="personal"]');
  await expect(personalRow).toHaveCount(1);
  await expect(personalRow).toBeVisible();

  // 4. Create a new case space via 新規.
  await page.getByTestId('create-space-btn').click();

  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2E案件-${Date.now()}`;
  await titleInput.fill(caseTitle);

  await page.getByTestId('create-space-submit').click();

  // 5. The new case space appears in the rail (and dialog closes).
  await expect(titleInput).toHaveCount(0);
  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();

  // 6. Selecting it (creation auto-selects it) shows the SpaceDetail with both tabs.
  await caseRow.click();
  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();
  await expect(detail.getByText(caseTitle)).toBeVisible();

  await expect(page.getByTestId('space-tab-chat')).toBeVisible();
  await expect(page.getByTestId('space-tab-files')).toBeVisible();

  // 7. The files tab opens without a fatal error (empty tree is fine).
  await page.getByTestId('space-tab-files').click();
  await expect(page.getByTestId('space-files')).toBeVisible();

  // No fatal client errors (uncaught exceptions / non-asset HTTP failures).
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// Mobile (<md) layout: the rail and detail must NOT sit side-by-side on a
// phone. With no space selected the rail is a full-width list; selecting a
// space replaces the rail with a full-width detail plus a "← ワークスペース一覧"
// back control that returns to the rail. At >=md both are visible together
// (unchanged), so this invariant only holds at narrow widths.
test('spaces mobile: rail <-> detail switch with back control at 390px', async ({ page }) => {
  await page.setViewportSize({ width: 390, height: 844 });

  // At phone widths the TopBar collapses its inline nav into a hamburger
  // drawer, so the nav-spaces tab isn't directly clickable. Navigate via the
  // URL state (page=spaces) instead — it's the same selection, sans drawer.
  await page.goto('/ui?page=spaces');

  // No space selected: the rail is the visible full-width list; detail hidden.
  const rail = page.getByTestId('space-rail');
  const detail = page.getByTestId('space-detail');
  await expect(rail).toBeVisible();
  await expect(detail).toBeHidden();
  await expect(page.getByTestId('space-back-to-list')).toHaveCount(0);

  // Create + auto-select a case space.
  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2Eモバイル-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  // A space is open: detail is now the visible full-width view, rail is hidden,
  // and the mobile back control is present.
  await expect(detail).toBeVisible();
  await expect(detail.getByText(caseTitle)).toBeVisible();
  await expect(rail).toBeHidden();
  const back = page.getByTestId('space-back-to-list');
  await expect(back).toBeVisible();

  // Tapping back clears the selection and returns to the full-width rail.
  await back.click();
  await expect(rail).toBeVisible();
  await expect(detail).toBeHidden();
  await expect(page.getByTestId('space-back-to-list')).toHaveCount(0);
});

// Inline conversation inside a space (plan 6). The user starts a chat from the
// space's チャット tab and the conversation (ChatPane: messages + composer)
// renders INLINE — without ever navigating to the Tasks page. No LLM is
// configured in the E2E env, so the agent never completes; we only assert the
// conversation UI is present and the no-navigation invariant holds.
test('space inline chat: create + open conversation without leaving for Tasks', async ({ page }) => {
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  // With auth disabled, these probes 401 on purpose (the CreateTaskDialog
  // fetches orgs + MCP connections which require an authenticated user). The
  // UI tolerates the 401 and falls back to empty lists, so they are the
  // expected no-auth signal — not a failure.
  const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
  // useVisibleDetailTabs (now used by the inline conversation's detail tabs)
  // polls per-task console/browser-session status to decide whether to show the
  // live SSH/Browser tabs. With no live session these 404 by design and the
  // hook degrades to "tab hidden" — benign, like the no-auth probes above.
  const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });

  // 1. Open the app, go to Spaces, create a case space.
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();

  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2E会話-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();
  await caseRow.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();

  // 2. On the チャット tab, start a new chat.
  await page.getByTestId('space-tab-chat').click();
  await page.getByTestId('space-new-chat-btn').click();

  const body = page.getByTestId('create-task-body');
  await expect(body).toBeVisible();
  await body.fill('E2E: inline conversation smoke. これはテスト用の最初のメッセージです。');
  await page.getByTestId('create-task-submit').click();

  // 3. The inline conversation pane (ChatPane) appears inside the space.
  //    No LLM => the agent won't progress, but the conversation UI renders.
  const conversation = page.getByTestId('space-conversation');
  await expect(conversation).toBeVisible();

  // 4. No navigation to the Tasks page. The app stays on the Spaces page
  //    (urlState serializes page=spaces) and sets the chat= param for the
  //    selected inline chat. It must NOT switch to page=tasks (which would
  //    drop page= entirely AND have no chat=).
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  await expect(page).toHaveURL(/[?&]chat=\d+/);
  expect(page.url()).toContain('chat=');
  expect(page.url()).not.toContain('page=tasks');
  // The Spaces detail (not the Tasks board) is still mounted.
  await expect(detail).toBeVisible();

  // 4b. The inline conversation exposes per-task detail tabs via the single
  //     in-place tab bar (data-testid="space-chat-tabs") so Progress is visible
  //     WITHOUT leaving for Tasks. Clicking 進捗 (Progress = the `activity` tab)
  //     swaps the ProgressTab content IN PLACE (no overlay/✕), then 会話 returns
  //     to the conversation — all while staying on page=spaces with the same
  //     chat= param. (A dedicated test below covers the tab-bar structure.)
  const progressTab = conversation.getByTestId('space-chat-tab-activity');
  await expect(progressTab).toBeVisible();
  await progressTab.click();
  // In-place ProgressTab content renders inside the shared tabpanel (no Tasks nav).
  await expect(conversation.getByTestId('progress-tab')).toBeVisible();
  await expect(progressTab).toHaveAttribute('aria-selected', 'true');
  // No overlay close affordance — the redesign dropped the overlay entirely.
  await expect(page.getByTestId('detail-panel-close')).toHaveCount(0);
  // Invariant: still inline on Spaces, same chat, never bounced to Tasks.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  await expect(page).toHaveURL(/[?&]chat=\d+/);
  expect(page.url()).not.toContain('page=tasks');
  // Return to the conversation via the 会話 tab so the rest of the flow
  // (creating a second chat) starts from chat again.
  await conversation.getByTestId('space-chat-tab-chat').click();
  await expect(conversation.getByTestId('progress-tab')).toHaveCount(0);

  // 5. Create a SECOND chat in the same space and assert the conversation
  //    switches when selecting the first one again from the list.
  const firstChatUrl = new URL(page.url());
  const firstChatId = firstChatUrl.searchParams.get('chat');
  expect(firstChatId).toBeTruthy();

  await page.getByTestId('space-new-chat-btn').click();
  const body2 = page.getByTestId('create-task-body');
  await expect(body2).toBeVisible();
  await body2.fill('E2E: second inline chat. 二つ目のチャットです。');
  await page.getByTestId('create-task-submit').click();

  // The second chat auto-selects: chat= now points at a different id.
  await expect
    .poll(() => new URL(page.url()).searchParams.get('chat'))
    .not.toBe(firstChatId);
  await expect(conversation).toBeVisible();
  const secondChatId = new URL(page.url()).searchParams.get('chat');

  // Selecting the first chat row again switches the conversation back to it.
  const firstRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${firstChatId}"]`);
  await expect(firstRow).toBeVisible();
  await firstRow.click();
  await expect
    .poll(() => new URL(page.url()).searchParams.get('chat'))
    .toBe(firstChatId);
  expect(secondChatId).not.toBe(firstChatId);
  await expect(conversation).toBeVisible();
  // Still on Spaces, still has a chat — never bounced to Tasks.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// Redesigned space Files tab (feat/space-files-explorer). The space-level ファイル
// tab is now an icon grid (space-files-grid) with a breadcrumb, an upload button +
// hidden <input type=file multiple>, and a dropzone. Uploading a file makes a
// space-file-tile appear; clicking a file tile opens the FilePreview modal
// (role="dialog") showing the content. Directory tiles navigate (breadcrumb
// updates) — covered here only by the no-nav invariant (we stay on page=spaces).
// No LLM is needed; this is pure file-management UI, so we never assert agent output.
test('space files: icon grid, upload appears as tile, click opens preview modal', async ({ page }) => {
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  // Same no-auth probe allowlist as the other space tests: with auth disabled the
  // UI's /api/auth/me probe 404s on purpose and falls back to the synthetic local
  // user — the expected no-auth signal, not a failure.
  const benignPaths = new Set(['/api/auth/me']);

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });

  // 1. Spaces → create a case space → open the space-level ファイル tab.
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();

  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2Eファイル-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();
  await caseRow.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();

  await page.getByTestId('space-tab-files').click();

  // 2. The grid + breadcrumb render (empty tree is fine).
  await expect(page.getByTestId('space-files-grid')).toBeVisible();
  await expect(page.getByTestId('space-files-breadcrumb')).toBeVisible();

  // 3. Upload a LARGE (>150kb) file through the hidden multiple-file input.
  //    This guards the P1 regression where a global express.json() (~100kb)
  //    shadowed the upload route's 50mb parser and 413'd any body >~100kb.
  //    A tiny buffer passed even with the bug; 160kb only passes after the fix.
  await page
    .locator('[data-testid="space-files-upload-input"]')
    .setInputFiles({
      name: 'e2e-large.txt',
      mimeType: 'text/plain',
      buffer: Buffer.alloc(160 * 1024, 'a'),
    });

  // 4. The large file shows up as a tile (upload + refetch take a moment).
  const largeTile = page.locator(
    '[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-large.txt"]',
  );
  await expect(largeTile).toBeVisible({ timeout: 15000 });

  // 5. Upload a separate TINY markdown file so the modal-preview assertion stays
  //    cheap and locale-independent.
  await page
    .locator('[data-testid="space-files-upload-input"]')
    .setInputFiles({
      name: 'e2e-upload.md',
      mimeType: 'text/markdown',
      buffer: Buffer.from('# E2E\nhello'),
    });
  const tile = page.locator(
    '[data-testid="space-file-tile"][data-kind="file"][data-name="e2e-upload.md"]',
  );
  await expect(tile).toBeVisible({ timeout: 15000 });

  // 6. Clicking the tile opens the FilePreview modal (role="dialog") showing the
  //    markdown content; close it by clicking the backdrop (locale-independent —
  //    the ✕ label is translated; the backdrop corner is not). The backdrop is
  //    the dialog's parent; its mousedown+click both land on the backdrop itself,
  //    which is exactly what useBackdropClose requires to close.
  await tile.click();
  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();
  await expect(dialog).toContainText('hello');
  const backdrop = dialog.locator('xpath=..');
  await backdrop.click({ position: { x: 4, y: 4 } });
  await expect(dialog).toHaveCount(0);

  // 6. No-nav invariant: file management never leaves Spaces for the Tasks board.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  await expect(detail).toBeVisible();

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// Feature B (feat/space-gpu-and-filelinks): the space rail carries a collapsible
// worker/GPU status footer. The header button (data-testid="space-worker-status-toggle")
// is always present in the rail (desktop width, hidden md:flex); clicking it expands
// the WorkerStatusWidget panel (data-testid="space-worker-status-panel") in place.
// We assert the toggle is visible at desktop width, then click it and assert the
// panel content becomes visible (aria-expanded flips true). No LLM is needed —
// the widget reads /api/workers/status which is always populated in worker mode.
test('space worker status: rail GPU panel toggles open', async ({ page }) => {
  // 1. Open the app at the default (desktop) viewport and go to Spaces.
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();

  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  // 2. The rail-bottom worker/GPU footer + its toggle header are present (the
  //    footer wrapper is always mounted; the toggle is visible at >=md width).
  const status = page.getByTestId('space-worker-status');
  await expect(status).toBeVisible();
  const toggle = page.getByTestId('space-worker-status-toggle');
  await expect(toggle).toBeVisible();
  // Collapsed by default: the inner widget panel is not yet rendered.
  await expect(toggle).toHaveAttribute('aria-expanded', 'false');
  await expect(page.getByTestId('space-worker-status-panel')).toHaveCount(0);

  // 3. Clicking the toggle expands the panel; the WorkerStatusWidget content
  //    mounts and aria-expanded flips to true.
  await toggle.click();
  await expect(toggle).toHaveAttribute('aria-expanded', 'true');
  await expect(page.getByTestId('space-worker-status-panel')).toBeVisible();
});

// AAO Gateway / LiteLLM front: a `proxy: true` worker fans out into a backends[]
// tree (collectWorkerStatuses, unit-tested separately). This test pins the *UI*
// half: given a proxy worker payload, the widget renders the proxy badge and one
// row per backend. The /api/local/dashboard/workers response is stubbed so the
// test is deterministic without standing up a real gateway + probe registry.
test('space worker status: proxy worker renders per-backend rows (AAO Gateway)', async ({ page }) => {
  await page.route('**/api/local/dashboard/workers', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        workers: [
          {
            id: 'gw', name: 'gw', roles: ['task'], state: 'running', proxy: true,
            backends: [
              { id: 'backend-a', state: 'running', busySlots: 1, totalSlots: 4, online: true },
              { id: 'backend-b', state: 'idle', busySlots: 0, totalSlots: 4, online: true },
            ],
          },
        ],
      }),
    }),
  );

  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();
  await expect(page.getByTestId('space-rail')).toBeVisible();

  // Open the footer; the WorkerStatusWidget mounts and fetches the stubbed list.
  await page.getByTestId('space-worker-status-toggle').click();
  await expect(page.getByTestId('space-worker-status-panel')).toBeVisible();

  // The proxy worker row + its proxy badge render.
  await expect(page.getByTestId('worker-row-gw')).toBeVisible();
  await expect(page.getByTestId('worker-proxy-badge')).toBeVisible();

  // Backends are expanded by default → one row per Gateway backend, with slot caption.
  await expect(page.getByTestId('worker-backend-backend-a')).toBeVisible();
  await expect(page.getByTestId('worker-backend-backend-b')).toBeVisible();
  await expect(page.getByTestId('worker-backend-backend-a')).toContainText('1/4');
});

// Feature A (feat/space-gpu-and-filelinks): the inline conversation now wraps its
// chat in an OutputPreviewProvider, so clicking a rendered output-path link
// (<a class="output-path-link" data-output-path="output/...">) opens the FilePreview
// modal (role="dialog") instead of doing nothing. To produce such a link
// deterministically WITHOUT an LLM, we post a user comment whose markdown body
// contains an output-path link; MarkdownText linkifies it (markdown link whose href
// matches OUTPUT_PATH_REGEX => data-output-path anchor). We also create the file
// itself (output/note.md, output-only PUT /files/content) so the .md preview resolves
// to content rather than a 404 (previewLocalFile fetches content for markdown and
// only opens the modal on success). We assert the dialog opens, then close it; we
// never assert agent output and we never leave page=spaces.
test('space output link: chat output-path link opens preview modal', async ({ page }) => {
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
  const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });

  // 1. Spaces → create a case space → start an inline chat (same flow as above).
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();

  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2Eリンク-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();
  await caseRow.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();

  await page.getByTestId('space-tab-chat').click();
  await page.getByTestId('space-new-chat-btn').click();
  const body = page.getByTestId('create-task-body');
  await expect(body).toBeVisible();
  await body.fill('E2E: 出力リンクのプレビュー確認。最初のメッセージ。');
  await page.getByTestId('create-task-submit').click();

  const conversation = page.getByTestId('space-conversation');
  await expect(conversation).toBeVisible();

  // 2. Capture the created task id from the chat= URL param.
  await expect(page).toHaveURL(/[?&]chat=\d+/);
  const taskId = new URL(page.url()).searchParams.get('chat');
  expect(taskId).toBeTruthy();

  // 3. Create output/note.md via the output-only inline-edit endpoint so the
  //    markdown preview resolves to content. A freshly created chat may have a
  //    queued/running job (which locks the editor with 409); with no LLM the job
  //    settles quickly, so poll the PUT until it succeeds.
  await expect.poll(async () => {
    const res = await page.request.put(`/api/local/tasks/${taskId}/files/content`, {
      data: { section: 'output', path: 'note.md', content: '# E2E note\nhello from output' },
    });
    return res.status();
  }, { timeout: 30_000 }).toBe(200);

  // 4. Post a user comment whose markdown body carries an output-path link. The
  //    link href (output/note.md) matches OUTPUT_PATH_REGEX, so MarkdownText emits
  //    <a class="output-path-link" data-output-path="output/note.md">.
  const commentRes = await page.request.post(`/api/local/tasks/${taskId}/comments`, {
    data: { body: '結果はこちら [note.md](output/note.md) です', author: 'user' },
  });
  expect(commentRes.ok()).toBeTruthy();

  // 5. The conversation polls comments; the output-path link appears. Click it.
  const link = conversation.locator('a.output-path-link[data-output-path="output/note.md"]');
  await expect(link).toBeVisible({ timeout: 15_000 });
  await link.click();

  // 6. The FilePreview modal (role="dialog") opens with the file content.
  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();
  await expect(dialog).toContainText('hello from output');

  // Close via the backdrop (locale-independent), same as the files test.
  const backdrop = dialog.locator('xpath=..');
  await backdrop.click({ position: { x: 4, y: 4 } });
  await expect(dialog).toHaveCount(0);

  // 7. No-nav invariant: clicking the link previewed in place, never bounced to Tasks.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  await expect(page).toHaveURL(/[?&]chat=\d+/);
  expect(page.url()).not.toContain('page=tasks');
  await expect(detail).toBeVisible();

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// Redesigned space-chat UX (feat/space-chat-ux). The inline conversation now
// carries a SINGLE in-place tab bar (data-testid="space-chat-tabs", role=tablist)
// with 会話 (chat) + 概要 (overview) + 進捗 (activity) + トレース (trace) and NO
// files tab (files live at the space level). Clicking a non-chat tab swaps the
// content IN PLACE — no overlay, no ✕ close button — and 会話 returns to the
// conversation. Throughout, we never leave for the Tasks page (page=spaces +
// chat= stay put). No LLM is configured in E2E, so we assert UI/structure only,
// never agent output.
test('space chat tabs: single in-place tab bar, overview in place, back to chat', async ({ page }) => {
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  // Same no-auth + live-session probe allowlist as the inline-chat test above.
  const benignPaths = new Set(['/api/auth/me', '/api/users/me/orgs', '/api/mcp/connections']);
  const benignPathRe = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname) && !benignPathRe.test(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });

  // 1. Spaces → create a case space → start an inline chat (same flow as above).
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();

  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2Eタブ-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();
  await caseRow.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();

  await page.getByTestId('space-tab-chat').click();
  await page.getByTestId('space-new-chat-btn').click();
  const body = page.getByTestId('create-task-body');
  await expect(body).toBeVisible();
  await body.fill('E2E: 単一タブの会話。インプレース切替を確認する最初のメッセージ。');
  await page.getByTestId('create-task-submit').click();

  const conversation = page.getByTestId('space-conversation');
  await expect(conversation).toBeVisible();

  // 2. The single tab bar (role=tablist) is visible and carries 会話 + 概要 but
  //    NOT a files tab in this bar.
  const tabs = conversation.getByTestId('space-chat-tabs');
  await expect(tabs).toBeVisible();
  await expect(tabs).toHaveAttribute('role', 'tablist');
  const chatTab = page.getByTestId('space-chat-tab-chat');
  const overviewTab = page.getByTestId('space-chat-tab-overview');
  await expect(chatTab).toBeVisible();
  await expect(overviewTab).toBeVisible();
  // No files tab inside this in-place bar (files are a space-level concern).
  await expect(tabs.getByTestId('space-chat-tab-files')).toHaveCount(0);
  // 会話 is the default-selected tab; the message composer (textarea) is present.
  await expect(chatTab).toHaveAttribute('aria-selected', 'true');
  await expect(conversation.locator('textarea')).toBeVisible();

  // 3. Click 概要 → its content renders IN PLACE. There is NO overlay and NO ✕
  //    close button (headerless DetailPanel ⇒ no detail-panel-close testid), and
  //    the overview tab becomes aria-selected. The conversation composer is gone.
  await overviewTab.click();
  await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
  await expect(chatTab).toHaveAttribute('aria-selected', 'false');
  // In place: the shared tabpanel (role=tabpanel) is still mounted inside the
  // same conversation container — not a separate overlay.
  await expect(conversation.getByRole('tabpanel')).toBeVisible();
  // No overlay close affordance anywhere on the page.
  await expect(page.getByTestId('detail-panel-close')).toHaveCount(0);
  // Composer is swapped out (overview content replaced the conversation in place).
  await expect(conversation.locator('textarea')).toHaveCount(0);

  // 4. Click 会話 → back to the conversation; composer returns, chat selected.
  await chatTab.click();
  await expect(chatTab).toHaveAttribute('aria-selected', 'true');
  await expect(overviewTab).toHaveAttribute('aria-selected', 'false');
  await expect(conversation.locator('textarea')).toBeVisible();

  // 5. No navigation to Tasks throughout: page=spaces + chat= held the entire time.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  await expect(page).toHaveURL(/[?&]chat=\d+/);
  expect(page.url()).toContain('chat=');
  expect(page.url()).not.toContain('page=tasks');
  await expect(detail).toBeVisible();

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// Space 設定 tab (feat/space-folder-settings). The SpaceDetail now carries a
// third tab "設定" (space-tab-settings) that renders SpaceSettings: a sub-nav
// (space-settings-nav-{agents,memory,pieces,skills,mcp,ssh}) + the matching
// panel. The file-based panels (agents/memory/pieces/skills) are space-scoped —
// their API calls carry ?spaceId=<id>, so AGENTS.md saved here lands under the
// space folder, NOT the per-user folder. We prove that by saving unique text via
// the AGENTS.md editor (Monaco), reloading, and asserting it persisted. MCP/SSH
// are now ALSO per-space (DB-backed, spec §11): selecting either renders its own
// space-scoped panel (no more "not space-scoped" disclaimer). No LLM is needed;
// this is pure settings UI, so we never assert agent output and never leave
// page=spaces.
test('space settings tab: AGENTS.md per-space persistence + MCP/SSH panels render', async ({ page }) => {
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  // Same no-auth probe allowlist as the other space tests. With auth disabled the
  // UI's /api/auth/me probe 404s on purpose; the MCP/SSH panels also probe
  // connection endpoints which 401/404 under no-auth and degrade to empty lists.
  const benignPaths = new Set([
    '/api/auth/me',
    '/api/users/me/orgs',
    '/api/mcp/connections',
    '/api/mcp/servers',
    '/api/mcp/user-servers',
    '/api/ssh/connections',
  ]);

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });

  // 1. Spaces → create a case space → open it.
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();

  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  await page.getByTestId('create-space-btn').click();
  const titleInput = page.getByTestId('space-title-input');
  await expect(titleInput).toBeVisible();
  const caseTitle = `E2E設定-${Date.now()}`;
  await titleInput.fill(caseTitle);
  await page.getByTestId('create-space-submit').click();
  await expect(titleInput).toHaveCount(0);

  const caseRow = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRow).toBeVisible();
  await caseRow.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();

  // 2. The 設定 tab is present; clicking it shows the settings sub-nav.
  const settingsTab = page.getByTestId('space-tab-settings');
  await expect(settingsTab).toBeVisible();
  await settingsTab.click();

  await expect(page.getByTestId('space-settings-nav-agents')).toBeVisible();
  await expect(page.getByTestId('space-settings-nav-memory')).toBeVisible();
  await expect(page.getByTestId('space-settings-nav-pieces')).toBeVisible();
  await expect(page.getByTestId('space-settings-nav-skills')).toBeVisible();
  await expect(page.getByTestId('space-settings-nav-mcp')).toBeVisible();
  await expect(page.getByTestId('space-settings-nav-ssh')).toBeVisible();
  await expect(page.getByTestId('space-settings-nav-browser')).toBeVisible();

  // 3. AGENTS.md panel (default-selected section). The Monaco editor mounts;
  //    type unique text into it and Save. A fixed unique marker lets the reload
  //    assertion below be exact and locale-independent.
  //    assertion below be exact and locale-independent.
  await page.getByTestId('space-settings-nav-agents').click();
  const editor = page.getByTestId('monaco-file-editor');
  await expect(editor).toBeVisible();

  // Monaco loads its editor lazily (@monaco-editor/react shows a loading state
  // first). Wait for the editor surface, then drive its hidden textarea: click
  // to focus, then type. The single short line lands in the (always-rendered)
  // .view-lines so we can assert it back after a reload.
  const marker = 'AGENTS_SPACE_PERSIST_42';
  const monacoSurface = editor.locator('.monaco-editor');
  await expect(monacoSurface).toBeVisible({ timeout: 20_000 });
  // The editing line (.view-lines) must be rendered before we focus + type.
  await expect(editor.locator('.view-lines')).toBeVisible({ timeout: 20_000 });
  await monacoSurface.click();
  // Replace the entire buffer with our unique marker (select-all → type). This
  // is build-agnostic — it doesn't depend on Monaco's internal textarea class.
  await page.keyboard.press('ControlOrMeta+A');
  await page.keyboard.type(`案件専用の指示 ${marker}`);
  // The typed marker shows up in the rendered Monaco view.
  await expect(editor.locator('.view-lines')).toContainText(marker, { timeout: 10_000 });

  // The Save button (data-testid="monaco-save") enables once dirty; click it and
  // wait for the PUT to the space-scoped agents-md endpoint to succeed (200).
  const saveBtn = page.getByTestId('monaco-save');
  await expect(saveBtn).toBeEnabled();
  const savePromise = page.waitForResponse(
    (r) => /\/api\/users\/me\/agents-md\?/.test(r.url()) && r.request().method() === 'PUT',
  );
  await saveBtn.click();
  const saveRes = await savePromise;
  expect(saveRes.status()).toBe(200);
  // The save URL must carry this space's id — that is what makes it per-space.
  expect(new URL(saveRes.url()).searchParams.get('spaceId')).toBeTruthy();

  // 4. Reload the page, re-open the same space → 設定 → AGENTS.md, and assert the
  //    editor re-hydrates with the saved text (proves server-side persistence
  //    under the space folder; a fresh GET re-fetches ?spaceId=...).
  await page.reload();
  await page.getByTestId('nav-spaces').click();
  await expect(rail).toBeVisible();
  const caseRowAfter = rail
    .locator('[data-testid="space-row"][data-space-kind="case"]')
    .filter({ hasText: caseTitle });
  await expect(caseRowAfter).toBeVisible();
  await caseRowAfter.click();
  await expect(detail).toBeVisible();
  await page.getByTestId('space-tab-settings').click();
  await page.getByTestId('space-settings-nav-agents').click();

  const editorAfter = page.getByTestId('monaco-file-editor');
  await expect(editorAfter).toBeVisible();
  // The persisted marker shows up in the rendered Monaco view.
  await expect(editorAfter.locator('.view-lines')).toContainText(marker, { timeout: 15_000 });

  // 5. MCP / SSH now render their own per-space panels when selected (the old
  //    "not space-scoped" disclaimer is gone). Light assertion — the space-scoped
  //    panel container mounts for each. (Isolation is proven by the dedicated test
  //    below.)
  await page.getByTestId('space-settings-nav-mcp').click();
  await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
  await page.getByTestId('space-settings-nav-ssh').click();
  await expect(page.getByTestId('space-ssh-panel')).toBeVisible();

  // 6. No-nav invariant: settings is in-place, never leaves Spaces for Tasks.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  await expect(detail).toBeVisible();

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// User Folder tab retirement (chore/remove-userfolder-tab). The top-level
// "User Folder" nav tab was removed: everything it hosted (AGENTS.md / memory /
// pieces / skills / MCP / SSH / browser) is now reachable from the personal
// workspace's 設定 tab (asserted by the settings test above), and the one orphan
// — Pets — moved into global Settings as its own section. This test proves the
// removal didn't strand anything: (1) no User Folder tab in the TopBar nav, and
// (2) the Pets settings section is present and selectable in global Settings.
// No LLM needed; this is pure navigation/structure.
test('userfolder tab removed: gone from nav, Pets reachable in Settings', async ({ page }) => {
  await page.goto('/ui');

  // 1. The top-level User Folder nav tab no longer exists (testid nav-userfolder
  //    was dropped along with the page id).
  await expect(page.getByTestId('nav-userfolder')).toHaveCount(0);
  await expect(page.getByText('User Folder', { exact: true })).toHaveCount(0);

  // 2. Settings still opens, and the Pets section (relocated here) is present in
  //    the sidebar and selectable. Pets is a user-scoped section, so it shows
  //    even with auth disabled (synthetic local user).
  await page.getByTestId('nav-settings').click();
  await expect(page).toHaveURL(/[?&]page=settings(&|$)/);

  const petsNav = page.getByTestId('settings-nav-pets');
  await expect(petsNav).toBeVisible();
  await petsNav.click();
  // The relocated Pets panel renders (PetsForm carries data-testid="pets-form").
  await expect(page.getByTestId('pets-form')).toBeVisible();
});

// Per-space MCP isolation (feat/space-mcp-ssh-isolation). MCP servers are
// DB-backed but scoped to a space: POST /api/mcp/user-servers accepts space_id,
// and GET /api/mcp/user-servers?spaceId=<id> returns ONLY that space's servers.
// The 設定 → MCP panel lists those (data-testid="space-mcp-list", each row a
// "space-mcp-item" carrying data-server-id / data-server-name).
//
// MCP is the deterministic choice over SSH here: registering an api_key server
// needs no OAuth dance and no private key, and the MCP subsystem is mounted in
// E2E via a fixed MCP_ENCRYPTION_KEY (see playwright.config.ts). The unreachable
// server URL only defers tool discovery (caught server-side), so register still
// returns 200. We then prove isolation TWO ways: (a) via the API — the server
// shows under ?spaceId=A but NOT ?spaceId=B; and (b) via the UI — it appears in
// space A's MCP list but is absent from space B's. No LLM is configured in E2E,
// so we assert structure only, never agent output.
test('space MCP isolation: server registered in A is visible in A, absent in B', async ({ page }) => {
  const fatalErrors: string[] = [];
  const benignAsset = /\.(ico|png|svg|map|webmanifest|json)$/i;
  // Same no-auth probe allowlist as the settings test. With auth disabled the
  // /api/auth/me probe 404s on purpose; the MCP panel also probes the admin
  // /api/mcp/servers + /api/mcp/connections endpoints which degrade to empty
  // lists under no-auth, and SSH (mounted alongside in the sub-nav) 404s since
  // its subsystem stays off — all benign, not failures.
  const benignPaths = new Set([
    '/api/auth/me',
    '/api/users/me/orgs',
    '/api/mcp/connections',
    '/api/mcp/servers',
    '/api/ssh/connections',
  ]);

  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      if (!benignAsset.test(pathname) && !benignPaths.has(pathname)) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });

  // 1. Create TWO case spaces (A and B) via the same API the create dialog uses.
  //    POST returns 201 with the full space row, so we capture each id.
  const stamp = Date.now();
  const resA = await page.request.post('/api/local/spaces', { data: { title: `E2E-MCP-A-${stamp}` } });
  expect(resA.status()).toBe(201);
  const spaceA = (await resA.json()) as { id: string };
  expect(spaceA.id).toBeTruthy();

  const resB = await page.request.post('/api/local/spaces', { data: { title: `E2E-MCP-B-${stamp}` } });
  expect(resB.status()).toBe(201);
  const spaceB = (await resB.json()) as { id: string };
  expect(spaceB.id).toBeTruthy();
  expect(spaceB.id).not.toBe(spaceA.id);

  // 2. Register an api_key MCP server INTO space A via the UI's register endpoint
  //    (POST /api/mcp/user-servers with space_id). api_key avoids the OAuth flow;
  //    the unreachable URL only defers tool discovery (caught server-side), so
  //    the register still returns ok.
  const mcpId = `e2e-mcp-${stamp}`;
  const mcpName = `spaceA-mcp-${stamp}`;
  const reg = await page.request.post('/api/mcp/user-servers', {
    data: {
      id: mcpId,
      name: mcpName,
      url: 'https://example.invalid/mcp',
      authKind: 'api_key',
      staticToken: 'e2e-static-token',
      space_id: spaceA.id,
    },
  });
  expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBeTruthy();

  // 3. API-level isolation: the server appears under ?spaceId=A but NOT ?spaceId=B.
  const listA = (await (await page.request.get(
    `/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceA.id)}`,
  )).json()) as { servers: Array<{ id: string; name: string }> };
  expect(listA.servers.some((s) => s.id === mcpId)).toBeTruthy();

  const listB = (await (await page.request.get(
    `/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceB.id)}`,
  )).json()) as { servers: Array<{ id: string; name: string }> };
  expect(listB.servers.some((s) => s.id === mcpId)).toBeFalsy();

  // 4. UI: open Spaces, select space A by its id, go to 設定 → MCP. The space MCP
  //    list renders and contains our server (proves the panel reads ?spaceId=A).
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();
  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  const rowA = rail.locator(`[data-testid="space-row"][data-space-id="${spaceA.id}"]`);
  await expect(rowA).toBeVisible();
  await rowA.click();

  const detail = page.getByTestId('space-detail');
  await expect(detail).toBeVisible();
  await page.getByTestId('space-tab-settings').click();
  await page.getByTestId('space-settings-nav-mcp').click();

  // The space-scoped MCP panel mounts and its list carries our per-space item.
  await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
  const listAUi = page.getByTestId('space-mcp-list');
  await expect(listAUi).toBeVisible({ timeout: 15_000 });
  await expect(
    listAUi.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`),
  ).toBeVisible({ timeout: 15_000 });
  await expect(listAUi).toContainText(mcpName);

  // 5. THE KEY ASSERTION — open space B → 設定 → MCP. The server registered in A
  //    must NOT appear here. Space B has no per-space MCP servers, so its list
  //    wrapper isn't rendered (empty-state); either way our item must be absent.
  const rowB = rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`);
  await expect(rowB).toBeVisible();
  await rowB.click();
  await expect(detail).toBeVisible();
  await page.getByTestId('space-tab-settings').click();
  await page.getByTestId('space-settings-nav-mcp').click();

  // The space-B MCP panel renders, but our server is nowhere in it.
  await expect(page.getByTestId('space-mcp-panel')).toBeVisible();
  await expect(
    page.locator(`[data-testid="space-mcp-item"][data-server-id="${mcpId}"]`),
  ).toHaveCount(0);
  await expect(page.getByTestId('space-mcp-panel')).not.toContainText(mcpName);

  // No-nav invariant + no fatal client errors.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ════════════════════════════════════════════════════════════════════════════
// Space task/settings feature parity (feat/space-feature-parity-tests).
//
// Phase 1 added a per-chat action toolbar (data-testid="space-chat-actions") to
// the inline SpaceConversation, with delete / share / continue controls reusing
// the SAME implementation as the Tasks-page DetailHeader. These
// tests prove each action works INSIDE a space without ever bouncing to the
// Tasks page, plus the 概要 (overview) feedback flow and every 設定 sub-tab.
//
// No LLM is configured in E2E, so a queued job fails fast and settles terminal
// (waitForTerminalJob), which is exactly what unlocks Continue + Overview
// feedback. Throughout we assert page=spaces is held (the no-nav invariant the
// user cares about) and no fatal client errors leak.
// ════════════════════════════════════════════════════════════════════════════

// 1. DELETE — the user-reported gap. The space-chat toolbar exposes a delete
//    button (data-testid="space-chat-delete"); clicking it pops a confirm()
//    dialog, and on accept the chat is removed and the conversation returns to
//    the empty list state (the chat row disappears). This guards the exact bug
//    the user hit: a missing delete button in space chats.
test('space chat DELETE: toolbar delete removes the chat and returns to the list', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2E削除');
  const { conversation, taskId } = await startInlineChat(
    page,
    'E2E: 削除テスト用チャット。最初のメッセージ。',
  );

  // The per-chat action toolbar and its delete button are present.
  const actions = conversation.getByTestId('space-chat-actions');
  await expect(actions).toBeVisible();
  const deleteBtn = conversation.getByTestId('space-chat-delete');
  await expect(deleteBtn).toBeVisible();

  // The chat row for this task exists in the space's chat list before delete.
  const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${taskId}"]`);
  await expect(chatRow).toBeVisible();

  // Accept the confirm() dialog that guards the destructive delete.
  page.once('dialog', (dialog) => {
    expect(dialog.type()).toBe('confirm');
    void dialog.accept();
  });
  await deleteBtn.click();

  // The chat disappears from the list AND the inline conversation closes
  // (SpaceConversation calls onBack(0) on delete → spaceTaskId null → no
  // conversation). The chat= URL param is dropped.
  await expect(chatRow).toHaveCount(0, { timeout: 15_000 });
  await expect(conversation).toHaveCount(0);
  await expect.poll(() => new URL(page.url()).searchParams.get('chat')).toBeFalsy();

  // No-nav invariant: deletion stayed on Spaces, never bounced to Tasks.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 2. CANCEL — start a chat (which queues a job) and cancel it from the ChatPane
//    stop button, asserting the job lands in the cancelled state.
//
//    The stop button only appears while the job is BUSY (running / dispatching /
//    waiting_subtasks), and POST /cancel only acts on a running job (it 404s on a
//    still-queued job). Reaching `running` requires the worker to actually pick
//    up the job, which needs a reachable LLM. The E2E env configures NO provider,
//    so the job sits `queued` and the stop button never appears — we skip the
//    active assertion in that case (clearly reported) and run it for real only
//    when an LLM IS reachable. Either way we first prove the conversation UI and
//    no-nav invariant hold.
test('space chat CANCEL: ChatPane stop button cancels a running task', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2Eキャンセル');
  const { conversation, taskId } = await startInlineChat(
    page,
    'E2E: キャンセルテスト。長めの作業を想定した最初のメッセージ。',
  );

  // The ChatPane stop button shows while the job is busy. Label is i18n
  // (English in the E2E browser locale, Japanese otherwise), so match either.
  const stopBtn = conversation.getByRole('button', { name: /^(Stop|停止)$/ });
  const becameBusy = await stopBtn
    .waitFor({ state: 'visible', timeout: 20_000 })
    .then(() => true)
    .catch(() => false);

  test.skip(
    !becameBusy,
    'no LLM reachable: the worker never runs the job, so it stays queued and the ' +
      'ChatPane stop control never appears — cancel needs a running job to act on',
  );

  // Cancel from the ChatPane, then assert the job is cancelled (terminal).
  await stopBtn.click();
  const status = await waitForTerminalJob(page, taskId, 20_000);
  expect(status).toBe('cancelled');

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 3. SHARE — the toolbar share button (data-testid="space-chat-share", reusing
//    ShareButton) publishes a share token. Clicking it issues POST
//    /api/local/tasks/:id/share and writes the link to the clipboard; the button
//    swaps to copy/stop affordances. Clipboard isn't observable headless, so we
//    assert persistence the robust way: the task's shareToken becomes set
//    server-side (poll the detail API) and the toolbar re-renders with the copy
//    control (aria-label = "共有リンクをコピー").
test('space chat SHARE: toolbar share publishes a share token', async ({ page, context }) => {
  // ShareButton's onSuccess copies the link to the clipboard; headless Chromium
  // denies clipboard writes by default, which would surface as a fatal pageerror.
  // Grant the permission so the (benign) copy succeeds and the share flow itself
  // is what we measure. Best-effort: some channels don't expose the permission.
  await context.grantPermissions(['clipboard-write']).catch(() => {});
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2E共有');
  const { conversation, taskId } = await startInlineChat(
    page,
    'E2E: 共有テスト用チャット。最初のメッセージ。',
  );

  // When unshared, ShareButton puts the testid on the publish <button> itself
  // (it switches to a wrapping <div> only once a token exists). So the testid IS
  // the publish button here — assert + click it directly.
  const share = conversation.getByTestId('space-chat-share');
  await expect(share).toBeVisible();
  await expect(share).toHaveAttribute('aria-label', /Create public link|公開リンクを発行/);
  await share.click();

  // The share token is persisted server-side (the source of truth for the link).
  await expect
    .poll(async () => (await fetchTask(page, taskId))?.shareToken ?? null, { timeout: 15_000 })
    .toBeTruthy();

  // And the toolbar re-renders into the shared state: a copy-link control now
  // exists where the publish button was (aria-label is i18n, match either).
  await expect(share.getByRole('button', { name: /Copy share link|共有リンクをコピー/ })).toBeVisible({
    timeout: 15_000,
  });

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 4. CONTINUE-WITH-PIECE — the toolbar Continue button (data-testid=
//    "space-chat-continue", reusing ContinueButton) is gated: disabled until the
//    latest job is TERMINAL, enabled after. We always assert the wiring that IS
//    observable without an LLM — the button is present and DISABLED while the job
//    is non-terminal (queued). Then, IF the job reaches a terminal state (needs a
//    reachable LLM; the E2E env has none, so this branch is skipped + reported),
//    we click it and assert ContinueWithPieceDialog opens with piece options.
test('space chat CONTINUE: button gated on terminal job; opens ContinueWithPieceDialog when terminal', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2E継続');
  const { conversation, taskId } = await startInlineChat(
    page,
    'E2E: 継続テスト用チャット。最初のメッセージ。',
  );

  // Always-verifiable: the Continue button exists and is DISABLED while the job
  // is non-terminal (proves the terminal-gating in ContinueButton is wired).
  const continueBtn = conversation.getByTestId('space-chat-continue');
  await expect(continueBtn).toBeVisible();
  await expect(continueBtn).toBeDisabled();

  // Active path: only reachable when the job goes terminal (needs an LLM).
  const status = await waitForTerminalJob(page, taskId);
  test.skip(
    status === '',
    'no LLM reachable: the job stays queued (never terminal), so Continue stays ' +
      'disabled — the dialog-open assertion needs a terminal job',
  );

  await expect(continueBtn).toBeEnabled({ timeout: 15_000 });
  await continueBtn.click();

  // ContinueWithPieceDialog opens: the piece <select> + instruction textarea
  // (stable ids) are present, and the select carries ≥1 piece option.
  const pieceSelect = page.locator('#continue-piece');
  await expect(pieceSelect).toBeVisible();
  await expect(page.locator('#continue-instruction')).toBeVisible();
  await expect.poll(async () => pieceSelect.locator('option').count()).toBeGreaterThan(0);

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 5. VISIBILITY — the toolbar visibility <select> (data-testid="space-chat-visibility")
//    drives updateLocalTask({ visibility }). Changing it to "public" persists;
//    after a full reload + reselecting the chat, the select retains "public".
//    ("org" is intentionally NOT used here: the server rejects org visibility
//    (400) unless the user belongs to the scoped org, and the no-auth synthetic
//    local user has no orgs — so "public" is the env-appropriate persistence
//    proof. The org branch is covered by backend visibility tests.)
test('space chat VISIBILITY: change to public persists across reload', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2E可視性');
  const { conversation, taskId } = await startInlineChat(
    page,
    'E2E: 可視性テスト用チャット。最初のメッセージ。',
  );

  const visibility = conversation.getByTestId('space-chat-visibility');
  await expect(visibility).toBeVisible();
  await expect(visibility).toHaveValue('private');

  // Wait for the PATCH so we don't assert before the server commits the change.
  // (updateLocalTask uses PATCH /api/local/tasks/:id.)
  const patchPromise = page.waitForResponse(
    (r) => new RegExp(`/api/local/tasks/${taskId}$`).test(r.url()) && r.request().method() === 'PATCH',
  );
  await visibility.selectOption('public');
  const patchRes = await patchPromise;
  expect(patchRes.status()).toBe(200);
  await expect(visibility).toHaveValue('public');

  // Reload, reopen the same space + chat via URL state (space= selects the space,
  // chat= opens the inline conversation), and assert the select retained public
  // (proves server-side persistence, not just local component state).
  await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`);
  await expect(detail).toBeVisible();
  const visibilityAfter = page.getByTestId('space-conversation').getByTestId('space-chat-visibility');
  await expect(visibilityAfter).toBeVisible({ timeout: 15_000 });
  await expect(visibilityAfter).toHaveValue('public', { timeout: 15_000 });

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 6. FEEDBACK via 概要 tab — the in-place 概要 (overview) tab renders OverviewTab.
//    We always assert that switching to 概要 swaps the OverviewTab content in
//    place (the conversation composer disappears; the shared tabpanel mounts) —
//    verifiable without an LLM. The FeedbackPanel itself only renders for a
//    COMPLETE (succeeded / failed) job, which needs a reachable LLM; the E2E env
//    has none, so the rating round-trip is skipped + reported and runs for real
//    only against a live LLM.
test('space chat FEEDBACK: 概要 tab renders OverviewTab; rating round-trip saves + persists', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2Eフィードバック');
  const { conversation, taskId } = await startInlineChat(
    page,
    'E2E: フィードバックテスト用チャット。最初のメッセージ。',
  );

  // Always-verifiable: switch to 概要 → OverviewTab renders in place. The
  // composer (textarea) is swapped out and the shared tabpanel is mounted.
  const overviewTab = conversation.getByTestId('space-chat-tab-overview');
  await expect(overviewTab).toBeVisible();
  await overviewTab.click();
  await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
  const panel = conversation.getByRole('tabpanel');
  await expect(panel).toBeVisible();
  await expect(conversation.locator('textarea')).toHaveCount(0);

  // Active path: the FeedbackPanel only renders once the job is succeeded/failed.
  const status = await waitForTerminalJob(page, taskId);
  test.skip(
    status !== 'succeeded' && status !== 'failed',
    'no LLM reachable: the job never completes (succeeded/failed), so the ' +
      'Overview FeedbackPanel does not render — the rating round-trip needs a completed job',
  );

  // The 👍 rating button appears once the job is complete. The 👍 emoji is
  // locale-independent (the text label beside it is i18n), so match the emoji.
  const good = panel.getByRole('button', { name: /👍/ });
  await expect(good).toBeVisible({ timeout: 15_000 });
  await good.click();
  // Pick a good-rating tag (GOOD_TAGS are literal JP strings, not i18n), then
  // submit. The Submit label IS i18n, so match English/Japanese. Wait for the
  // feedback PUT to commit before asserting the saved state.
  await panel.getByRole('button', { name: '出力の精度が高い' }).click();
  const fbPromise = page.waitForResponse(
    (r) => /\/api\/local\/tasks\/\d+\/feedback$/.test(r.url()) && r.request().method() === 'PUT',
  );
  await panel.getByRole('button', { name: /^(Submit|送信)$/ }).click();
  const fbRes = await fbPromise;
  expect(fbRes.status()).toBe(200);

  // Saved → the panel collapses to its summary view: a change button appears
  // (only rendered when feedback exists and we're not editing). Label is i18n.
  await expect(panel.getByRole('button', { name: /^(Change|変更)$/ })).toBeVisible({ timeout: 15_000 });

  // Persistence: the rating is on the task server-side.
  await expect
    .poll(async () => (await fetchTask(page, taskId))?.feedbackRating ?? null)
    .toBe('good');

  // Reload + reopen via URL state → the 概要 panel still shows the saved summary.
  await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`);
  await expect(detail).toBeVisible();
  const conversationAfter = page.getByTestId('space-conversation');
  await conversationAfter.getByTestId('space-chat-tab-overview').click();
  await expect(
    conversationAfter.getByRole('tabpanel').getByRole('button', { name: /^(Change|変更)$/ }),
  ).toBeVisible({ timeout: 15_000 });

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 7a. SETTINGS sub-tabs render — every 設定 sub-tab (agents/memory/pieces/skills/
//     mcp/ssh) opens its space-scoped panel without a crash or fatal 404. The
//     AGENTS.md Monaco round-trip + MCP/SSH panels are covered by the dedicated
//     settings test above; here we additionally prove pieces + skills + memory
//     panels mount their content (stable in-panel markers), so a regression that
//     blanks any one sub-tab is caught.
test('space settings sub-tabs: agents/memory/pieces/skills/mcp/ssh all render their panel', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2E設定タブ');
  await page.getByTestId('space-tab-settings').click();

  // agents → Monaco editor mounts.
  await page.getByTestId('space-settings-nav-agents').click();
  await expect(page.getByTestId('monaco-file-editor')).toBeVisible({ timeout: 20_000 });

  // memory → the MemoryEntriesPanel header + new-entry button render. The header
  // text is i18n (English in the E2E browser locale, Japanese otherwise).
  await page.getByTestId('space-settings-nav-memory').click();
  await expect(page.getByText(/Memory entries|メモリエントリ/)).toBeVisible({ timeout: 15_000 });
  await expect(page.getByRole('button', { name: /New entry|新しいエントリ/ })).toBeVisible();

  // pieces → the SpacePiecesPanel list (Default / Custom sections) renders. These
  // section labels are literal (non-i18n) strings; match the built-in piece list.
  await page.getByTestId('space-settings-nav-pieces').click();
  await expect(page.getByText('Default', { exact: true })).toBeVisible({ timeout: 15_000 });
  await expect(page.getByText('Custom', { exact: true })).toBeVisible();

  // skills → the SkillsForm heading renders (the install-from-URL input is
  // intentionally hidden in space mode — the Git install path isn't space-scoped
  // yet — so the unique <h2>Skills</h2> heading is the panel-mounted marker).
  await page.getByTestId('space-settings-nav-skills').click();
  await expect(page.getByRole('heading', { name: 'Skills' })).toBeVisible({ timeout: 15_000 });

  // mcp / ssh → their space-scoped panel containers render.
  await page.getByTestId('space-settings-nav-mcp').click();
  await expect(page.getByTestId('space-mcp-panel')).toBeVisible({ timeout: 15_000 });
  await page.getByTestId('space-settings-nav-ssh').click();
  await expect(page.getByTestId('space-ssh-panel')).toBeVisible({ timeout: 15_000 });

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 7b. MEMORY per-space persistence + isolation — memory is space-scoped: the
//     panel reads/writes /api/local/memory/entries?spaceId=<id>. We seed an entry
//     into space A via the same space-scoped API the panel uses, open A's 設定 →
//     メモリ and assert the entry renders (proves the panel reads ?spaceId=A and
//     the write persisted to the space folder), then open space B's メモリ and
//     assert the entry is absent (isolation). Mirrors the MCP-isolation pattern.
test('space settings MEMORY: entry saved in space A persists + is absent in space B', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  const stamp = Date.now();
  const resA = await page.request.post('/api/local/spaces', { data: { title: `E2E-MEM-A-${stamp}` } });
  expect(resA.status()).toBe(201);
  const spaceA = (await resA.json()) as { id: string };
  const resB = await page.request.post('/api/local/spaces', { data: { title: `E2E-MEM-B-${stamp}` } });
  expect(resB.status()).toBe(201);
  const spaceB = (await resB.json()) as { id: string };
  expect(spaceB.id).not.toBe(spaceA.id);

  // Seed a memory entry into space A via the space-scoped API (same endpoint the
  // panel's modal posts to). A unique name lets the UI assertion be exact.
  const memName = `e2e-mem-${stamp}`;
  const putRes = await page.request.put(
    `/api/local/memory/entries/${memName}?spaceId=${encodeURIComponent(spaceA.id)}`,
    { data: { description: 'E2E space-scoped memory', type: 'reference', body: 'space A only' } },
  );
  expect(putRes.status(), `memory PUT failed: ${putRes.status()} ${await putRes.text()}`).toBe(200);

  // Open Spaces, select A → 設定 → メモリ; the seeded entry renders (persisted +
  // read back via ?spaceId=A).
  await page.goto('/ui');
  await page.getByTestId('nav-spaces').click();
  const rail = page.getByTestId('space-rail');
  await expect(rail).toBeVisible();

  const detail = page.getByTestId('space-detail');
  await rail.locator(`[data-testid="space-row"][data-space-id="${spaceA.id}"]`).click();
  await expect(detail).toBeVisible();
  await page.getByTestId('space-tab-settings').click();
  await page.getByTestId('space-settings-nav-memory').click();
  await expect(page.getByText(memName, { exact: true })).toBeVisible({ timeout: 15_000 });

  // Open space B → 設定 → メモリ; the entry from A must NOT appear (isolation).
  await rail.locator(`[data-testid="space-row"][data-space-id="${spaceB.id}"]`).click();
  await expect(detail).toBeVisible();
  await page.getByTestId('space-tab-settings').click();
  await page.getByTestId('space-settings-nav-memory').click();
  await expect(page.getByText(/Memory entries|メモリエントリ/)).toBeVisible({ timeout: 15_000 });
  await expect(page.getByText(memName, { exact: true })).toHaveCount(0);

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ── Space calendar tab (feat/space-calendar, plan stage 4) ───────────────────
//
// The per-space カレンダー tab renders a self-built month grid (no external
// calendar lib) plus a day panel with three sections (タスク / 変更ファイル /
// 予定) and event CRUD. These E2E reuse the shared create-space / inline-chat
// helpers above and the same fatal-error tracker — they assert the calendar
// behaves on the real orchestrator (auth off => synthetic 'local' user, who is
// the space owner and therefore can edit events).
//
// 'today' is computed in the viewer's local frame exactly like the UI's
// localToday(), so the `space-cal-day-{date}` testid matches the cell the UI
// renders for today (the grid + day endpoint share the tz_offset).
function e2eLocalToday(): string {
  const d = new Date();
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const dd = String(d.getDate()).padStart(2, '0');
  return `${d.getFullYear()}-${mm}-${dd}`;
}

test('space calendar: tab renders month grid + month nav', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2Eカレンダー表示');

  await page.getByTestId('space-tab-calendar').click();
  await expect(page.getByTestId('space-calendar')).toBeVisible();
  await expect(page.getByTestId('space-cal-grid')).toBeVisible();

  // Today's cell is present in the current-month grid.
  const today = e2eLocalToday();
  await expect(page.getByTestId(`space-cal-day-${today}`)).toBeVisible();

  // Month nav changes the YYYY年M月 header. Capture, step forward, assert it
  // differs, step back, assert it returns.
  const title = page.getByTestId('space-cal-title');
  const initial = (await title.textContent())?.trim();
  expect(initial).toMatch(/\d{4}年\d{1,2}月/);
  await page.getByTestId('space-cal-next').click();
  await expect(title).not.toHaveText(initial!);
  await page.getByTestId('space-cal-prev').click();
  await expect(title).toHaveText(initial!);

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space calendar: add event → list + day badge + persists across reload', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2Eカレンダー予定');

  await page.getByTestId('space-tab-calendar').click();
  await expect(page.getByTestId('space-cal-grid')).toBeVisible();

  const today = e2eLocalToday();
  const todayCell = page.getByTestId(`space-cal-day-${today}`);
  // Badge baseline: today's event count badge (📌N) is absent before adding.
  await expect(todayCell.getByText(/^📌/)).toHaveCount(0);

  // Open today's day panel and add an event.
  await todayCell.click();
  const panel = page.getByTestId('space-cal-day-panel');
  await expect(panel).toBeVisible();

  await panel.getByTestId('space-cal-add-event-btn').click();
  const form = page.getByTestId('space-cal-add-event');
  await expect(form).toBeVisible();
  const eventTitle = `打合せ-${Date.now()}`;
  await form.getByTestId('space-cal-event-title').fill(eventTitle);
  await form.getByTestId('space-cal-event-time').fill('14:30');
  await form.getByTestId('space-cal-event-description').fill('E2E: テスト予定');
  await form.getByTestId('space-cal-event-save').click();

  // The new event appears in the 予定 list (with its time) after invalidation.
  await expect(panel.getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
  await expect(panel.getByText('14:30')).toBeVisible();

  // The day cell event badge increments to 📌1 after the month query invalidates.
  await expect(todayCell.getByText('📌1')).toBeVisible({ timeout: 10_000 });

  // Persists across a full reload (server-backed, not just client state).
  await page.reload();
  await page.getByTestId('space-tab-calendar').click();
  await expect(page.getByTestId(`space-cal-day-${today}`).getByText('📌1')).toBeVisible({ timeout: 10_000 });
  await page.getByTestId(`space-cal-day-${today}`).click();
  await expect(page.getByTestId('space-cal-day-panel').getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 });

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space calendar: edit + delete an event', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2Eカレンダー編集');

  await page.getByTestId('space-tab-calendar').click();
  const today = e2eLocalToday();
  const todayCell = page.getByTestId(`space-cal-day-${today}`);
  await todayCell.click();
  const panel = page.getByTestId('space-cal-day-panel');
  await expect(panel).toBeVisible();

  // Create one event.
  await panel.getByTestId('space-cal-add-event-btn').click();
  const origTitle = `元タイトル-${Date.now()}`;
  await page.getByTestId('space-cal-event-title').fill(origTitle);
  await page.getByTestId('space-cal-event-save').click();
  await expect(panel.getByText(origTitle, { exact: true })).toBeVisible({ timeout: 10_000 });

  // Grab the rendered event id from its row testid (space-cal-event-{id}).
  const row = panel.locator('[data-testid^="space-cal-event-"]').filter({ hasText: origTitle }).first();
  const rowTestId = await row.getAttribute('data-testid');
  const eventId = rowTestId!.replace('space-cal-event-', '');
  expect(eventId).toMatch(/^\d+$/);

  // Edit: open the edit form, change the title, save.
  await panel.getByTestId(`space-cal-event-edit-${eventId}`).click();
  const newTitle = `編集後-${Date.now()}`;
  await page.getByTestId('space-cal-event-title').fill(newTitle);
  await page.getByTestId('space-cal-event-save').click();
  await expect(panel.getByText(newTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
  await expect(panel.getByText(origTitle, { exact: true })).toHaveCount(0);

  // Delete: confirm the window.confirm() then assert the row disappears + badge clears.
  page.once('dialog', (d) => d.accept());
  await panel.getByTestId(`space-cal-event-delete-${eventId}`).click();
  await expect(panel.getByTestId(`space-cal-event-${eventId}`)).toHaveCount(0, { timeout: 10_000 });
  await expect(todayCell.getByText(/^📌/)).toHaveCount(0, { timeout: 10_000 });

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space calendar: day panel lists tasks created in the space', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2Eカレンダータスク');

  // Create an inline chat (task) in this space — it is created "today", so it
  // must surface in today's calendar day panel タスク section.
  const { taskId } = await startInlineChat(page, 'E2E: カレンダー日詳細用のタスク。');
  expect(taskId).toBeTruthy();

  // Switch to the calendar tab and open today's cell.
  await page.getByTestId('space-tab-calendar').click();
  await expect(page.getByTestId('space-cal-grid')).toBeVisible();
  const today = e2eLocalToday();
  const todayCell = page.getByTestId(`space-cal-day-${today}`);
  // The task badge (💬N) should be present for today.
  await expect(todayCell.getByText(/^💬/)).toBeVisible({ timeout: 10_000 });
  await todayCell.click();

  const panel = page.getByTestId('space-cal-day-panel');
  await expect(panel).toBeVisible();
  // The created task is listed and clicking it opens the chat (switches tab).
  const taskBtn = panel.getByTestId(`space-cal-task-${taskId}`);
  await expect(taskBtn).toBeVisible({ timeout: 10_000 });
  await taskBtn.click();
  await expect(page.getByTestId('space-conversation')).toBeVisible();
  await expect(page).toHaveURL(/[?&]chat=\d+/);

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ── Space members panel (feat/shared-space, stage 3) ─────────────────────────
//
// The 設定 → メンバー sub-tab renders SpaceMembersPanel. Membership management
// (invite/role/remove) is gated on canManage, which in the E2E env (auth OFF)
// resolves to TRUE: useAuthState() is `disabled`, so the synthetic 'local' user
// can manage. The invite button therefore renders.
//
// ENV NOTE — no owner row in this harness: a case space created under no-auth is
// stored with owner_id = NULL (POST /api/local/spaces: `viewer.id === 'local' ?
// null : viewer.id`), and that space owns no space_members rows yet. The members
// GET only synthesizes an owner row when space.ownerId is set, so under no-auth
// the list is legitimately empty — there is no real user identity to attribute
// the owner row to. We therefore assert the realistic no-auth surface: the panel
// mounts, the empty list is handled without a crash, and the invite flow shows
// the no-auth sharing hint (pickable returns [] -> "認証を有効化すると…"). The
// owner-badge / role-select / remove controls are exercised by the API + (auth-on)
// component tests; they cannot be reached in this auth-off harness. A future
// auth-on harness (a second seeded user) would assert invite -> member appears.
//
// /api/users/pickable returns 200 with [] in no-auth (mirrors /api/admin/users),
// so it is NOT a fatal HTTP error — the shared SPACE_BENIGN_PATHS set is enough.
test('space settings MEMBERS: panel renders + invite shows no-auth hint', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2Eメンバー');
  await page.getByTestId('space-tab-settings').click();

  // メンバー sub-tab → the panel mounts with its header.
  await page.getByTestId('space-settings-nav-members').click();
  const panel = page.getByTestId('space-members-panel');
  await expect(panel).toBeVisible({ timeout: 15_000 });
  await expect(panel.getByRole('heading', { name: 'メンバー' })).toBeVisible();

  // canManage is true (no-auth), so the invite button renders. Clicking it opens
  // the picker, which — with an empty pickable list — shows the no-auth sharing
  // hint instead of a user list.
  const inviteBtn = panel.getByTestId('space-member-invite');
  await expect(inviteBtn).toBeVisible();
  await inviteBtn.click();
  const picker = panel.getByTestId('space-member-picker');
  await expect(picker).toBeVisible({ timeout: 15_000 });
  await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toBeVisible();

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ── Space source library (feat/space-source-library, stage 2) ────────────────
//
// The Files tab now renders a curated "ソース" group at the top (testid
// space-sources) sourced from source/index.jsonl. Two surfaces are observable
// WITHOUT an LLM:
//   1. EMPTY-STATE: a fresh space has no source/index.jsonl → the GET on
//      .../files/content?path=source/index.jsonl 404s, fetchSpaceSourceIndex
//      swallows it and returns [], and the group shows the empty hint.
//      NOTE: that 404 is EXPECTED here, so we use a local response tracker that
//      treats the space source-index content 404 as benign (the shared
//      trackFatalErrors would otherwise flag it; SPACE_BENIGN_ASSET only covers
//      .json, not .jsonl).
//   2. POPULATED: we seed source/research.md + source/index.jsonl via the space
//      file upload API (POST /files/upload, path=source), then assert the group
//      lists the entry with its title and origin host.
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64');

// The curated ソース list view was removed (#6): a fresh space's ファイル tab no
// longer renders the source list or its empty-state. (Previously this asserted
// the empty-state was visible; now it must be ABSENT.) The Files grid renders
// instead, and the UI no longer fetches source/index.jsonl on open.
test('space sources: curated ソース list view (incl. empty-state) is absent on a fresh space', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2Eソース空');
  await page.getByTestId('space-tab-files').click();

  // The Files grid renders, but the old ソース list view + its empty-state are gone.
  await expect(page.getByTestId('space-files-grid')).toBeVisible({ timeout: 15_000 });
  await expect(page.getByTestId('space-sources')).toHaveCount(0);
  await expect(page.getByTestId('space-sources-empty')).toHaveCount(0);

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// The curated ソース list view was removed (#6). The source/ folder + its
// index.jsonl provenance STILL accumulate (agent WebFetch/DownloadFile → source/),
// but the Files UI no longer renders a special list — source/ is just a normal
// folder in the grid. This test proves: (1) the seeded source/ upload succeeds,
// (2) the old space-sources list view is gone, (3) source/ is browsable as a
// regular folder and its file shows up there.
test('space sources: source/ folder still accumulates and is browsable; curated ソース list view removed', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  const { spaceId } = await createAndOpenCaseSpace(page, 'E2Eソース');

  // Seed a source file + provenance index into the space's source/ dir via the
  // same upload API the Files tab uses (path=source places files under source/).
  const indexLine = JSON.stringify({
    file: 'research.md',
    url: 'https://example.com/article',
    title: 'リサーチ記事タイトル',
    fetched_at: '2026-06-19T10:00:00Z',
    tool: 'WebFetch',
    bytes: 2048,
  });
  const upRes = await page.request.post(`/api/local/spaces/${spaceId}/files/upload`, {
    data: {
      path: 'source',
      files: [
        { name: 'research.md', contentBase64: b64('# 調査結果\n\n本文。') },
        { name: 'index.jsonl', contentBase64: b64(`${indexLine}\n`) },
      ],
    },
  });
  expect(upRes.status(), `upload failed: ${upRes.status()} ${await upRes.text()}`).toBe(200);

  // Open the Files tab. The curated ソース list view (#6) is GONE.
  await page.getByTestId('space-tab-files').click();
  await expect(page.getByTestId('space-files-grid')).toBeVisible({ timeout: 15_000 });
  await expect(page.getByTestId('space-sources')).toHaveCount(0);

  // source/ appears as a normal folder tile; navigate into it.
  const sourceDir = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="source"]');
  await expect(sourceDir).toBeVisible({ timeout: 15_000 });
  await sourceDir.click();

  // The seeded research.md shows in the source/ folder (index.jsonl stays hidden
  // — it is filtered out of the raw file window as before).
  await expect(
    page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="research.md"]'),
  ).toBeVisible({ timeout: 15_000 });
  await expect(
    page.locator('[data-testid="space-file-tile"][data-name="index.jsonl"]'),
  ).toHaveCount(0);

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ── Space settings → ブラウザ section (workstream 2, stage 2) ─────────────────
//
// The 設定 sub-nav now carries a "ブラウザ" section (space-settings-nav-browser)
// rendering SpaceBrowserPanel: three groups — ブラウザセッション
// (space-browser-sessions), ブラウザマクロ (space-browser-macros), 録画
// (space-browser-recordings). All three are space-scoped (their fetches carry
// ?spaceId=<id>). In the auth-off harness there are no real sessions/macros/
// recordings for a fresh space, so we assert the panel mounts and each group
// shows its in-product empty state without a crash or fatal 4xx/5xx. The
// per-user-DEK "作成者のみ利用可" badge and management gating are exercised by the
// component/API tests; this render test guards against a regression that blanks
// the whole section.
//
// ENV NOTE — benign endpoints under no-auth: the panel lists profiles via
// GET /api/browser-sessions/profiles?spaceId=… and macros/recordings via
// GET /api/users/me/folder/{list}?subdir=…&spaceId=…. Under no-auth the synthetic
// 'local' user is the space owner, so these return 200 with empty arrays — no new
// benign paths are needed beyond the shared set. The add-session control renders
// because canManage is TRUE in the auth-off harness (useAuthState() = disabled).
test('space settings BROWSER: panel + three groups render with empty states', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await createAndOpenCaseSpace(page, 'E2Eブラウザ');
  await page.getByTestId('space-tab-settings').click();

  // The ブラウザ sub-tab is present and selecting it mounts SpaceBrowserPanel.
  const browserNav = page.getByTestId('space-settings-nav-browser');
  await expect(browserNav).toBeVisible();
  await browserNav.click();

  const panel = page.getByTestId('space-browser-panel');
  await expect(panel).toBeVisible({ timeout: 15_000 });

  // 1. セッション group: header + empty state.
  const sessions = page.getByTestId('space-browser-sessions');
  await expect(sessions).toBeVisible();
  await expect(sessions.getByRole('heading', { name: 'ブラウザセッション' })).toBeVisible();
  await expect(sessions).toContainText('このワークスペースにはまだブラウザセッションがありません');
  // canManage (no-auth) → the add-session control renders.
  await expect(page.getByTestId('space-browser-add-session')).toBeVisible();

  // 2. マクロ group: header + empty state.
  const macros = page.getByTestId('space-browser-macros');
  await expect(macros).toBeVisible();
  await expect(macros.getByRole('heading', { name: 'ブラウザマクロ' })).toBeVisible();
  await expect(macros).toContainText('このワークスペースにはまだブラウザマクロがありません');

  // 3. 録画 group: header + empty state.
  const recordings = page.getByTestId('space-browser-recordings');
  await expect(recordings).toBeVisible();
  await expect(recordings.getByRole('heading', { name: '録画' })).toBeVisible();
  await expect(recordings).toContainText('このワークスペースにはまだ録画がありません');

  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ════════════════════════════════════════════════════════════════════════════
// Workspace management + file management (feat/workspace-files-mgmt).
//
// - File delete with multi-select in the ファイル tab (#8).
// - Workspace rename via the header pencil (#8).
// - Workspace (case) delete via the header trash → returns to the list (#8).
// - The curated ソース list view was removed (#6); source/ still accumulates as
//   a normal folder, but the SpaceSources list (data-testid="space-sources") is
//   gone from the Files UI.
// All run with auth disabled; no LLM needed (pure management UI).
// ════════════════════════════════════════════════════════════════════════════

test('space files DELETE: select a file and delete it (gone); ソース list view is removed', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  const { detail } = await createAndOpenCaseSpace(page, 'E2E削除');

  await page.getByTestId('space-tab-files').click();
  await expect(page.getByTestId('space-files-grid')).toBeVisible();

  // The curated ソース list view (#6) must NOT be rendered anymore.
  await expect(page.getByTestId('space-sources')).toHaveCount(0);

  // Upload a file so we have something to delete.
  await page
    .locator('[data-testid="space-files-upload-input"]')
    .setInputFiles({ name: 'delete-me.txt', mimeType: 'text/plain', buffer: Buffer.from('bye') });
  const tile = page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="delete-me.txt"]');
  await expect(tile).toBeVisible({ timeout: 15000 });

  // Select it via its checkbox → the bulk 削除 button appears.
  await page.getByTestId('space-file-select-delete-me.txt').check();
  const deleteSelected = page.getByTestId('space-files-delete-selected');
  await expect(deleteSelected).toBeVisible();

  // Accept the confirm() dialog guarding the destructive delete.
  page.once('dialog', (dialog) => { expect(dialog.type()).toBe('confirm'); void dialog.accept(); });
  await deleteSelected.click();

  // The tile disappears after the delete + refetch.
  await expect(tile).toHaveCount(0, { timeout: 15000 });

  // No-nav invariant.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  await expect(detail).toBeVisible();
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space files DELETE: multi-select all + per-file trash icon', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2E複数削除');
  await page.getByTestId('space-tab-files').click();
  await expect(page.getByTestId('space-files-grid')).toBeVisible();

  // Upload two files.
  await page.locator('[data-testid="space-files-upload-input"]').setInputFiles([
    { name: 'one.txt', mimeType: 'text/plain', buffer: Buffer.from('1') },
    { name: 'two.txt', mimeType: 'text/plain', buffer: Buffer.from('2') },
  ]);
  await expect(
    page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'),
  ).toBeVisible({ timeout: 15000 });

  // "Select all" checks both; the count reflects 2 選択中.
  await page.getByTestId('space-files-select-all').check();
  await expect(page.getByTestId('space-files-selected-count')).toContainText('2');

  // Delete the whole selection.
  page.once('dialog', (d) => void d.accept());
  await page.getByTestId('space-files-delete-selected').click();
  await expect(
    page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="one.txt"]'),
  ).toHaveCount(0, { timeout: 15000 });
  await expect(
    page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'),
  ).toHaveCount(0);

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space files DOWNLOAD: bulk zip + per-tile download', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2Eダウンロード');
  await page.getByTestId('space-tab-files').click();
  await expect(page.getByTestId('space-files-grid')).toBeVisible();

  // Upload two files to download.
  await page.locator('[data-testid="space-files-upload-input"]').setInputFiles([
    { name: 'one.txt', mimeType: 'text/plain', buffer: Buffer.from('1') },
    { name: 'two.txt', mimeType: 'text/plain', buffer: Buffer.from('2') },
  ]);
  await expect(
    page.locator('[data-testid="space-file-tile"][data-kind="file"][data-name="two.txt"]'),
  ).toBeVisible({ timeout: 15000 });

  // Bulk: select all → ダウンロード → a single files.zip download fires.
  await page.getByTestId('space-files-select-all').check();
  const [zipDownload] = await Promise.all([
    page.waitForEvent('download'),
    page.getByTestId('space-files-download-selected').click(),
  ]);
  expect(zipDownload.suggestedFilename()).toBe('files.zip');

  // Per-tile: the download anchor downloads that single file (name preserved).
  const [single] = await Promise.all([
    page.waitForEvent('download'),
    page.getByTestId('space-file-download-one.txt').click(),
  ]);
  expect(single.suggestedFilename()).toBe('one.txt');

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space RENAME: header pencil renames the workspace and the title updates', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  const { detail, caseTitle, rail } = await createAndOpenCaseSpace(page, 'E2E改名');

  // Open the inline rename editor, change the title, save.
  await page.getByTestId('space-rename').click();
  const input = page.getByTestId('space-rename-input');
  await expect(input).toBeVisible();
  const renamed = `${caseTitle}-改`;
  await input.fill(renamed);
  await page.getByTestId('space-rename-save').click();

  // The header reflects the new title, and the rail row updates too.
  await expect(detail).toContainText(renamed);
  await expect(
    rail.locator('[data-testid="space-row"][data-space-kind="case"]').filter({ hasText: renamed }),
  ).toBeVisible({ timeout: 15000 });

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space DELETE: case workspace delete returns to the list (selection cleared)', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  const { caseTitle, rail } = await createAndOpenCaseSpace(page, 'E2EWS削除');

  // Click the header trash → inline confirm → 削除する.
  await page.getByTestId('space-delete').click();
  const confirm = page.getByTestId('space-delete-confirm');
  await expect(confirm).toBeVisible();
  await confirm.click();

  // The space row disappears from the rail and the selection is cleared
  // (space= URL param dropped → back to the list / empty detail).
  await expect(
    rail.locator('[data-testid="space-row"][data-space-kind="case"]').filter({ hasText: caseTitle }),
  ).toHaveCount(0, { timeout: 15000 });
  await expect.poll(() => new URL(page.url()).searchParams.get('space')).toBeFalsy();

  // Still on Spaces, never bounced to Tasks.
  await expect(page).toHaveURL(/[?&]page=spaces(&|$)/);
  expect(page.url()).not.toContain('page=tasks');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// ── Workspace apps tab (feat/workspace-apps-stage2) ──────────────────────────
// The "アプリ" tab lists apps = subfolders under apps/ that contain an
// index.html, reading an optional app.json for title/description, and launches
// the selected one in the sandboxed AppRunner.

test('space apps: empty-state on a fresh workspace', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  await createAndOpenCaseSpace(page, 'E2EApps空');

  await page.getByTestId('space-tab-apps').click();
  await expect(page.getByTestId('space-apps')).toBeVisible();
  // A fresh case workspace has no apps/ folder → in-product empty-state.
  await expect(page.getByTestId('space-apps-empty')).toBeVisible({ timeout: 15_000 });
  await expect(page.getByTestId('space-apps-empty')).toContainText('ワークスペース・アプリを作って');
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

test('space apps: seeded app lists with its title and opens in the AppRunner', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);
  const { spaceId } = await createAndOpenCaseSpace(page, 'E2EApps起動');

  // Seed apps/demo/index.html (+ app.json) via the restricted write endpoint
  // (only apps/ + output/ are writable, which is exactly where apps live).
  const html =
    '<!doctype html><meta charset="utf-8"><title>demo</title>' +
    '<body><h1 id="hdr">デモアプリ</h1><script>document.getElementById("hdr").textContent="ready";</script>';
  const w1 = await page.request.post(`/api/local/spaces/${spaceId}/files/write`, {
    data: { path: 'apps/demo/index.html', content: html },
  });
  expect(w1.ok()).toBeTruthy();
  const w2 = await page.request.post(`/api/local/spaces/${spaceId}/files/write`, {
    data: {
      path: 'apps/demo/app.json',
      content: JSON.stringify({ title: 'デモ・アプリ', description: '起動テスト用' }),
    },
  });
  expect(w2.ok()).toBeTruthy();

  // Open the アプリ tab and assert the seeded app appears with its manifest title.
  await page.getByTestId('space-tab-apps').click();
  await expect(page.getByTestId('space-apps')).toBeVisible();
  const card = page.getByTestId('space-app-demo');
  await expect(card).toBeVisible({ timeout: 15_000 });
  await expect(card).toContainText('デモ・アプリ');
  await expect(card).toContainText('起動テスト用');

  // 開く → AppRunner mounts (sandbox iframe overlay).
  await page.getByTestId('space-app-open-demo').click();
  await expect(page.getByTestId('app-runner')).toBeVisible({ timeout: 15_000 });

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
